use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use ratatui::backend::TestBackend;
use ratatui::Terminal;
use crate::app::{App, Mode};
use crate::config::Config;
use crate::{handle_key, ui};
struct Sim {
app: App,
term: Terminal<TestBackend>,
quit: bool,
}
impl Sim {
fn new(root: &std::path::Path) -> Sim {
Sim::with_config(root, Config::default())
}
fn with_config(root: &std::path::Path, cfg: Config) -> Sim {
let app = App::new(root.to_path_buf(), cfg).expect("App::new");
let term = Terminal::new(TestBackend::new(90, 26)).expect("terminal");
let mut sim = Sim {
app,
term,
quit: false,
};
sim.draw();
sim
}
fn draw(&mut self) {
let app = &mut self.app;
self.term.draw(|f| ui::render(f, app)).expect("draw");
}
fn press(&mut self, code: KeyCode, mods: KeyModifiers) {
let res = handle_key(&mut self.app, KeyEvent::new(code, mods));
match res {
Ok(true) => self.quit = true,
Ok(false) => {}
Err(e) => self.app.flash = Some(format!("error: {e:#}")),
}
self.draw();
}
fn key(&mut self, c: char) {
self.press(KeyCode::Char(c), KeyModifiers::NONE);
}
fn keys(&mut self, s: &str) {
for c in s.chars() {
self.key(c);
}
}
fn ctrl(&mut self, c: char) {
self.press(KeyCode::Char(c), KeyModifiers::CONTROL);
}
fn enter(&mut self) {
self.press(KeyCode::Enter, KeyModifiers::NONE);
}
fn esc(&mut self) {
self.press(KeyCode::Esc, KeyModifiers::NONE);
}
fn tab(&mut self) {
self.press(KeyCode::Tab, KeyModifiers::NONE);
}
fn screen(&self) -> String {
let buf = self.term.backend().buffer();
let w = buf.area.width as usize;
let mut out = String::new();
for (i, cell) in buf.content().iter().enumerate() {
out.push_str(cell.symbol());
if (i + 1) % w == 0 {
out.push('\n');
}
}
out
}
#[track_caller]
fn see(&self, needle: &str) {
assert!(
self.screen().contains(needle),
"画面に「{needle}」が見えるはず:\n{}",
self.screen()
);
}
#[track_caller]
fn dont_see(&self, needle: &str) {
assert!(
!self.screen().contains(needle),
"画面に「{needle}」は見えないはず:\n{}",
self.screen()
);
}
#[track_caller]
fn select(&mut self, suffix: &str) {
let i = self
.app
.entries
.iter()
.position(|e| e.path.to_string_lossy().ends_with(suffix))
.unwrap_or_else(|| panic!("entry not found: {suffix}"));
self.app.selected = i;
self.draw();
}
}
fn sandbox(name: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!("konoma_e2e_{name}"));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
fn seed_files(dir: &std::path::Path) {
std::fs::create_dir_all(dir.join("src")).unwrap();
std::fs::write(dir.join("src/lib.rs"), "pub fn hi() {}\n").unwrap();
std::fs::write(
dir.join("readme.md"),
"# Title\n\nhello [go](./notes.txt)\n\n- [ ] task one\n",
)
.unwrap();
std::fs::write(dir.join("notes.txt"), "alpha\nbeta\ngamma\ndelta\n").unwrap();
std::fs::write(dir.join("data.csv"), "a,b\n1,2\n3,4\n").unwrap();
std::fs::write(dir.join(".hidden.txt"), "secret\n").unwrap();
}
fn canon(dir: &std::path::Path) -> std::path::PathBuf {
dir.canonicalize().unwrap()
}
#[cfg(test)]
fn cfg_code_bg_none() -> Config {
let mut cfg = Config::default();
cfg.ui.theme.code_bg = "none".into();
cfg
}
#[cfg(test)]
fn md_preview(cfg: Config, name: &str, body: &str) -> (Sim, std::path::PathBuf) {
let dir = sandbox(name);
std::fs::write(dir.join(format!("{name}.md")), body).unwrap();
let root = canon(&dir);
let mut s = Sim::with_config(&root, cfg);
s.select(&format!("{name}.md"));
s.enter();
(s, dir)
}
#[test]
fn e2e_tree_navigation_and_ends() {
let dir = sandbox("tree_nav");
seed_files(&dir);
let mut s = Sim::new(&canon(&dir));
s.see("TREE");
let first = s.app.selected;
s.key('j');
assert_eq!(s.app.selected, first + 1, "j で1つ下へ");
s.key('k');
assert_eq!(s.app.selected, first, "k で戻る");
s.key('G');
assert_eq!(s.app.selected, s.app.entries.len() - 1, "G で末尾");
s.key('g');
assert_eq!(s.app.selected, 0, "g で先頭");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn e2e_tree_filter_narrows_and_esc_clears() {
let dir = sandbox("tree_filter");
seed_files(&dir);
let mut s = Sim::new(&canon(&dir));
let all = s.app.entries.len();
s.key('/');
s.keys("csv");
s.see("data.csv");
s.dont_see("readme.md");
assert!(s.app.entries.len() < all, "絞り込みで件数が減る");
s.enter(); s.see("data.csv");
s.esc(); assert_eq!(s.app.entries.len(), all, "Esc で全件に戻る");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn e2e_tree_hidden_toggle() {
let dir = sandbox("tree_hidden");
seed_files(&dir);
let mut s = Sim::new(&canon(&dir));
s.dont_see(".hidden.txt");
s.key('.');
s.see(".hidden.txt");
s.key('.');
s.dont_see(".hidden.txt");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn e2e_tree_descend_and_parent() {
let dir = sandbox("tree_descend");
seed_files(&dir);
let root = canon(&dir);
let mut s = Sim::new(&root);
s.select("src");
s.key('l');
assert!(
s.app.root.ends_with("src"),
"l でディレクトリへ潜る(root 変更)"
);
s.see("lib.rs");
s.key('h');
assert_eq!(s.app.root, root, "h で親へ戻る");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn e2e_sort_menu_switches_order() {
let dir = sandbox("sort");
seed_files(&dir);
let mut s = Sim::new(&canon(&dir));
s.key('s');
s.see("sort"); s.key('s'); assert!(!s.app.is_sort_menu());
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn e2e_file_create_via_dialog() {
let dir = sandbox("file_create");
seed_files(&dir);
let mut s = Sim::new(&canon(&dir));
s.select("notes.txt");
s.key(' ');
s.key('n'); s.keys("fresh.txt");
s.enter();
assert!(
dir.join("fresh.txt").exists(),
"作成したファイルがディスクに存在"
);
s.see("fresh.txt");
s.select("src");
s.key(' ');
s.key('n');
s.keys("inner.txt");
s.enter();
assert!(
dir.join("src/inner.txt").exists(),
"ディレクトリ選択時はその中に作成される"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn e2e_file_duplicate_in_place() {
let dir = sandbox("file_duplicate");
seed_files(&dir);
let mut s = Sim::new(&canon(&dir));
s.select("notes.txt");
s.key(' ');
s.key('D');
assert!(
dir.join("notes copy.txt").exists(),
"複製 notes copy.txt がディスクに存在"
);
assert_eq!(
std::fs::read(dir.join("notes copy.txt")).unwrap(),
std::fs::read(dir.join("notes.txt")).unwrap(),
"複製の内容が元と同一"
);
s.see("notes copy.txt"); std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn e2e_file_rename_via_dialog() {
let dir = sandbox("file_rename");
seed_files(&dir);
let mut s = Sim::new(&canon(&dir));
s.select("notes.txt");
s.key(' ');
s.key('r');
for _ in 0..30 {
s.press(KeyCode::Backspace, KeyModifiers::NONE);
}
s.keys("renamed.txt");
s.enter();
assert!(dir.join("renamed.txt").exists(), "新名で存在");
assert!(!dir.join("notes.txt").exists(), "旧名は消える");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn e2e_file_delete_cancel_and_permanent() {
let dir = sandbox("file_delete");
seed_files(&dir);
let mut s = Sim::new(&canon(&dir));
s.select("notes.txt");
s.key(' ');
s.key('d');
s.key('n');
assert!(dir.join("notes.txt").exists(), "取消で残る");
s.key(' ');
s.key('d');
s.key('!');
assert!(!dir.join("notes.txt").exists(), "完全削除で消える");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn e2e_preview_text_roundtrip() {
let dir = sandbox("preview_text");
seed_files(&dir);
let mut s = Sim::new(&canon(&dir));
s.select("notes.txt");
s.enter();
assert_eq!(s.app.mode, Mode::Preview);
s.see("PREVIEW");
s.see("alpha");
s.key('q');
assert_eq!(s.app.mode, Mode::Tree, "q でツリーへ戻る");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn e2e_markdown_link_follow_and_back() {
let dir = sandbox("md_link");
seed_files(&dir);
let mut s = Sim::new(&canon(&dir));
s.select("readme.md");
s.enter();
s.see("Title");
s.tab(); s.enter(); s.see("alpha");
assert!(
s.app
.preview_path
.as_deref()
.is_some_and(|p| p.ends_with("notes.txt")),
"リンク先へ遷移"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn e2e_markdown_checkbox_space_toggles_file() {
let dir = sandbox("md_checkbox");
seed_files(&dir);
let mut s = Sim::new(&canon(&dir));
s.select("readme.md");
s.enter();
s.tab(); s.tab(); s.key(' ');
let src = std::fs::read_to_string(dir.join("readme.md")).unwrap();
assert!(
src.contains("- [x] task one"),
"Space でファイルに書き戻る: {src}"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn e2e_csv_table_cell_navigation() {
let dir = sandbox("csv_nav");
seed_files(&dir);
let mut s = Sim::new(&canon(&dir));
s.select("data.csv");
s.enter();
s.see("TABLE");
let r0 = s.app.table_cursor();
s.key('j');
s.key('l');
let r1 = s.app.table_cursor();
assert_ne!(r0, r1, "hjkl でセルカーソルが動く");
s.key('q');
assert_eq!(s.app.mode, Mode::Tree);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn e2e_decorated_markdown_search() {
let dir = sandbox("md_search");
let mut src = String::from("# Guide\n\n");
for i in 0..30 {
src.push_str(&format!("padding paragraph {i}\n\n"));
}
src.push_str("the **haystack** contains a needle here\n\n");
for i in 30..60 {
src.push_str(&format!("padding paragraph {i}\n\n"));
}
src.push_str("日本語の行にも needle があります\n");
std::fs::write(dir.join("guide.md"), &src).unwrap();
let mut s = Sim::new(&canon(&dir));
s.select("guide.md");
s.enter();
s.key('/');
s.keys("needle");
s.enter();
assert_eq!(
s.app.search_status(),
Some((1, 2)),
"装飾 md で 2 件見つかる"
);
assert!(!s.app.is_raw_source(), "装飾表示のまま検索できる");
s.see("needle");
s.key('n');
assert_eq!(s.app.search_status(), Some((2, 2)), "n で 2 件目へ");
s.see("needle");
s.key('q');
assert_eq!(s.app.mode, Mode::Tree);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn e2e_table_search_jumps_to_matching_cell() {
let dir = sandbox("table_search");
std::fs::write(
dir.join("data.csv"),
"name,city\nalice,kyoto\nbob,osaka\ncarol,kyoto\n",
)
.unwrap();
let mut s = Sim::new(&canon(&dir));
s.select("data.csv");
s.enter();
s.see("TABLE");
assert_eq!(s.app.table_cursor(), (0, 0));
s.key('/');
s.keys("kyoto");
s.enter();
assert_eq!(s.app.table_cursor(), (0, 1), "最初の一致セルへ");
assert_eq!(s.app.search_status(), Some((1, 2)), "2 件中 1 件目");
s.key('n');
assert_eq!(s.app.table_cursor(), (2, 1), "n で次の一致セルへ");
s.key('n');
assert_eq!(s.app.table_cursor(), (0, 1), "wrap して先頭へ");
s.esc();
assert_eq!(s.app.preview_search_query(), None, "Esc で検索が解除される");
assert!(!s.app.table_cell_is_hit(0, 1), "一致の強調も消える");
assert_eq!(s.app.mode, Mode::Preview, "1回目の Esc では表に留まる");
s.esc();
assert_eq!(s.app.mode, Mode::Tree, "2回目の Esc でツリーへ戻る");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn e2e_preview_search_moves_between_matches() {
let dir = sandbox("preview_search");
let big: String = (0..300)
.map(|i| {
if i % 100 == 0 {
format!("needle line {i}\n")
} else {
format!("filler {i}\n")
}
})
.collect();
std::fs::write(dir.join("big.txt"), &big).unwrap();
let mut s = Sim::new(&canon(&dir));
s.select("big.txt");
s.enter();
s.key('/');
s.keys("needle");
s.enter();
s.see("needle line 0"); s.key('n'); s.see("needle line 100");
s.key('N'); s.see("needle line 0");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn e2e_tabs_full_lifecycle() {
let dir = sandbox("tabs_life");
seed_files(&dir);
let mut s = Sim::new(&canon(&dir));
s.keys("tt"); assert_eq!(s.app.tab_count(), 3);
s.see("3:");
s.key('1');
assert_eq!(s.app.active_tab_index(), 0, "数字で直行");
s.key(']');
assert_eq!(s.app.active_tab_index(), 1, "] で次");
s.key('T');
assert!(s.app.is_tab_list());
s.see("Tabs");
s.key('j');
s.enter();
assert!(!s.app.is_tab_list(), "Enter で切替+一覧が閉じる");
assert_eq!(s.app.active_tab_index(), 2);
s.key('T');
s.key('d');
assert_eq!(s.app.tab_count(), 2, "一覧の d で選択タブを閉じる");
s.key('q'); assert!(!s.app.is_tab_list());
s.key('w');
assert_eq!(s.app.tab_count(), 2, "w は未割当");
s.key('q');
assert_eq!(s.app.tab_count(), 1, "tree の q でタブが閉じる");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn e2e_ctrl_t_opens_selected_in_new_tab() {
let dir = sandbox("newtab_open");
std::fs::write(dir.join("note.txt"), "HELLO_NEWTAB body\n").unwrap();
std::fs::create_dir_all(dir.join("sub")).unwrap();
std::fs::write(dir.join("sub/inside.txt"), "INSIDE_MARK\n").unwrap();
let mut s = Sim::new(&canon(&dir));
s.select("note.txt");
assert_eq!(s.app.tab_count(), 1);
s.ctrl('t');
assert_eq!(s.app.tab_count(), 2, "Ctrl-t で新規タブができる");
assert_eq!(s.app.active_tab_index(), 1, "新規タブがアクティブ");
s.see("HELLO_NEWTAB");
s.key('[');
assert_eq!(s.app.active_tab_index(), 0);
s.see("note.txt");
s.dont_see("HELLO_NEWTAB");
s.select("sub");
s.ctrl('t');
assert_eq!(s.app.tab_count(), 3);
s.see("inside.txt"); std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn e2e_bookmarks_set_list_jump() {
let root = sandbox("bm_flow");
let proj = root.join("proj");
std::fs::create_dir_all(&proj).unwrap();
seed_files(&proj);
let proj = canon(&proj);
let mut s = Sim::new(&proj);
s.app.bookmarks = crate::bookmarks::Bookmarks::with_base(root.join("cfg"), &proj);
s.select("notes.txt");
s.keys("mb"); s.see("bookmarked");
s.key('\'');
s.see("Bookmarks");
s.key('b'); assert_eq!(s.app.mode, Mode::Preview);
assert!(s
.app
.preview_path
.as_deref()
.is_some_and(|p| p.ends_with("notes.txt")));
s.keys("mz");
assert!(
s.app
.bookmarks
.get('z')
.is_some_and(|p| p.ends_with("notes.txt")),
"プレビュー中の登録対象は表示中ファイル"
);
s.key('\'');
s.see("Bookmarks");
let before =
s.app.bookmarks.get('b').is_some() as u8 + s.app.bookmarks.get('z').is_some() as u8;
assert_eq!(before, 2, "削除前は b/z の2件");
s.ctrl('d');
let after = s.app.bookmarks.get('b').is_some() as u8 + s.app.bookmarks.get('z').is_some() as u8;
assert_eq!(after, 1, "Ctrl-d で選択中の1件が削除される");
std::fs::remove_dir_all(&root).ok();
}
#[test]
fn e2e_bookmark_overwrite_prompts_confirm_then_applies() {
let root = sandbox("bm_overwrite");
let proj = root.join("proj");
std::fs::create_dir_all(&proj).unwrap();
seed_files(&proj);
let proj = canon(&proj);
let mut s = Sim::new(&proj);
s.app.bookmarks = crate::bookmarks::Bookmarks::with_base(root.join("cfg"), &proj);
s.select("notes.txt");
s.keys("mb");
s.see("bookmarked");
let notes = s.app.bookmarks.get('b').expect("b set");
s.select("data.csv");
s.keys("mb");
s.see("OVERWRITE?");
s.see("Overwrite bookmark");
assert_eq!(
s.app.bookmarks.get('b'),
Some(notes.clone()),
"確認前は元のまま"
);
s.key('n');
s.see("canceled");
assert_eq!(
s.app.bookmarks.get('b'),
Some(notes.clone()),
"取消で notes.txt のまま"
);
s.select("data.csv");
s.keys("mb");
s.see("OVERWRITE?");
s.key('y');
assert!(
s.app
.bookmarks
.get('b')
.is_some_and(|p| p.ends_with("data.csv")),
"y で data.csv へ上書き"
);
std::fs::remove_dir_all(&root).ok();
}
#[test]
fn e2e_bookmark_overwrite_off_applies_silently() {
let root = sandbox("bm_overwrite_off");
let proj = root.join("proj");
std::fs::create_dir_all(&proj).unwrap();
seed_files(&proj);
let proj = canon(&proj);
let mut cfg = Config::default();
cfg.ui.confirm_bookmark_overwrite = false;
let mut s = Sim::with_config(&proj, cfg);
s.app.bookmarks = crate::bookmarks::Bookmarks::with_base(root.join("cfg"), &proj);
s.select("notes.txt");
s.keys("mb");
s.select("data.csv");
s.keys("mb");
s.dont_see("OVERWRITE?");
assert!(
s.app
.bookmarks
.get('b')
.is_some_and(|p| p.ends_with("data.csv")),
"confirm オフは即上書き"
);
std::fs::remove_dir_all(&root).ok();
}
#[cfg(feature = "git")]
fn seed_repo(dir: &std::path::Path) {
let run = |args: &[&str]| {
let out = std::process::Command::new("git")
.current_dir(dir)
.args(args)
.output()
.unwrap();
assert!(out.status.success(), "git {args:?}: {out:?}");
};
run(&["init", "-q", "."]);
run(&["config", "user.email", "t@t"]);
run(&["config", "user.name", "t"]);
std::fs::write(dir.join("a.rs"), "fn a() {}\n").unwrap();
std::fs::write(dir.join("b.rs"), "fn b() {}\n").unwrap();
run(&["add", "-A"]);
run(&[
"commit",
"-q",
"-m",
"init",
"-m",
"First body paragraph.",
"-m",
"Second body paragraph.",
]);
run(&["branch", "feature-x"]);
std::fs::write(dir.join("a.rs"), "fn a() { let _x = 1; }\n").unwrap();
std::fs::write(dir.join("new.txt"), "untracked\n").unwrap();
}
#[cfg(feature = "git")]
#[test]
fn e2e_git_hub_stage_and_diff() {
let dir = sandbox("git_hub");
seed_repo(&dir);
let mut s = Sim::new(&canon(&dir));
s.key('o');
s.see("a.rs");
s.see("new.txt");
s.key('s');
s.key('u');
s.enter();
s.see("diff");
s.key('q');
assert!(s.app.is_git_view(), "diff の q でハブへ戻る");
s.key('q');
assert_eq!(s.app.mode, Mode::Tree);
std::fs::remove_dir_all(&dir).ok();
}
#[cfg(feature = "git")]
#[test]
fn e2e_git_diff_from_tree_and_cycle_files() {
let dir = sandbox("git_diff_cycle");
seed_repo(&dir);
let mut s = Sim::new(&canon(&dir));
s.select("a.rs");
s.key('d');
s.see("diff");
s.see("(");
let before = s.screen();
s.key('n');
assert_ne!(before, s.screen(), "n で別ファイルの diff へ");
s.key('q');
assert_eq!(s.app.mode, Mode::Tree);
std::fs::remove_dir_all(&dir).ok();
}
#[cfg(feature = "git")]
#[test]
fn e2e_changed_filter_and_jumps() {
let dir = sandbox("changed_filter");
seed_repo(&dir);
let mut s = Sim::new(&canon(&dir));
s.key('C');
s.see("CHANGED");
assert!(s.app.changed_filter());
s.see("a.rs");
s.see("new.txt");
s.key('C');
assert!(!s.app.changed_filter(), "C 再押下で解除");
std::fs::remove_dir_all(&dir).ok();
}
#[cfg(feature = "git")]
#[test]
fn e2e_follow_mode_chip_toggles() {
let dir = sandbox("follow_chip");
seed_repo(&dir);
let mut s = Sim::new(&canon(&dir));
s.key('F');
assert!(s.app.follow_enabled());
s.see("FOLLOW");
s.key('j'); assert!(!s.app.follow_enabled());
s.dont_see("FOLLOW");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn e2e_help_open_close() {
let dir = sandbox("help");
seed_files(&dir);
let mut s = Sim::new(&canon(&dir));
s.key('?');
assert!(s.app.show_help);
s.key('q');
assert!(!s.app.show_help, "q でヘルプを閉じる");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn e2e_quit_confirm_flow() {
let dir = sandbox("quit");
seed_files(&dir);
let mut s = Sim::new(&canon(&dir));
s.key('Q');
assert!(!s.quit, "確認ダイアログで即終了しない");
s.key('n');
assert!(!s.quit, "n で取消");
s.key('Q');
s.key('y');
assert!(s.quit, "y で終了");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn e2e_info_popup() {
let dir = sandbox("info");
seed_files(&dir);
let mut s = Sim::new(&canon(&dir));
s.select("notes.txt");
s.key('i');
assert!(s.app.is_info());
s.see("notes.txt");
s.key('q');
assert!(!s.app.is_info());
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn e2e_markdown_task_cycles_custom_states() {
let dir = sandbox("md_task_cycle");
seed_files(&dir);
let mut cfg = Config::default();
cfg.ui.md_task_states = vec![" ".into(), "!".into(), "x".into()];
let mut s = Sim::with_config(&canon(&dir), cfg);
s.select("readme.md");
s.enter();
s.tab(); s.tab(); let read = || std::fs::read_to_string(dir.join("readme.md")).unwrap();
s.key(' ');
assert!(
read().contains("- [!] task one"),
"1回目: [ ]→[!]: {}",
read()
);
s.key(' ');
assert!(
read().contains("- [x] task one"),
"2回目: [!]→[x]: {}",
read()
);
s.key(' ');
assert!(
read().contains("- [ ] task one"),
"3回目: [x]→[ ] へ巡回: {}",
read()
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn e2e_markdown_focus_forward_then_back() {
let dir = sandbox("md_focus_back");
seed_files(&dir);
let mut s = Sim::new(&canon(&dir));
s.select("readme.md");
s.enter();
assert_eq!(s.app.focused_item(), None, "初期はフォーカス無し");
s.tab();
let first = s.app.focused_item();
assert!(first.is_some(), "Tab で最初のアイテムへ");
s.tab();
let second = s.app.focused_item();
assert!(second > first, "2回目の Tab で次のアイテムへ");
s.press(KeyCode::BackTab, KeyModifiers::SHIFT);
assert_eq!(
s.app.focused_item(),
first,
"Shift-Tab で前のアイテムへ戻る"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn e2e_last_tab_q_quits_when_confirm_off() {
let dir = sandbox("last_tab_quit");
seed_files(&dir);
let mut cfg = Config::default();
cfg.ui.confirm_quit = false;
let mut s = Sim::with_config(&canon(&dir), cfg);
assert_eq!(s.app.tab_count(), 1);
s.key('q');
assert!(s.quit, "最後のタブでの q は終了する");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn e2e_last_tab_q_confirms_when_confirm_on() {
let dir = sandbox("last_tab_confirm");
seed_files(&dir);
let mut s = Sim::new(&canon(&dir));
s.key('q');
assert!(!s.quit, "確認ダイアログでまだ終了しない");
assert!(s.app.is_dialog(), "終了確認ダイアログが開く");
s.key('q'); assert!(s.quit, "qq で終了");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn e2e_global_bookmark_shows_absolute_path() {
let root = sandbox("bm_global");
let proj = canon(&root);
seed_files(&proj);
let mut s = Sim::new(&proj);
s.app.bookmarks = crate::bookmarks::Bookmarks::with_base(root.join("cfg"), &proj);
s.select("notes.txt");
s.keys("mB"); s.key('\'');
let items = s.app.bookmark_list_items();
let entry = items
.iter()
.find(|(_, k, _)| *k == 'B')
.expect("global bookmark B");
assert!(!entry.0, "大文字はグローバル(is_local=false)");
let disp = s.app.bookmark_display_path(entry.0, &entry.2);
assert!(
disp.starts_with('/') || disp.starts_with('~'),
"グローバルは絶対パス表示: {disp}"
);
std::fs::remove_dir_all(&root).ok();
}
#[test]
fn e2e_preview_visual_selection_and_cancel() {
let dir = sandbox("visual_sel");
seed_files(&dir);
let mut s = Sim::new(&canon(&dir));
s.select("notes.txt");
s.enter();
assert!(s.app.is_windowed(), "テキストは windowed プレビュー");
s.key('v');
assert!(s.app.is_preview_visual(), "v で選択開始");
assert!(!s.app.preview_visual_linewise(), "v は charwise");
s.key('l'); assert!(matches!(
s.app.preview_selection(),
crate::app::PreviewSelection::Char { .. }
));
s.esc();
assert!(!s.app.is_preview_visual(), "Esc で選択解除");
s.key('V');
assert!(
s.app.is_preview_visual() && s.app.preview_visual_linewise(),
"V は linewise"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn e2e_copy_leader_opens_and_cancels() {
let dir = sandbox("copy_leader");
seed_files(&dir);
let mut s = Sim::new(&canon(&dir));
s.select("notes.txt");
s.key('y');
assert_eq!(
s.app.pending_leader,
Some(crate::keymap::LeaderId::Copy),
"y でコピーリーダー待ち"
);
s.esc();
assert_eq!(s.app.pending_leader, None, "Esc でリーダー解除");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn e2e_sort_reverse_flips_order() {
let dir = sandbox("sort_rev");
seed_files(&dir);
let mut s = Sim::new(&canon(&dir));
let before: Vec<_> = s.app.entries.iter().map(|e| e.path.clone()).collect();
s.key('s');
s.key('r'); assert!(
s.app.is_sort_menu(),
"r/. はトグルなのでメニューは開いたまま"
);
let after: Vec<_> = s.app.entries.iter().map(|e| e.path.clone()).collect();
assert_ne!(before, after, "反転で並びが変わる");
s.esc(); assert!(!s.app.is_sort_menu(), "Esc でメニューが閉じる");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn e2e_anchor_reanchor_and_reset() {
let dir = sandbox("anchor");
seed_files(&dir);
let root = canon(&dir);
let mut s = Sim::new(&root);
s.select("src");
s.key('l'); assert!(s.app.root.ends_with("src"));
s.key('a'); assert!(s.app.flash.is_some(), "a で通知");
s.key('A'); assert!(s.app.flash.is_some(), "A で通知");
assert!(s.app.root.ends_with("src"), "アンカーは root を動かさない");
std::fs::remove_dir_all(&dir).ok();
}
#[cfg(feature = "git")]
#[test]
fn e2e_git_hub_to_log_shows_commit_subject() {
let dir = sandbox("git_log_view");
seed_repo(&dir);
let mut s = Sim::new(&canon(&dir));
s.key('o');
assert!(s.app.is_git_view(), "o で変更ハブが開く");
s.key('l'); assert!(s.app.is_git_log(), "l で log ビューへ");
s.see("Git log"); s.see("init"); s.key('q'); assert!(s.app.is_git_view(), "log の q でハブへ戻る");
std::fs::remove_dir_all(&dir).ok();
}
#[cfg(feature = "git")]
#[test]
fn e2e_git_graph_shows_commit_nodes() {
let dir = sandbox("git_graph_view");
seed_repo(&dir);
let mut s = Sim::new(&canon(&dir));
s.key('o');
s.key('g'); assert!(s.app.is_git_graph(), "g でグラフへ");
s.see("Git graph"); s.see("●"); s.key('q');
assert!(s.app.is_git_view(), "グラフの q でハブへ戻る");
std::fs::remove_dir_all(&dir).ok();
}
#[cfg(feature = "git")]
#[test]
fn e2e_git_graph_set_and_clear_base() {
let dir = sandbox("git_graph_base");
seed_repo(&dir);
let mut s = Sim::new(&canon(&dir));
s.key('o');
s.key('g');
s.dont_see("⌖ base:"); s.key('G'); s.key('s'); s.see("⌖ base:"); s.key('x'); s.dont_see("⌖ base:");
std::fs::remove_dir_all(&dir).ok();
}
#[cfg(feature = "git")]
#[test]
fn e2e_git_branches_view_lists_branches() {
let dir = sandbox("git_branches_view");
seed_repo(&dir);
let mut s = Sim::new(&canon(&dir));
s.key('o');
s.key('b'); assert!(s.app.is_git_branches(), "b でブランチ一覧へ");
s.see("Git branches"); s.see("feature-x"); s.see("*"); s.key('q');
assert!(s.app.is_git_view(), "branches の q でハブへ戻る");
std::fs::remove_dir_all(&dir).ok();
}
#[cfg(feature = "git")]
#[test]
fn e2e_git_commit_detail_shows_full_message() {
let dir = sandbox("git_commit_detail");
seed_repo(&dir);
let mut s = Sim::new(&canon(&dir));
s.key('o');
s.key('l');
s.enter(); assert!(s.app.is_git_detail(), "Enter で詳細へ");
s.see("First body paragraph."); s.see("Second body paragraph."); s.key('q'); assert!(s.app.is_git_log(), "詳細の q で log へ戻る");
std::fs::remove_dir_all(&dir).ok();
}
#[cfg(feature = "git")]
#[test]
fn e2e_git_graph_branch_picker_toggles() {
let dir = sandbox("git_graph_picker");
seed_repo(&dir);
let mut s = Sim::new(&canon(&dir));
s.key('o');
s.key('g');
s.key('b'); assert!(s.app.is_git_graph_picker(), "b でパネルが開く");
s.see("feature-x"); s.key('q'); assert!(!s.app.is_git_graph_picker(), "q でパネルを閉じる");
assert!(s.app.is_git_graph(), "パネルを閉じるとグラフへ戻る");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn e2e_copy_path_strings_from_tree() {
let dir = sandbox("copy_strings");
seed_files(&dir);
let mut s = Sim::new(&canon(&dir));
s.select("notes.txt");
let path = s.app.entries[s.app.selected].path.clone();
assert_eq!(
s.app.copy_string_for(crate::app::CopyKind::Name).as_deref(),
Some("notes.txt")
);
assert_eq!(
s.app.copy_string_for(crate::app::CopyKind::Full),
Some(path.display().to_string())
);
assert_eq!(
s.app.copy_string_for(crate::app::CopyKind::Parent),
Some(path.parent().unwrap().display().to_string())
);
assert_eq!(
s.app
.copy_string_for(crate::app::CopyKind::AtRef)
.as_deref(),
Some("@notes.txt")
);
let rel = s
.app
.copy_string_for(crate::app::CopyKind::Relative)
.unwrap();
assert_eq!(
rel,
s.app.format_path(&path),
"相対コピー=タイトル表示と同一基準"
);
assert!(
rel.ends_with("/notes.txt") && rel != "notes.txt" && rel != "@notes.txt",
"相対は起動dir名を先頭に付ける(@ の厳密相対とは異なる): {rel}"
);
s.key('y');
assert_eq!(s.app.pending_leader, Some(crate::keymap::LeaderId::Copy));
s.key('@');
assert_eq!(s.app.pending_leader, None, "y→@ でリーダーが消費される");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn e2e_copy_atref_is_open_dir_relative_when_descended() {
let dir = sandbox("atref_descend");
seed_files(&dir);
let root = canon(&dir);
let mut s = Sim::new(&root);
s.select("src");
s.key('l'); assert!(s.app.root.ends_with("src"));
s.select("lib.rs");
assert_eq!(
s.app
.copy_string_for(crate::app::CopyKind::AtRef)
.as_deref(),
Some("@src/lib.rs"),
"@ 参照は起動位置からの厳密相対(現在の root ではない)"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn e2e_preview_at_ref_line_reference() {
let dir = sandbox("preview_atref");
seed_files(&dir);
let mut s = Sim::new(&canon(&dir));
s.select("notes.txt");
s.enter();
assert!(s.app.is_windowed(), "テキストは windowed(2D キャレット)");
assert_eq!(
s.app.selection_ref_string().as_deref(),
Some("@notes.txt#L1")
);
s.key('j'); assert_eq!(
s.app.selection_ref_string().as_deref(),
Some("@notes.txt#L2")
);
s.key('v');
s.key('j'); assert!(matches!(
s.app.preview_selection(),
crate::app::PreviewSelection::Char { .. }
));
assert_eq!(
s.app.selection_ref_string().as_deref(),
Some("@notes.txt#L2-3")
);
s.key('Y');
assert!(!s.app.is_preview_visual(), "Y で参照コピー後に選択解除");
std::fs::remove_dir_all(&dir).ok();
}
#[cfg(feature = "git")]
#[test]
fn e2e_git_commit_copy_values() {
let dir = sandbox("git_commit_copy");
seed_repo(&dir);
let mut s = Sim::new(&canon(&dir));
s.key('o'); s.key('l'); s.see("init");
assert_eq!(s.app.surface(), crate::keymap::Surface::GitLog);
let id = s.app.git_log_selected_id().expect("選択コミットの id");
let meta = crate::git::commit_meta(&s.app.root, &id).expect("commit_meta");
assert_eq!(meta.message.lines().next(), Some("init"), "Subject=件名");
assert_eq!(meta.author, "t", "Author=user.name");
assert_eq!(meta.short.len(), 7, "ShortHash=7桁");
assert_eq!(meta.id.len(), 40, "FullHash=40桁");
assert!(
meta.id.starts_with(&meta.short),
"短ハッシュは完全ハッシュの接頭辞"
);
assert!(!meta.date.is_empty(), "Date は非空");
s.key('y');
assert_eq!(
s.app.pending_leader,
Some(crate::keymap::LeaderId::GitCopy),
"log の y は GitCopy リーダー(パスコピーの Copy ではない)"
);
s.key('t');
assert_eq!(s.app.pending_leader, None, "y→t でリーダー消費");
std::fs::remove_dir_all(&dir).ok();
}
#[cfg(feature = "git")]
#[test]
fn e2e_follow_opens_full_screen_diff() {
let dir = sandbox("follow_diff");
seed_repo(&dir); let mut s = Sim::new(&canon(&dir));
s.key('F');
assert!(s.app.follow_enabled(), "F でフォロー ON");
let a = s.app.root.join("a.rs");
assert!(s.app.follow_note_change(&a), "変更ファイルは有効な追尾対象");
s.app.follow_jump(&a);
s.draw();
assert!(
s.app.is_git_diff_preview(),
"追尾先は全画面 git diff で開く"
);
assert_eq!(s.app.mode, Mode::Preview);
s.see("diff");
assert_eq!(
s.app.diff_change_position(),
Some((1, 1)),
"セッションに 1 ファイル → (1/1)"
);
std::fs::remove_dir_all(&dir).ok();
}
#[cfg(feature = "git")]
#[test]
fn e2e_follow_diff_cycles_session_files() {
let dir = sandbox("follow_cycle");
seed_repo(&dir);
let mut s = Sim::new(&canon(&dir));
s.key('F');
let a = s.app.root.join("a.rs");
let nt = s.app.root.join("new.txt");
assert!(s.app.follow_note_change(&a));
assert!(s.app.follow_note_change(&nt));
s.app.follow_jump(&a); s.draw();
assert!(s.app.is_git_diff_preview());
assert_eq!(
s.app.diff_change_position(),
Some((1, 2)),
"a.rs = 2 件中 1 番目"
);
s.key('n');
assert!(
!s.app.follow_enabled(),
"n(F 以外)で follow は解除される(Zed 流)"
);
assert_eq!(
s.app.diff_change_position(),
Some((2, 2)),
"n で 2 番目(new.txt)へ"
);
s.key('N'); assert_eq!(
s.app.diff_change_position(),
Some((1, 2)),
"N で 1 番目へ戻る"
);
std::fs::remove_dir_all(&dir).ok();
}
fn glyph_cols(row: &str, target: char) -> Vec<usize> {
row.chars()
.enumerate()
.filter(|(_, c)| *c == target)
.map(|(i, _)| i)
.collect()
}
#[test]
fn e2e_md_table_renders_box_and_aligns_cjk() {
let dir = sandbox("md_table_grid");
seed_files(&dir);
std::fs::write(
dir.join("grid.md"),
"| Tool | 名称 |\n|------|------|\n| alpharow | 設定ファイル |\n| betarow | 日本語 |\n",
)
.unwrap();
let mut s = Sim::new(&canon(&dir));
s.select("grid.md");
s.enter();
s.see("┬");
s.see("┼");
s.see("┴");
s.see("├");
s.see("┤");
let scr = s.screen();
let rows: Vec<&str> = scr.lines().collect();
let bars = |needle: &str| -> Vec<usize> {
let row = rows
.iter()
.find(|l| l.contains(needle))
.unwrap_or_else(|| panic!("row not found: {needle}\n{scr}"));
glyph_cols(row, '│')
};
let head = bars("Tool");
let d1 = bars("alpharow");
let d2 = bars("betarow");
assert!(head.len() >= 4, "外枠+列区切りの │ が並ぶ: {head:?}");
assert_eq!(head, d1, "ヘッダとデータ行1で列が揃う(CJK 桁): {scr}");
assert_eq!(head, d2, "ヘッダとデータ行2で列が揃う(CJK 桁): {scr}");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn e2e_md_table_cell_link_shows_label_hides_url() {
let dir = sandbox("md_table_link");
seed_files(&dir); std::fs::write(
dir.join("tbl.md"),
"| name | doc |\n|---|---|\n| konoma | [OpenDoc](./notes.txt) |\n",
)
.unwrap();
let mut s = Sim::new(&canon(&dir));
s.select("tbl.md");
s.enter();
s.see("OpenDoc"); s.dont_see("[OpenDoc]"); s.dont_see("./notes.txt"); s.tab(); assert_eq!(
s.app.focused_item(),
Some(0),
"表内リンクが md_items の先頭"
);
s.enter(); assert!(
s.app
.preview_path
.as_deref()
.is_some_and(|p| p.ends_with("notes.txt")),
"表内リンクの Enter で ./notes.txt を開く"
);
s.see("alpha"); std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn e2e_md_html_details_collapse_and_toggle() {
let dir = sandbox("md_html_details");
seed_files(&dir);
std::fs::write(
dir.join("html.md"),
"before text\n\n<details>\n<summary>Summary text</summary>\n\nhidden body line\n\n</details>\n\nafter text\n",
)
.unwrap();
let mut s = Sim::new(&canon(&dir));
s.select("html.md");
s.enter();
s.see("Summary text"); s.dont_see("hidden body line"); s.dont_see("<summary>"); s.dont_see("<details>");
s.tab();
assert_eq!(s.app.focused_item(), Some(0), "summary が Tab アイテム");
s.key(' ');
s.see("hidden body line"); s.enter();
s.dont_see("hidden body line");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn e2e_md_thematic_break_becomes_rule_and_fenced_dashes_kept() {
let dir = sandbox("md_hr");
seed_files(&dir);
std::fs::write(
dir.join("hr.md"),
"above paragraph\n\n---\n\nbelow paragraph\n\n```text\nraw --- text\n```\n",
)
.unwrap();
let mut s = Sim::new(&canon(&dir));
s.select("hr.md");
s.enter();
s.see("above paragraph");
s.see("below paragraph");
let has_rule = s.screen().lines().any(|l| {
l.starts_with('│') && l.ends_with('│') && l.chars().filter(|&c| c == '─').count() >= 60
});
assert!(has_rule, "--- が全幅 ─ 罫線になる:\n{}", s.screen());
s.see("raw --- text");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn e2e_markdown_autolink_and_emoji_full_pipeline() {
let body = concat!(
"Prose url https://prose.example here.\n\n",
"Prose mail me@prose.example here.\n\n",
"Prose www www.prose.example here.\n\n",
"Prose emoji :rocket: here.\n\n",
"Inline `https://in-code.example` and `:sparkles:` stay literal.\n\n",
"```sh\ncurl https://in-fence.example # :tada:\n```\n",
);
for (label, cfg) in [
("code_bg_set", Config::default()),
("code_bg_none", cfg_code_bg_none()),
] {
let (mut s, dir) = md_preview(cfg, "autolink_emoji", body);
assert_eq!(
s.app.md_link_targets(),
vec![
"https://prose.example".to_string(),
"mailto:me@prose.example".to_string(),
"http://www.prose.example".to_string(),
],
"[{label}] only prose url/email/www linked; code URLs excluded"
);
s.tab();
assert_eq!(
s.app.focused_item(),
Some(0),
"[{label}] Tab reaches the first link"
);
s.see("🚀");
s.dont_see(":rocket:");
s.see(":sparkles:");
s.see(":tada:");
s.see("https://in-code.example");
s.see("https://in-fence.example");
std::fs::remove_dir_all(&dir).ok();
}
}
#[test]
fn e2e_markdown_alert_callout_and_nested_code_fence() {
let body = concat!(
"> [!NOTE]\n",
"> Docs https://alert-prose.example here.\n",
"> ```sh\n",
"> curl https://alert-fence.example # :tada:\n",
"> ```\n",
);
for (label, cfg) in [
("code_bg_set", Config::default()),
("code_bg_none", cfg_code_bg_none()),
] {
let (s, dir) = md_preview(cfg, "alert_code", body);
s.see("Note"); s.dont_see("[!NOTE]"); assert_eq!(
s.app.md_link_targets(),
vec!["https://alert-prose.example".to_string()],
"[{label}] only the alert prose URL links, not the fenced one"
);
s.see(":tada:"); s.see("https://alert-fence.example"); std::fs::remove_dir_all(&dir).ok();
}
}
#[test]
fn e2e_markdown_alert_all_five_types_and_aliases() {
let body = concat!(
"> [!NOTE]\n> n\n\n",
"> [!TIP]\n> t\n\n",
"> [!IMPORTANT]\n> i\n\n",
"> [!WARNING]\n> w\n\n",
"> [!CAUTION]\n> c\n\n",
"> [!danger] Aliased\n> d\n",
);
let (s, dir) = md_preview(Config::default(), "alert_types", body);
for label in ["Note", "Tip", "Important", "Warning", "Caution"] {
s.see(label);
}
s.see("Caution — Aliased"); s.dont_see("[!");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn e2e_markdown_autolink_emoji_alerts_toggle_off() {
let body = concat!(
"> [!WARNING]\n> careful\n\n",
"Prose https://x.example :rocket: here.\n",
);
let mut cfg = cfg_code_bg_none();
cfg.ui.md_autolink = false;
cfg.ui.md_emoji = false;
cfg.ui.md_alerts = false;
let (s, dir) = md_preview(cfg, "md_toggles_off", body);
assert!(s.app.md_link_targets().is_empty(), "autolink off: no links");
s.see(":rocket:"); s.see("https://x.example"); s.see("[!WARNING]"); std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn e2e_markdown_inline_html_renders() {
let body = "Press <kbd>Ctrl</kbd>. H<sub>2</sub>O. <del>old</del> new.\n";
let (s, dir) = md_preview(Config::default(), "inline_html", body);
s.see("Ctrl"); s.see("H₂O"); s.see("new"); s.dont_see("<kbd>"); s.dont_see("<sub>");
s.dont_see("<del>");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn e2e_markdown_footnotes_render_superscript_and_section() {
let body = "A claim.[^src] More text.\n\n[^src]: The evidence.\n";
let (s, dir) = md_preview(Config::default(), "footnotes", body);
s.see("¹"); s.dont_see("[^src]"); s.see("The evidence."); std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn e2e_markdown_footnotes_off_stays_literal() {
let body = "A claim.[^src]\n\n[^src]: The evidence.\n";
let mut cfg = Config::default();
cfg.ui.md_footnotes = false;
let (s, dir) = md_preview(cfg, "footnotes_off", body);
s.see("[^src]"); std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn e2e_markdown_front_matter_renders_as_metadata() {
let body = concat!(
"---\n",
"title: My Doc\n",
"author: Me\n",
"---\n",
"[to section](#real-heading)\n\n",
"# Real Heading\n\n",
"body text\n",
);
let (mut s, dir) = md_preview(Config::default(), "frontmatter", body);
s.see("title"); s.see("My Doc"); s.see("author");
s.see("Real Heading"); assert_eq!(
s.app.md_link_targets(),
vec!["#real-heading".to_string()],
"the in-page anchor link is present"
);
s.tab();
s.enter();
assert!(
s.app.flash.is_none(),
"anchor resolves (no 'not found' flash)"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn e2e_markdown_front_matter_off_leaves_body_intact() {
let body = "---\ntitle: My Doc\n---\n# H\n\nbody\n";
let mut cfg = Config::default();
cfg.ui.md_frontmatter = false;
let (s, dir) = md_preview(cfg, "frontmatter_off", body);
s.see("title");
s.see("body");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn e2e_outline_lists_headings_and_jumps() {
let mut body = String::from("# Top Heading\n\nintro\n\n");
for i in 0..40 {
body.push_str(&format!("filler line {i}\n\n"));
}
body.push_str("## Deep Section\n\ndeep body text\n");
let (mut s, dir) = md_preview(Config::default(), "outline_jump", &body);
assert_eq!(s.app.preview_scroll, 0);
s.key('o');
assert!(s.app.is_outline(), "o opens the outline");
s.see("Top Heading");
s.see("Deep Section");
assert_eq!(s.app.outline_sel(), 0);
s.key('j');
assert_eq!(s.app.outline_sel(), 1);
s.enter();
assert!(!s.app.is_outline(), "Enter closes the outline");
assert!(s.app.preview_scroll > 0, "jumped down to the deep heading");
s.see("Deep Section");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn e2e_outline_toggle_and_esc_close() {
let (mut s, dir) = md_preview(
Config::default(),
"outline_toggle",
"# A\n\nx\n\n## B\n\ny\n",
);
s.key('o');
assert!(s.app.is_outline());
s.key('o'); assert!(!s.app.is_outline());
s.key('o');
assert!(s.app.is_outline());
s.esc(); assert!(!s.app.is_outline());
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn e2e_outline_closes_on_new_tab() {
let (mut s, dir) = md_preview(
Config::default(),
"outline_newtab",
"# A\n\nx\n\n## B\n\ny\n",
);
s.key('o');
assert!(s.app.is_outline());
s.key('t'); assert!(!s.app.is_outline(), "a new tab closes the outline overlay");
assert_eq!(s.app.mode, Mode::Tree, "the new tab is a fresh tree");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn e2e_outline_empty_flashes_on_plain_text() {
let dir = sandbox("outline_empty");
std::fs::write(dir.join("f.txt"), "# not a markdown heading\njust text\n").unwrap();
let mut s = Sim::new(&canon(&dir));
s.select("f.txt");
s.enter();
s.key('o');
assert!(
!s.app.is_outline(),
"no overlay opens for a non-Markdown preview"
);
assert!(s.app.flash.is_some(), "a 'no headings' flash is shown");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn e2e_markdown_anchor_jump_scrolls_to_heading() {
let mut body = String::from("[go to target](#the-target)\n\n");
for i in 0..40 {
body.push_str(&format!("line {i}\n\n"));
}
body.push_str("## The Target\n\ndestination text\n");
let (mut s, dir) = md_preview(Config::default(), "anchor_jump", &body);
assert_eq!(s.app.preview_scroll, 0, "starts at the top");
s.tab(); assert_eq!(s.app.focused_item(), Some(0));
s.enter(); assert!(
s.app.preview_scroll > 0,
"scrolled down toward the heading (was {})",
s.app.preview_scroll
);
s.see("The Target");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn e2e_markdown_anchor_jump_unknown_slug_flashes() {
let body = "[bad](#nope)\n\n# Real Heading\n\nbody\n";
let (mut s, dir) = md_preview(Config::default(), "anchor_bad", body);
s.tab();
s.enter();
assert!(
s.screen().contains("nope") || s.app.flash.is_some(),
"unknown anchor flashes"
);
assert_eq!(s.app.preview_scroll, 0, "no scroll for a missing anchor");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn e2e_markdown_autolink_cjk_and_trailing_punctuation() {
let body = "見るhttps://cjk.example、と (https://paren.example) を確認。\n";
let (s, dir) = md_preview(cfg_code_bg_none(), "autolink_cjk", body);
assert_eq!(
s.app.md_link_targets(),
vec![
"https://cjk.example".to_string(),
"https://paren.example".to_string(),
],
"CJK boundary + paren-wrapped URL both link, punctuation trimmed"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn e2e_md_task_markers_render_ascii_or_nf_by_icons() {
let dir = sandbox("md_tasks");
seed_files(&dir);
std::fs::write(dir.join("tasks.md"), "- [ ] todo item\n- [x] done item\n").unwrap();
let root = canon(&dir);
let mut cfg = Config::default();
cfg.ui.icons = false;
let mut s = Sim::with_config(&root, cfg);
s.select("tasks.md");
s.enter();
s.see("[ ] todo item");
s.see("[x] done item");
let mut s2 = Sim::new(&root); s2.select("tasks.md");
s2.enter();
s2.see(&crate::ui::icons::task_icon(false).to_string()); s2.see(&crate::ui::icons::task_icon(true).to_string()); s2.dont_see("[ ]");
s2.dont_see("[x]");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn e2e_md_raw_source_toggle() {
let dir = sandbox("md_raw");
seed_files(&dir);
std::fs::write(
dir.join("guide.md"),
"# Heading\n\nsome **bold** text and a [link](./notes.txt)\n",
)
.unwrap();
let mut s = Sim::new(&canon(&dir));
s.select("guide.md");
s.enter();
assert!(!s.app.is_raw_source(), "初期は装飾表示");
s.dont_see("· raw source"); s.key('R');
assert!(s.app.is_raw_source(), "R で raw ソース表示");
s.see("· raw source"); s.key('R');
assert!(!s.app.is_raw_source(), "R で装飾表示へ戻る");
s.dont_see("· raw source");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn e2e_markdown_edit_opens_near_scroll_position() {
let dir = sandbox("md_edit_line");
let mut content = String::from("# Top\n\n");
for i in 1..=25 {
content.push_str(&format!(
"Intro prose line {i:02} with several words to read on it here.\n"
));
}
content.push_str("\n## Section Two\n\n");
for i in 26..=50 {
content.push_str(&format!(
"Section prose line {i:02} with several words to read on it here.\n"
));
}
std::fs::write(dir.join("doc.md"), content).unwrap();
let mut s = Sim::new(&canon(&dir));
s.select("doc.md");
s.enter(); assert!(!s.app.is_windowed(), "装飾 md は非 windowed");
s.key('e');
let (_p, top) = s.app.take_pending_edit().expect("edit requested");
assert_eq!(top, Some(1), "先頭スクロールは 1 行目");
for _ in 0..40 {
s.key('j');
}
s.key('e');
let (_p, deep) = s.app.take_pending_edit().expect("edit requested");
let deep = deep.expect("装飾 md は描画後に近似行を渡す");
assert!(
deep > 10,
"スクロール後は後方のソース行(>10)を指すべき: {deep}"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn e2e_markdown_edit_content_anchor_hits_exact_line() {
let dir = sandbox("md_anchor");
let mut content = String::from("# Title\n\n");
for i in 1..=15 {
content.push_str(&format!(
"Intro paragraph {i:02} with a deliberately long sentence that wraps across several display rows in a narrow preview here.\n"
));
}
content.push_str("\n## ANCHORZZZ Section\n\n");
for i in 1..=60 {
content.push_str(&format!("Trailing line {i:02} after the anchor.\n"));
}
let heading_line = content
.lines()
.position(|l| l.contains("ANCHORZZZ"))
.expect("見出しがある")
+ 1;
std::fs::write(dir.join("doc.md"), &content).unwrap();
let mut s = Sim::new(&canon(&dir));
s.select("doc.md");
s.enter(); assert!(!s.app.is_windowed(), "装飾 md は非 windowed");
let mut scrolled = 0;
while scrolled < 300 {
let top = s.screen().lines().nth(2).unwrap_or("").to_string();
if top.contains("ANCHORZZZ") {
break;
}
s.key('j');
scrolled += 1;
}
s.see("ANCHORZZZ");
s.key('e');
let (_p, line) = s.app.take_pending_edit().expect("edit requested");
assert_eq!(
line,
Some(heading_line),
"content-anchor は見出しの正確なソース行 {heading_line} に着地すべき"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn e2e_markdown_edit_lands_on_tab_focused_item() {
let dir = sandbox("md_focus_edit");
let content = "# Title\n\nIntro paragraph one.\n\n- [ ] UNIQUETASK finish the report\n\nMiddle paragraph.\n\nSee [UNIQUELINK](./target.txt) here.\n";
std::fs::write(dir.join("doc.md"), content).unwrap();
std::fs::write(dir.join("target.txt"), "x\n").unwrap();
let task_line = content
.lines()
.position(|l| l.contains("UNIQUETASK"))
.expect("task line")
+ 1;
let link_line = content
.lines()
.position(|l| l.contains("UNIQUELINK"))
.expect("link line")
+ 1;
let mut s = Sim::new(&canon(&dir));
s.select("doc.md");
s.enter(); assert!(!s.app.is_windowed(), "装飾 md は非 windowed");
s.tab();
s.key('e');
let (_p, line) = s.app.take_pending_edit().expect("edit requested");
assert_eq!(
line,
Some(task_line),
"Tab フォーカスのタスク行 {task_line}"
);
s.tab();
s.key('e');
let (_p, line) = s.app.take_pending_edit().expect("edit requested");
assert_eq!(
line,
Some(link_line),
"Tab フォーカスのリンク行 {link_line}"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn e2e_md_code_block_tab_focus_and_copy() {
let dir = sandbox("md_code_copy");
seed_files(&dir);
std::fs::write(
dir.join("snip.md"),
"see [doc](./notes.txt)\n\n```rust\nfn main() {}\nlet y = 2;\n```\n\n- [ ] todo\n",
)
.unwrap();
let mut s = Sim::new(&canon(&dir));
s.select("snip.md");
s.enter();
s.see("rust"); s.tab(); assert!(!s.app.md_focused_code());
s.tab(); assert!(s.app.md_focused_code(), "2番目=コードブロックにフォーカス");
assert_eq!(
s.app.focused_code_text().as_deref(),
Some("fn main() {}\nlet y = 2;"),
"生ソースをコピー対象に"
);
s.enter();
assert!(s.app.flash.is_none(), "Enter はコードブロックで何もしない");
s.key('y');
assert_eq!(
s.app.pending_leader,
Some(crate::keymap::LeaderId::Copy),
"y はコピーリーダー(which-key)を開く"
);
s.see("code block"); s.key('c');
assert!(s.app.pending_leader.is_none(), "c で確定=リーダー閉じる");
assert!(s.app.flash.is_some(), "y c でコピー通知が出る");
s.tab(); assert!(!s.app.md_focused_code());
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn e2e_md_wrapped_focus_follows_offscreen_item() {
let dir = sandbox("md_wrap_focus");
seed_files(&dir);
let para = "lorem ".repeat(500); std::fs::write(
dir.join("torture.md"),
format!("{para}\n\n[ZZLINK](./notes.txt)\n\n- [ ] ZZTASK\n"),
)
.unwrap();
let root = canon(&dir);
let mut on = Sim::new(&root);
on.select("torture.md");
on.enter();
assert!(on.app.cfg.ui.wrap, "前提: 既定は折返しON");
assert_eq!(on.app.preview_scroll, 0, "初期スクロール 0");
on.dont_see("ZZLINK"); on.tab(); assert!(
on.app.preview_scroll > 5,
"折返しでフォーカスに追従してスクロール: scroll={}",
on.app.preview_scroll
);
on.see("ZZLINK");
let mut cfg = Config::default();
cfg.ui.wrap = false;
let mut off = Sim::with_config(&root, cfg);
off.select("torture.md");
off.enter();
off.see("ZZLINK"); off.tab();
assert_eq!(
off.app.preview_scroll, 0,
"非折返しは追従スクロール不要(scroll 0 のまま)"
);
off.see("ZZLINK");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn e2e_file_copy_then_paste_duplicates() {
let dir = sandbox("copy_paste");
std::fs::write(dir.join("orig.txt"), "hello copy\n").unwrap();
std::fs::create_dir_all(dir.join("dest")).unwrap();
let dir = canon(&dir);
let mut s = Sim::new(&dir);
s.select("orig.txt"); s.key(' ');
s.key('c'); s.select("dest"); s.key(' ');
s.key('p');
assert!(
dir.join("dest/orig.txt").exists(),
"コピーがディレクトリ内に複製される"
);
assert!(dir.join("orig.txt").exists(), "コピーは元を残す");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn e2e_file_cut_then_paste_moves() {
let dir = sandbox("cut_paste");
std::fs::write(dir.join("orig.txt"), "hello move\n").unwrap();
std::fs::create_dir_all(dir.join("dest")).unwrap();
let dir = canon(&dir);
let mut s = Sim::new(&dir);
s.select("orig.txt");
s.key(' ');
s.key('x'); s.select("dest");
s.key(' ');
s.key('p');
assert!(dir.join("dest/orig.txt").exists(), "移動先に出来る");
assert!(!dir.join("orig.txt").exists(), "移動元は消える");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn e2e_batch_rename_numbers_selection_via_preview() {
let dir = sandbox("batch_rename");
std::fs::write(dir.join("alpha.log"), "a\n").unwrap();
std::fs::write(dir.join("beta.log"), "b\n").unwrap();
let dir = canon(&dir);
let mut s = Sim::new(&dir);
s.key('V');
s.key('V');
assert!(s.app.has_selection(), "複数選択が入っている");
s.key(' ');
s.key('r'); assert!(s.app.is_dialog(), "一括リネームの入力ダイアログが開く");
s.keys("renamed-{n}"); s.enter(); assert!(s.app.dialog_is_preview(), "リネームプレビューが挟まる");
s.see("renamed-1");
s.key('y');
assert!(dir.join("renamed-1.log").exists(), "1件目が連番リネーム");
assert!(dir.join("renamed-2.log").exists(), "2件目が連番リネーム");
assert!(!dir.join("alpha.log").exists(), "旧名は消える");
assert!(!dir.join("beta.log").exists(), "旧名は消える");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn e2e_tree_visual_selection_grows_then_commits() {
let dir = sandbox("tree_visual");
seed_files(&dir);
let mut s = Sim::new(&canon(&dir));
s.key('v'); assert!(s.app.is_visual(), "v で範囲選択開始");
assert_eq!(s.app.marked_count(), 1, "開始直後はアンカー1行");
s.key('j'); assert_eq!(s.app.marked_count(), 2, "j で範囲が2行に広がる");
s.key('v'); assert!(!s.app.is_visual(), "v で範囲確定して visual を抜ける");
assert!(s.app.has_selection(), "確定した集合が残る");
assert_eq!(s.app.marked_count(), 2, "確定後は選択2件");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn e2e_details_columns_show_size_and_modified() {
let dir = sandbox("details");
seed_files(&dir);
let dir = canon(&dir);
let notes = dir.join("notes.txt");
let meta = crate::fileops::quick_meta(¬es).expect("quick_meta");
let size_cell = crate::fileops::human_size(meta.size);
let mod_cell = crate::fileops::format_epoch_short(meta.mtime.expect("mtime"));
let plain = Sim::new(&dir);
plain.dont_see(&mod_cell);
let mut cfg = Config::default();
cfg.ui.details = vec!["size".into(), "modified".into()];
let s = Sim::with_config(&dir, cfg);
s.see(&size_cell); s.see(&mod_cell); std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn e2e_path_style_cycles() {
use crate::app::PathStyle;
let dir = sandbox("path_style");
seed_files(&dir);
let mut s = Sim::new(&canon(&dir));
assert_eq!(s.app.path_style, PathStyle::Relative, "既定は relative");
s.see("path:rel"); s.key('p');
assert_eq!(s.app.path_style, PathStyle::Home, "p で ~ 表示へ");
s.see("path:~");
s.key('p');
assert_eq!(s.app.path_style, PathStyle::Full, "p で絶対へ");
s.see("path:abs");
s.key('p');
assert_eq!(s.app.path_style, PathStyle::Relative, "巡回して先頭へ戻る");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn e2e_csv_table_renders_and_navigates_without_rainbow() {
let dir = sandbox("csv_norainbow");
std::fs::write(
dir.join("fruit.csv"),
"fruit,color\napple,red\nkiwi,green\n",
)
.unwrap();
let mut cfg = Config::default();
cfg.ui.csv_rainbow = false;
let mut s = Sim::with_config(&canon(&dir), cfg);
s.select("fruit.csv");
s.enter();
s.see("TABLE");
s.see("fruit"); s.see("apple"); assert_eq!(s.app.table_cursor(), (0, 0), "初期カーソルは先頭セル");
s.key('l'); assert_eq!(s.app.table_cursor(), (0, 1), "l で1列右へ");
s.key('l'); assert_eq!(s.app.table_cursor(), (0, 1), "末列でクランプ");
s.key('0'); assert_eq!(s.app.table_cursor(), (0, 0), "0 で先頭列");
s.key('$'); assert_eq!(s.app.table_cursor(), (0, 1), "$ で末列");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn e2e_tabbar_overflow_markers_keep_active_visible() {
let dir = sandbox("tabbar_overflow");
seed_files(&dir);
let mut s = Sim::new(&canon(&dir));
s.keys("tttttttt"); assert_eq!(s.app.tab_count(), 9);
s.key('5'); assert_eq!(s.app.active_tab_index(), 4);
s.see("‹"); s.see("›"); let label = s.app.tab_label(4);
s.see(&format!("5:{label}")); std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn e2e_busy_indicator_absent_when_idle() {
let dir = sandbox("busy_idle");
seed_files(&dir);
let dir = canon(&dir);
let s = Sim::new(&dir);
assert!(s.app.busy_jobs().is_empty(), "アイドルでジョブ無し");
assert!(
!s.app.busy_indicator_active(),
"アイドルでインジケーター非アクティブ"
);
s.dont_see("loading media");
s.dont_see("git scan");
s.dont_see("loading images");
let mut cfg = Config::default();
cfg.ui.busy_indicator = false;
let s2 = Sim::with_config(&dir, cfg);
assert!(
!s2.app.busy_indicator_active(),
"busy_indicator=false は常に非アクティブ"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn e2e_paste_jump_local_path_with_line() {
let dir = sandbox("paste_jump_local");
seed_files(&dir);
std::fs::create_dir_all(dir.join("src")).unwrap();
let mut body = String::new();
for i in 1..=60 {
if i == 1 {
body.push_str("LINE_ONE_MARKER\n");
} else if i == 30 {
body.push_str("LINE_THIRTY_MARKER\n");
} else {
body.push_str(&format!("plain line {i}\n"));
}
}
std::fs::write(dir.join("src/deep.txt"), body).unwrap();
let dir = canon(&dir);
let mut s = Sim::new(&dir);
s.app.paste_jump_from("src/deep.txt:30");
s.draw();
assert_eq!(s.app.mode, Mode::Preview, "ファイルはプレビューで開く");
assert!(
s.app
.preview_path
.as_deref()
.map(|p| p.ends_with("src/deep.txt"))
.unwrap_or(false),
"deep.txt がプレビュー対象: {:?}",
s.app.preview_path
);
s.see("LINE_THIRTY_MARKER"); s.dont_see("LINE_ONE_MARKER");
s.app.paste_jump_from("does/not/exist.rs");
s.draw();
assert!(
s.app
.flash
.as_deref()
.map(|f| f.contains("exist.rs"))
.unwrap_or(false),
"不在パスは flash 通知: {:?}",
s.app.flash
);
std::fs::remove_dir_all(&dir).ok();
}
#[cfg(feature = "git")]
#[test]
fn e2e_paste_jump_github_url_switches_root() {
let dir = sandbox("paste_jump_url");
std::fs::create_dir_all(dir.join("src")).unwrap();
std::fs::create_dir_all(dir.join("docs")).unwrap();
std::fs::write(dir.join("src/lib.rs"), "pub fn hi() {}\n").unwrap();
std::fs::write(dir.join("docs/guide.md"), "# GUIDE_MARKER\n\nbody\n").unwrap();
let run = |args: &[&str]| {
let out = std::process::Command::new("git")
.current_dir(&dir)
.args(args)
.output()
.unwrap();
assert!(out.status.success(), "git {args:?}: {out:?}");
};
run(&["init", "-q", "."]);
run(&["config", "user.email", "t@t"]);
run(&["config", "user.name", "t"]);
run(&["add", "-A"]);
run(&["commit", "-q", "-m", "init"]);
let repo = canon(&dir);
let subdir = repo.join("src");
let mut s = Sim::new(&subdir);
assert_eq!(s.app.root, subdir, "起動 root は src サブディレクトリ");
s.app
.paste_jump_from("https://github.com/some-owner/some-name/blob/main/docs/guide.md");
s.draw();
assert_eq!(s.app.root, repo, "root が repo(workdir)へ切替わる");
assert_eq!(s.app.mode, Mode::Preview, "guide.md をプレビューで開く");
assert!(
s.app
.preview_path
.as_deref()
.map(|p| p.ends_with("docs/guide.md"))
.unwrap_or(false),
"guide.md がプレビュー対象: {:?}",
s.app.preview_path
);
s.see("GUIDE_MARKER");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn e2e_md_link_ctrl_t_opens_in_new_tab() {
let dir = sandbox("md_link_newtab");
seed_files(&dir);
std::fs::write(dir.join("target.txt"), "TARGET_FILE_MARKER\nmore lines\n").unwrap();
std::fs::write(
dir.join("doc.md"),
"# DOC_HEADING\n\nsee [go](./target.txt) here.\n",
)
.unwrap();
let dir = canon(&dir);
let mut s = Sim::new(&dir);
s.select("doc.md");
s.enter();
s.see("DOC_HEADING");
assert_eq!(s.app.tab_count(), 1, "最初は1タブ");
s.tab(); assert!(s.app.focused_item().is_some(), "リンクにフォーカス");
s.ctrl('t'); assert_eq!(s.app.tab_count(), 2, "別タブが増える");
assert_eq!(s.app.mode, Mode::Preview);
assert!(
s.app
.preview_path
.as_deref()
.map(|p| p.ends_with("target.txt"))
.unwrap_or(false),
"新タブは target.txt を開く: {:?}",
s.app.preview_path
);
s.see("TARGET_FILE_MARKER");
s.key('[');
assert!(
s.app
.preview_path
.as_deref()
.map(|p| p.ends_with("doc.md"))
.unwrap_or(false),
"元タブは doc.md のまま: {:?}",
s.app.preview_path
);
s.see("DOC_HEADING");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn e2e_tab_switch_reloads_tree_from_disk() {
let dir = sandbox("tab_switch_reload");
seed_files(&dir);
let dir = canon(&dir);
let mut s = Sim::new(&dir);
s.dont_see("ZZ_NEW_FILE.txt");
s.key('t');
assert_eq!(s.app.tab_count(), 2, "タブが2つ");
std::fs::write(dir.join("ZZ_NEW_FILE.txt"), "hi\n").unwrap();
s.key('[');
assert_eq!(s.app.active_tab_index(), 0, "タブ1 に戻る");
assert!(
s.app
.entries
.iter()
.any(|e| e.path.ends_with("ZZ_NEW_FILE.txt")),
"タブ切替でツリーが再読み込みされ新規ファイルが現れる"
);
s.see("ZZ_NEW_FILE.txt");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn e2e_preview_file_paging_ctrl_n_p() {
let dir = std::env::temp_dir().join("konoma_e2e_file_paging");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("mid")).unwrap();
std::fs::write(dir.join("a.txt"), b"alpha content\n").unwrap();
std::fs::write(dir.join("z.txt"), b"zulu content\n").unwrap();
std::fs::write(dir.join("mid/inner.txt"), b"inner content\n").unwrap();
let mut s = Sim::new(&dir);
s.enter();
s.key('j'); s.key('j'); s.enter(); assert!(matches!(s.app.mode, Mode::Preview));
s.see("alpha content");
s.ctrl('n');
s.see("zulu content");
assert!(s.app.preview_path.as_ref().unwrap().ends_with("z.txt"));
s.ctrl('n');
s.see("inner content");
assert!(s.app.preview_path.as_ref().unwrap().ends_with("inner.txt"));
s.ctrl('p');
s.see("zulu content");
assert!(s.app.preview_path.as_ref().unwrap().ends_with("z.txt"));
s.key('q');
assert!(matches!(s.app.mode, Mode::Tree));
assert!(s.app.entries[s.app.selected].path.ends_with("z.txt"));
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn e2e_session_restore_reopens_previous_tabs() {
let dir = sandbox("session_restore");
std::fs::write(dir.join("a.txt"), "alpha body\n").unwrap();
std::fs::write(dir.join("b.txt"), "beta body\n").unwrap();
let base = std::env::temp_dir().join("konoma_e2e_session_restore_base");
let _ = std::fs::remove_dir_all(&base);
let mut s = Sim::new(&dir);
s.app
.attach_session_store(crate::session::SessionStore::with_base(base.clone(), &dir));
s.select("a.txt");
s.press(KeyCode::Enter, KeyModifiers::NONE);
assert!(matches!(s.app.mode, Mode::Preview));
s.key('t');
s.select("b.txt");
s.app.save_session(); drop(s);
let mut s2 = Sim::new(&dir);
assert_eq!(s2.app.tab_count(), 1, "復元前は素の1タブ");
s2.app
.attach_session_store(crate::session::SessionStore::with_base(base.clone(), &dir));
s2.app.restore_session();
s2.draw();
assert_eq!(s2.app.tab_count(), 2, "タブ数を復元");
assert_eq!(s2.app.active_tab_index(), 1, "アクティブタブを復元");
assert!(matches!(s2.app.mode, Mode::Tree));
assert!(
s2.app.entries[s2.app.selected].path.ends_with("b.txt"),
"ツリーカーソルを復元"
);
s2.see("a.txt"); s2.key('[');
assert!(matches!(s2.app.mode, Mode::Preview));
s2.see("alpha body");
std::fs::remove_dir_all(&dir).ok();
std::fs::remove_dir_all(&base).ok();
}
fn text_preview(cfg: Config, dir_name: &str, file: &str, body: &str) -> (Sim, std::path::PathBuf) {
let dir = sandbox(dir_name);
std::fs::write(dir.join(file), body).unwrap();
let root = canon(&dir);
let mut s = Sim::with_config(&root, cfg);
s.select(file);
s.enter();
(s, dir)
}
#[test]
fn e2e_search_opens_input_shows_prompt_and_query() {
let dir = sandbox("search_open_input");
std::fs::write(dir.join("f.txt"), "needle at top\nplain line\n").unwrap();
let mut s = Sim::new(&canon(&dir));
s.select("f.txt");
s.enter();
assert!(s.app.is_windowed());
s.key('/');
assert!(s.app.is_searching());
assert_eq!(s.app.search_input(), Some(""));
s.keys("needle");
assert_eq!(s.app.search_input(), Some("needle"));
s.see("/needle");
s.enter();
assert!(!s.app.is_searching());
assert_eq!(s.app.preview_search_query(), Some("needle"));
assert_eq!(s.app.search_status(), Some((1, 1)));
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn e2e_search_backspace_edits_query() {
let dir = sandbox("search_backspace");
std::fs::write(dir.join("f.txt"), "needle here\n").unwrap();
let mut s = Sim::new(&canon(&dir));
s.select("f.txt");
s.enter();
s.key('/');
s.keys("need");
assert_eq!(s.app.search_input(), Some("need"));
s.press(KeyCode::Backspace, KeyModifiers::NONE);
assert_eq!(s.app.search_input(), Some("nee"));
s.keys("dle");
assert_eq!(s.app.search_input(), Some("needle"));
s.esc();
assert!(!s.app.is_searching());
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn e2e_search_esc_during_input_cancels_no_search() {
let dir = sandbox("search_esc_input");
std::fs::write(dir.join("f.txt"), "needle here\nmore\n").unwrap();
let mut s = Sim::new(&canon(&dir));
s.select("f.txt");
s.enter();
s.key('/');
s.keys("needle");
assert!(s.app.is_searching());
s.esc();
assert!(!s.app.is_searching());
assert_eq!(s.app.preview_search_query(), None);
assert_eq!(s.app.search_status(), None);
assert_eq!(s.app.mode, Mode::Preview);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn e2e_search_commit_populates_status_and_jumps_to_first() {
let dir = sandbox("search_commit");
let body = "x MARK a\nfiller one\nfiller two\ny MARK b\nfiller three\nz MARK c\n";
std::fs::write(dir.join("f.txt"), body).unwrap();
let mut s = Sim::new(&canon(&dir));
s.select("f.txt");
s.enter();
s.key('/');
s.keys("MARK");
s.enter();
assert!(!s.app.is_searching());
assert_eq!(s.app.preview_search_query(), Some("MARK"));
assert_eq!(s.app.search_status(), Some((1, 3)));
s.see("x MARK a");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn e2e_search_next_prev_cycle_with_wrap() {
let dir = sandbox("search_cycle");
let body = "a MARK a\nfiller\nfiller\nb MARK b\nfiller\nc MARK c\n";
std::fs::write(dir.join("f.txt"), body).unwrap();
let mut s = Sim::new(&canon(&dir));
s.select("f.txt");
s.enter();
s.key('/');
s.keys("MARK");
s.enter();
assert_eq!(s.app.search_status(), Some((1, 3)));
s.key('n');
assert_eq!(s.app.search_status(), Some((2, 3)));
s.key('n');
assert_eq!(s.app.search_status(), Some((3, 3)));
s.key('n');
assert_eq!(s.app.search_status(), Some((1, 3)));
s.key('N');
assert_eq!(s.app.search_status(), Some((3, 3)));
s.key('N');
assert_eq!(s.app.search_status(), Some((2, 3)));
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn e2e_search_scroll_follows_current_match() {
let dir = sandbox("search_follow");
let mut body = String::new();
for i in 0..300 {
if i == 10 || i == 150 || i == 290 {
body.push_str(&format!("MARK line {i}\n"));
} else {
body.push_str(&format!("filler {i}\n"));
}
}
std::fs::write(dir.join("big.txt"), &body).unwrap();
let mut s = Sim::new(&canon(&dir));
s.select("big.txt");
s.enter();
s.key('/');
s.keys("MARK");
s.enter();
s.see("MARK line 10");
s.dont_see("MARK line 150");
s.key('n');
s.see("MARK line 150");
s.dont_see("MARK line 10");
s.key('n');
s.see("MARK line 290");
s.key('n');
s.see("MARK line 10");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn e2e_search_case_insensitive_matches() {
let dir = sandbox("search_ci");
std::fs::write(
dir.join("f.txt"),
"hello line\nWORLD marker here\nbye line\n",
)
.unwrap();
let mut s = Sim::new(&canon(&dir));
s.select("f.txt");
s.enter();
s.key('/');
s.keys("world");
s.enter();
assert_eq!(s.app.search_status(), Some((1, 1)));
s.see("WORLD marker here");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn e2e_search_no_match_flash_and_empty_status() {
let dir = sandbox("search_no_match");
std::fs::write(dir.join("f.txt"), "alpha\nbeta\ngamma\n").unwrap();
let mut s = Sim::new(&canon(&dir));
s.select("f.txt");
s.enter();
s.key('/');
s.keys("zzzznope");
s.enter();
assert_eq!(s.app.preview_search_query(), Some("zzzznope"));
assert_eq!(s.app.search_status(), None);
assert!(s
.app
.flash
.as_deref()
.is_some_and(|m| m.contains("no match")));
s.key('n');
assert_eq!(s.app.search_status(), None);
s.key('N');
assert_eq!(s.app.search_status(), None);
s.esc();
assert_eq!(s.app.preview_search_query(), None);
assert_eq!(s.app.mode, Mode::Preview);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn e2e_search_esc_clears_then_second_esc_returns_to_tree() {
let dir = sandbox("search_esc_two");
std::fs::write(dir.join("f.txt"), "alpha\nbeta marker\ngamma\n").unwrap();
let mut s = Sim::new(&canon(&dir));
s.select("f.txt");
s.enter();
s.key('/');
s.keys("marker");
s.enter();
assert_eq!(s.app.search_status(), Some((1, 1)));
s.esc();
assert_eq!(s.app.preview_search_query(), None);
assert_eq!(s.app.mode, Mode::Preview);
s.esc();
assert_eq!(s.app.mode, Mode::Tree);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn e2e_search_counts_multiple_occurrences_per_line() {
let dir = sandbox("search_multi_occ");
std::fs::write(
dir.join("f.txt"),
"one TARGET two TARGET end\nfiller line\nTARGET solo\n",
)
.unwrap();
let mut s = Sim::new(&canon(&dir));
s.select("f.txt");
s.enter();
s.key('/');
s.keys("TARGET");
s.enter();
assert_eq!(s.app.search_status(), Some((1, 3)));
s.key('n');
assert_eq!(s.app.search_status(), Some((2, 3)));
s.see("one TARGET two TARGET end");
s.key('n');
assert_eq!(s.app.search_status(), Some((3, 3)));
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn e2e_search_works_in_code_preview() {
let dir = sandbox("search_code");
std::fs::write(
dir.join("code.rs"),
"fn alpha() {}\nfn beta() {}\nlet x = 1;\n",
)
.unwrap();
let mut cfg = Config::default();
cfg.ui.syntax_highlight = false;
let mut s = Sim::with_config(&canon(&dir), cfg);
s.select("code.rs");
s.enter();
assert!(s.app.is_windowed());
s.key('/');
s.keys("fn");
s.enter();
assert_eq!(s.app.search_status(), Some((1, 2)));
s.see("fn alpha()");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn e2e_search_works_on_decorated_markdown_preview() {
let (mut s, dir) = md_preview(
Config::default(),
"doc",
"# Title\n\nsome needle text here\n",
);
assert!(!s.app.is_windowed(), "装飾 md は窓読みではない");
s.key('/');
assert!(s.app.is_searching(), "装飾 md でも検索入力に入れる");
s.keys("needle");
s.enter();
assert_eq!(s.app.preview_search_query(), Some("needle"));
assert_eq!(s.app.search_status(), Some((1, 1)));
assert!(!s.app.is_raw_source(), "raw ソースに切替わらない");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn e2e_search_rejected_on_media_preview() {
let dir = sandbox("search_media");
std::fs::copy("samples/sample.svg", dir.join("pic.svg")).unwrap();
let mut s = Sim::new(&canon(&dir));
s.select("pic.svg");
s.enter();
s.key('/');
assert!(!s.app.is_searching(), "画像プレビューでは検索に入らない");
assert_eq!(s.app.preview_search_query(), None);
assert!(s.app.flash.is_some(), "拒否理由を flash で知らせる");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn e2e_search_empty_query_enter_is_noop() {
let dir = sandbox("search_empty");
std::fs::write(dir.join("f.txt"), "alpha\nbeta\n").unwrap();
let mut s = Sim::new(&canon(&dir));
s.select("f.txt");
s.enter();
s.key('/');
assert!(s.app.is_searching());
s.enter();
assert!(!s.app.is_searching());
assert_eq!(s.app.preview_search_query(), None);
assert_eq!(s.app.search_status(), None);
assert_eq!(s.app.mode, Mode::Preview);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn e2e_search_cjk_query_matches_without_crash() {
let dir = sandbox("search_cjk");
std::fs::write(
dir.join("jp.txt"),
"リンゴ みかん\nぶどう みかん\nバナナ だけ\n",
)
.unwrap();
let mut s = Sim::new(&canon(&dir));
s.select("jp.txt");
s.enter();
assert!(s.app.is_windowed());
s.key('/');
s.keys("みかん");
s.enter();
assert_eq!(s.app.search_status(), Some((1, 2)));
s.key('n');
assert_eq!(s.app.search_status(), Some((2, 2)));
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn e2e_search_second_query_replaces_first() {
let dir = sandbox("search_replace");
std::fs::write(
dir.join("f.txt"),
"MARK aaa\nfiller\nMARK bbb\nSPOT ccc\nfiller\n",
)
.unwrap();
let mut s = Sim::new(&canon(&dir));
s.select("f.txt");
s.enter();
s.key('/');
s.keys("MARK");
s.enter();
assert_eq!(s.app.search_status(), Some((1, 2)));
assert_eq!(s.app.preview_search_query(), Some("MARK"));
s.key('/');
assert!(s.app.is_searching());
s.keys("SPOT");
s.enter();
assert_eq!(s.app.preview_search_query(), Some("SPOT"));
assert_eq!(s.app.search_status(), Some((1, 1)));
s.see("SPOT ccc");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn e2e_visual_block_caret_drawn_and_moves() {
use ratatui::style::Modifier;
let (mut s, dir) = text_preview(
Config::default(),
"visual_caret_cell",
"caret.txt",
"alpha\nbeta\ngamma\n",
);
assert!(s.app.is_windowed());
s.see("alpha");
let reversed = |s: &Sim| -> Vec<(usize, usize)> {
let buf = s.term.backend().buffer();
let w = buf.area.width as usize;
buf.content()
.iter()
.enumerate()
.filter(|(_, c)| c.modifier.contains(Modifier::REVERSED))
.map(|(i, _)| (i % w, i / w))
.collect()
};
let before = reversed(&s);
assert_eq!(before.len(), 1, "one block-caret cell: {before:?}");
s.key('j');
let after = reversed(&s);
assert_eq!(after.len(), 1);
assert_eq!(after[0].0, before[0].0);
assert_eq!(after[0].1, before[0].1 + 1);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn e2e_visual_caret_line_tracks_jk_and_gg() {
let (mut s, dir) = text_preview(
Config::default(),
"visual_line_nav",
"nav.txt",
"alpha\nbeta\ngamma\n",
);
let refstr = |s: &Sim| s.app.selection_ref_string().unwrap();
assert_eq!(refstr(&s), "@nav.txt#L1");
s.key('j');
assert_eq!(refstr(&s), "@nav.txt#L2");
s.key('j');
assert_eq!(refstr(&s), "@nav.txt#L3");
s.key('j');
assert_eq!(refstr(&s), "@nav.txt#L3");
s.key('k');
assert_eq!(refstr(&s), "@nav.txt#L2");
s.key('G');
assert_eq!(refstr(&s), "@nav.txt#L3");
s.key('g');
assert_eq!(refstr(&s), "@nav.txt#L1");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn e2e_visual_v_enters_charwise_state() {
let (mut s, dir) = text_preview(
Config::default(),
"visual_v_char",
"v.txt",
"alpha\nbeta\ngamma\n",
);
assert!(!s.app.is_preview_visual());
s.key('v');
assert!(s.app.is_preview_visual());
assert!(!s.app.preview_visual_linewise());
assert_eq!(
s.app.preview_selection(),
crate::app::PreviewSelection::Char {
start: (0, 0),
end: (0, 0)
}
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn e2e_visual_bigv_enters_linewise_state() {
let (mut s, dir) = text_preview(
Config::default(),
"visual_V_line",
"vv.txt",
"alpha\nbeta\ngamma\n",
);
s.key('V');
assert!(s.app.is_preview_visual());
assert!(s.app.preview_visual_linewise());
assert_eq!(
s.app.preview_selection(),
crate::app::PreviewSelection::Line { lo: 0, hi: 0 }
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn e2e_visual_charwise_range_grows_with_l() {
let (mut s, dir) = text_preview(
Config::default(),
"visual_grow_l",
"gl.txt",
"alpha\nbeta\ngamma\n",
);
s.key('v');
s.key('l');
assert_eq!(
s.app.preview_selection(),
crate::app::PreviewSelection::Char {
start: (0, 0),
end: (0, 1)
}
);
s.key('l');
assert_eq!(
s.app.preview_selection(),
crate::app::PreviewSelection::Char {
start: (0, 0),
end: (0, 2)
}
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn e2e_visual_charwise_range_grows_across_lines() {
let (mut s, dir) = text_preview(
Config::default(),
"visual_grow_j",
"gj.txt",
"alpha\nbeta\ngamma\n",
);
s.key('v');
s.key('j');
assert_eq!(
s.app.preview_selection(),
crate::app::PreviewSelection::Char {
start: (0, 0),
end: (1, 0)
}
);
s.key('j');
assert_eq!(
s.app.preview_selection(),
crate::app::PreviewSelection::Char {
start: (0, 0),
end: (2, 0)
}
);
assert_eq!(
s.app.selection_ref_string().as_deref(),
Some("@gj.txt#L1-3")
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn e2e_visual_linewise_range_grows_with_j() {
let (mut s, dir) = text_preview(
Config::default(),
"visual_lw_grow",
"lw.txt",
"alpha\nbeta\ngamma\n",
);
s.key('V');
s.key('j');
assert_eq!(
s.app.preview_selection(),
crate::app::PreviewSelection::Line { lo: 0, hi: 1 }
);
assert_eq!(
s.app.selection_ref_string().as_deref(),
Some("@lw.txt#L1-2")
);
s.key('j');
assert_eq!(
s.app.preview_selection(),
crate::app::PreviewSelection::Line { lo: 0, hi: 2 }
);
assert_eq!(
s.app.selection_ref_string().as_deref(),
Some("@lw.txt#L1-3")
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn e2e_visual_col_home_and_end_bound_selection() {
let (mut s, dir) = text_preview(
Config::default(),
"visual_home_end",
"he.txt",
"alpha\nbeta\ngamma\n",
);
s.key('v');
s.key('$');
assert_eq!(
s.app.preview_selection(),
crate::app::PreviewSelection::Char {
start: (0, 0),
end: (0, 4)
}
);
s.key('0');
assert_eq!(
s.app.preview_selection(),
crate::app::PreviewSelection::Char {
start: (0, 0),
end: (0, 0)
}
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn e2e_visual_gg_extend_linewise_to_ends() {
let (mut s, dir) = text_preview(
Config::default(),
"visual_gg_line",
"gg.txt",
"alpha\nbeta\ngamma\n",
);
s.key('V');
s.key('G');
assert_eq!(
s.app.preview_selection(),
crate::app::PreviewSelection::Line { lo: 0, hi: 2 }
);
assert_eq!(
s.app.selection_ref_string().as_deref(),
Some("@gg.txt#L1-3")
);
s.key('g');
assert_eq!(
s.app.preview_selection(),
crate::app::PreviewSelection::Line { lo: 0, hi: 0 }
);
assert_eq!(s.app.selection_ref_string().as_deref(), Some("@gg.txt#L1"));
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn e2e_visual_cancel_with_esc() {
let (mut s, dir) = text_preview(
Config::default(),
"visual_esc",
"esc.txt",
"alpha\nbeta\ngamma\n",
);
s.key('v');
s.key('l');
assert!(s.app.is_preview_visual());
s.esc();
assert!(!s.app.is_preview_visual());
assert_eq!(s.app.mode, Mode::Preview);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn e2e_visual_cancel_with_v_bigv_q_toggles() {
let (mut s, dir) = text_preview(
Config::default(),
"visual_toggles",
"tg.txt",
"alpha\nbeta\ngamma\n",
);
s.key('v');
assert!(s.app.is_preview_visual());
s.key('v');
assert!(!s.app.is_preview_visual());
assert_eq!(s.app.mode, Mode::Preview);
s.key('v');
assert!(s.app.is_preview_visual());
s.key('V');
assert!(!s.app.is_preview_visual());
assert_eq!(s.app.mode, Mode::Preview);
s.key('v');
assert!(s.app.is_preview_visual());
s.key('q');
assert!(!s.app.is_preview_visual());
assert_eq!(s.app.mode, Mode::Preview);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn e2e_visual_ref_caret_line_and_charwise_span() {
let (mut s, dir) = text_preview(
Config::default(),
"visual_ref_char",
"rc.txt",
"alpha\nbeta\ngamma\n",
);
assert_eq!(s.app.selection_ref_string().as_deref(), Some("@rc.txt#L1"));
s.key('j');
assert_eq!(s.app.selection_ref_string().as_deref(), Some("@rc.txt#L2"));
s.key('v');
s.key('j');
assert!(matches!(
s.app.preview_selection(),
crate::app::PreviewSelection::Char { .. }
));
assert_eq!(
s.app.selection_ref_string().as_deref(),
Some("@rc.txt#L2-3")
);
s.key('Y');
assert!(!s.app.is_preview_visual());
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn e2e_visual_ref_linewise_span() {
let (mut s, dir) = text_preview(
Config::default(),
"visual_ref_line",
"rl.txt",
"alpha\nbeta\ngamma\n",
);
s.key('V');
s.key('j');
s.key('j');
assert_eq!(
s.app.preview_selection(),
crate::app::PreviewSelection::Line { lo: 0, hi: 2 }
);
assert_eq!(
s.app.selection_ref_string().as_deref(),
Some("@rl.txt#L1-3")
);
s.key('Y');
assert!(!s.app.is_preview_visual());
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn e2e_visual_selection_in_code_preview() {
let mut cfg = Config::default();
cfg.ui.syntax_highlight = false;
let (mut s, dir) = text_preview(cfg, "visual_code", "code.rs", "fn one() {}\nfn two() {}\n");
assert!(s.app.is_windowed());
s.see("fn one");
assert_eq!(s.app.selection_ref_string().as_deref(), Some("@code.rs#L1"));
s.key('j');
assert_eq!(s.app.selection_ref_string().as_deref(), Some("@code.rs#L2"));
s.key('k');
assert_eq!(s.app.selection_ref_string().as_deref(), Some("@code.rs#L1"));
s.key('v');
assert!(s.app.is_preview_visual() && !s.app.preview_visual_linewise());
s.key('l');
assert_eq!(
s.app.preview_selection(),
crate::app::PreviewSelection::Char {
start: (0, 0),
end: (0, 1)
}
);
s.esc();
assert!(!s.app.is_preview_visual());
assert_eq!(s.app.mode, Mode::Preview);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn e2e_visual_selection_in_raw_markdown() {
let (mut s, dir) = text_preview(
Config::default(),
"visual_rawmd",
"doc.md",
"# Heading\nalpha line\nbeta line\n",
);
assert!(!s.app.is_windowed());
assert!(!s.app.is_md_raw());
s.key('v');
assert!(!s.app.is_preview_visual());
s.key('R');
assert!(s.app.is_windowed());
assert!(s.app.is_md_raw() && s.app.is_raw_source());
assert_eq!(s.app.selection_ref_string().as_deref(), Some("@doc.md#L1"));
s.key('v');
assert!(s.app.is_preview_visual());
assert_eq!(
s.app.preview_selection(),
crate::app::PreviewSelection::Char {
start: (0, 0),
end: (0, 0)
}
);
s.key('j');
assert_eq!(
s.app.preview_selection(),
crate::app::PreviewSelection::Char {
start: (0, 0),
end: (1, 0)
}
);
assert_eq!(
s.app.selection_ref_string().as_deref(),
Some("@doc.md#L1-2")
);
s.esc();
assert!(!s.app.is_preview_visual());
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn e2e_visual_cjk_charwise_no_panic() {
let (mut s, dir) = text_preview(
Config::default(),
"visual_cjk",
"cjk.txt",
"あいう\nかきくけ\n",
);
assert!(s.app.is_windowed());
assert_eq!(s.app.selection_ref_string().as_deref(), Some("@cjk.txt#L1"));
s.key('v');
s.key('l');
assert_eq!(
s.app.preview_selection(),
crate::app::PreviewSelection::Char {
start: (0, 0),
end: (0, 1)
}
);
s.key('l');
assert_eq!(
s.app.preview_selection(),
crate::app::PreviewSelection::Char {
start: (0, 0),
end: (0, 2)
}
);
s.key('$');
assert_eq!(
s.app.preview_selection(),
crate::app::PreviewSelection::Char {
start: (0, 0),
end: (0, 2)
}
);
s.key('y');
assert!(!s.app.is_preview_visual());
std::fs::remove_dir_all(&dir).ok();
}
#[cfg(feature = "git")]
fn seed_repo_history(dir: &std::path::Path) {
let run = |args: &[&str]| {
let out = std::process::Command::new("git")
.current_dir(dir)
.args(args)
.output()
.unwrap();
assert!(out.status.success(), "git {args:?}: {out:?}");
};
run(&["init", "-q", "."]);
run(&["config", "user.email", "t@t"]);
run(&["config", "user.name", "t"]);
std::fs::write(dir.join("a.rs"), "fn a() {}\n").unwrap();
run(&["add", "-A"]);
run(&["commit", "-q", "-m", "alphacommit"]);
std::fs::write(dir.join("a.rs"), "fn a() { 1 }\n").unwrap();
run(&["add", "-A"]);
run(&["commit", "-q", "-m", "betacommit"]);
std::fs::write(dir.join("a.rs"), "fn a() { 22 }\n").unwrap();
run(&["add", "-A"]);
run(&["commit", "-q", "-m", "gammacommit"]);
run(&["branch", "feature-x"]);
}
#[cfg(feature = "git")]
fn staged_of(app: &App, name: &str) -> Option<bool> {
app.git_view_entries()
.iter()
.find(|e| e.path.ends_with(name))
.map(|e| e.staged)
}
#[cfg(feature = "git")]
#[test]
fn e2e_git_stage_then_commit_appears_in_log() {
let dir = sandbox("git_commit_flow");
seed_repo(&dir);
let mut s = Sim::new(&canon(&dir));
s.key('o');
assert!(s.app.is_git_view());
assert_eq!(staged_of(&s.app, "a.rs"), Some(false));
s.key('s');
assert_eq!(staged_of(&s.app, "a.rs"), Some(true));
s.key('c');
assert!(s.app.is_dialog());
s.keys("ZZCOMMITSUBJECT");
s.enter();
let commits = crate::git::log(&s.app.root, 200);
assert!(
commits.iter().any(|c| c.summary == "ZZCOMMITSUBJECT"),
"commit in log: {:?}",
commits
.iter()
.map(|c| c.summary.clone())
.collect::<Vec<_>>()
);
assert!(s.app.is_git_view());
assert_eq!(staged_of(&s.app, "a.rs"), None);
assert!(staged_of(&s.app, "new.txt").is_some());
s.key('l');
assert!(s.app.is_git_log());
s.see("ZZCOMMITSUBJECT");
s.see("init");
std::fs::remove_dir_all(&dir).ok();
}
#[cfg(feature = "git")]
#[test]
fn e2e_git_stage_unstage_roundtrip() {
let dir = sandbox("git_stage_unstage");
seed_repo(&dir);
let mut s = Sim::new(&canon(&dir));
s.key('o');
assert_eq!(staged_of(&s.app, "a.rs"), Some(false));
s.key('s');
assert_eq!(staged_of(&s.app, "a.rs"), Some(true));
s.key('u');
assert_eq!(staged_of(&s.app, "a.rs"), Some(false));
std::fs::remove_dir_all(&dir).ok();
}
#[cfg(feature = "git")]
#[test]
fn e2e_git_stage_all_then_unstage_all() {
let dir = sandbox("git_stage_all");
seed_repo(&dir);
let mut s = Sim::new(&canon(&dir));
s.key('o');
assert!(!s.app.git_view_entries().is_empty());
assert!(s.app.git_view_entries().iter().any(|e| !e.staged));
s.key('S');
assert!(s.app.git_view_entries().iter().all(|e| e.staged));
s.key('U');
assert!(s.app.git_view_entries().iter().all(|e| !e.staged));
std::fs::remove_dir_all(&dir).ok();
}
#[cfg(feature = "git")]
#[test]
fn e2e_git_diff_layout_toggle_cycles_in_preview() {
let dir = sandbox("git_diff_layout");
seed_repo(&dir);
let mut s = Sim::new(&canon(&dir));
s.select("a.rs");
s.key('d');
assert!(s.app.is_git_diff_preview());
s.see("diff");
assert!(!s.app.diff_is_split(200));
s.key('s');
s.see("side-by-side");
assert!(s.app.diff_is_split(1) && s.app.diff_is_split(200));
s.key('s');
s.see("diff: auto");
assert!(!s.app.diff_is_split(50) && s.app.diff_is_split(200));
s.key('s');
s.see("diff: unified");
assert!(!s.app.diff_is_split(200));
std::fs::remove_dir_all(&dir).ok();
}
#[cfg(feature = "git")]
#[test]
fn e2e_git_worktree_diff_toggle_layout_and_back() {
let dir = sandbox("git_worktree_detail");
seed_repo(&dir);
let mut s = Sim::new(&canon(&dir));
s.key('o');
s.key('d');
assert!(s.app.is_git_detail());
s.see("Uncommitted changes");
s.dont_see("⇆");
s.key('s');
s.see("side-by-side");
s.see("⇆");
s.key('q');
assert!(!s.app.is_git_detail());
assert!(s.app.is_git_view());
std::fs::remove_dir_all(&dir).ok();
}
#[cfg(feature = "git")]
#[test]
fn e2e_git_log_navigate_open_detail_and_back() {
let dir = sandbox("git_log_nav");
seed_repo_history(&dir);
let mut s = Sim::new(&canon(&dir));
s.key('o');
s.key('l');
assert!(s.app.is_git_log());
s.see("gammacommit");
s.see("betacommit");
s.see("alphacommit");
let top = s.app.git_log_selected_id().expect("top id");
s.key('j');
let second = s.app.git_log_selected_id().expect("second id");
assert_ne!(top, second);
let second_subj = crate::git::commit_meta(&s.app.root, &second)
.and_then(|m| m.message.lines().next().map(str::to_string))
.expect("second subject");
s.enter();
assert!(s.app.is_git_detail());
s.see(&second_subj); s.key('q');
assert!(s.app.is_git_log());
assert_eq!(
s.app.git_log_selected_id().as_deref(),
Some(second.as_str())
);
s.key('l');
assert!(s.app.is_git_detail());
s.see(&second_subj); s.key('q');
assert!(s.app.is_git_log());
std::fs::remove_dir_all(&dir).ok();
}
#[cfg(feature = "git")]
#[test]
fn e2e_git_graph_navigates_commit_rows() {
let dir = sandbox("git_graph_nav");
seed_repo_history(&dir);
let mut s = Sim::new(&canon(&dir));
s.key('o');
s.key('g');
assert!(s.app.is_git_graph());
s.see("●");
s.key('g');
let top = s
.app
.git_graph_selected_row()
.and_then(|r| r.commit.clone())
.expect("top commit");
s.key('G');
let bottom = s
.app
.git_graph_selected_row()
.and_then(|r| r.commit.clone())
.expect("bottom commit");
assert_ne!(top, bottom);
s.key('g');
s.key('j');
let after_j = s
.app
.git_graph_selected_row()
.and_then(|r| r.commit.clone());
assert!(
after_j.is_some() && after_j.as_deref() != Some(top.as_str()),
"j: {after_j:?}"
);
std::fs::remove_dir_all(&dir).ok();
}
#[cfg(feature = "git")]
#[test]
fn e2e_git_graph_set_base_keeps_selection_then_clear() {
let dir = sandbox("git_graph_base2");
seed_repo_history(&dir);
let mut s = Sim::new(&canon(&dir));
s.key('o');
s.key('g');
s.dont_see("⌖ base:");
assert!(s.app.git_graph_base_label().is_none());
s.key('g');
let sel_before = s
.app
.git_graph_selected_row()
.and_then(|r| r.commit.clone())
.expect("commit");
s.key('s');
s.see("⌖ base:");
assert!(s.app.git_graph_base_label().is_some());
assert_eq!(
s.app
.git_graph_selected_row()
.and_then(|r| r.commit.clone()),
Some(sel_before)
);
s.key('x');
s.dont_see("⌖ base:");
assert!(s.app.git_graph_base_label().is_none());
std::fs::remove_dir_all(&dir).ok();
}
#[cfg(feature = "git")]
#[test]
fn e2e_git_graph_picker_current_only_then_all() {
let dir = sandbox("git_graph_picker2");
seed_repo_history(&dir);
let mut s = Sim::new(&canon(&dir));
s.key('o');
s.key('g');
s.key('b');
assert!(s.app.is_git_graph_picker());
let feature_on = |app: &App| -> Option<bool> {
app.git_graph_picker_items()
.into_iter()
.find(|(name, _, _)| name == "feature-x")
.map(|(_, _, on)| on)
};
assert_eq!(feature_on(&s.app), Some(true));
s.key('n');
assert_eq!(feature_on(&s.app), Some(false));
s.key('a');
assert_eq!(feature_on(&s.app), Some(true));
s.key('q');
assert!(!s.app.is_git_graph_picker());
assert!(s.app.is_git_graph());
std::fs::remove_dir_all(&dir).ok();
}
#[cfg(feature = "git")]
#[test]
fn e2e_git_branches_filter_narrows_and_clears() {
let dir = sandbox("git_branches_filter");
seed_repo_history(&dir);
let mut s = Sim::new(&canon(&dir));
s.key('o');
s.key('b');
assert!(s.app.is_git_branches());
assert_eq!(s.app.git_branch_view().len(), 2);
s.key('/');
assert!(s.app.git_branch_filtering());
s.keys("feature");
let view = s.app.git_branch_view();
assert_eq!(view.len(), 1);
assert_eq!(view[0].name, "feature-x");
s.see("feature-x");
s.esc();
assert!(!s.app.git_branch_filtering());
assert_eq!(s.app.git_branch_view().len(), 2);
std::fs::remove_dir_all(&dir).ok();
}
#[cfg(feature = "git")]
#[test]
fn e2e_git_branch_create_switches_to_it() {
let dir = sandbox("git_branch_create");
seed_repo(&dir);
let mut s = Sim::new(&canon(&dir));
s.key('o');
s.key('b');
s.key('n');
assert!(s.app.is_dialog());
s.keys("createdbr");
s.enter();
let branches = crate::git::branches(&s.app.root);
let created = branches.iter().find(|b| b.name == "createdbr");
assert!(created.is_some());
assert!(created.unwrap().is_current);
assert!(s.app.is_git_view());
std::fs::remove_dir_all(&dir).ok();
}
#[cfg(feature = "git")]
#[test]
fn e2e_git_branch_delete_removes_feature_branch() {
let dir = sandbox("git_branch_delete");
seed_repo(&dir);
let mut s = Sim::new(&canon(&dir));
s.key('o');
s.key('b');
s.key('g');
assert_eq!(
s.app.git_branch_view()[s.app.git_branch_sel()].name,
"feature-x"
);
s.key('d');
assert!(s.app.is_dialog());
s.key('y');
assert!(!crate::git::branches(&s.app.root)
.iter()
.any(|b| b.name == "feature-x"));
assert!(!s
.app
.git_branch_view()
.iter()
.any(|b| b.name == "feature-x"));
std::fs::remove_dir_all(&dir).ok();
}
#[cfg(feature = "git")]
#[test]
fn e2e_git_copy_leader_all_subkeys_consume_from_log() {
let dir = sandbox("git_copy_subkeys");
seed_repo(&dir);
let mut s = Sim::new(&canon(&dir));
s.key('o');
s.key('l');
assert_eq!(s.app.surface(), crate::keymap::Surface::GitLog);
s.see("init");
let id = s.app.git_log_selected_id().expect("selected id");
let meta = crate::git::commit_meta(&s.app.root, &id).expect("commit_meta");
assert_eq!(meta.message.lines().next(), Some("init"));
assert_eq!(meta.author, "t");
assert_eq!(meta.short.len(), 7);
assert_eq!(meta.id.len(), 40);
assert!(meta.id.starts_with(&meta.short));
assert!(!meta.date.is_empty());
for sub in ['s', 'h', 't', 'm', 'a', 'd'] {
s.key('y');
assert_eq!(
s.app.pending_leader,
Some(crate::keymap::LeaderId::GitCopy),
"sub={sub}"
);
s.key(sub);
assert_eq!(s.app.pending_leader, None, "y->{sub}");
}
std::fs::remove_dir_all(&dir).ok();
}
#[cfg(feature = "git")]
#[test]
fn e2e_git_copy_leader_opens_in_graph_and_detail() {
let dir = sandbox("git_copy_surfaces");
seed_repo_history(&dir);
let mut s = Sim::new(&canon(&dir));
s.key('o');
s.key('g');
s.key('g');
assert_eq!(s.app.surface(), crate::keymap::Surface::GitGraph);
s.key('y');
assert_eq!(s.app.pending_leader, Some(crate::keymap::LeaderId::GitCopy));
s.esc();
assert_eq!(s.app.pending_leader, None);
s.enter();
assert_eq!(s.app.surface(), crate::keymap::Surface::GitDetail);
s.key('y');
assert_eq!(s.app.pending_leader, Some(crate::keymap::LeaderId::GitCopy));
s.esc();
assert_eq!(s.app.pending_leader, None);
std::fs::remove_dir_all(&dir).ok();
}
#[cfg(feature = "git")]
#[test]
fn e2e_git_changed_filter_n_jumps_between_changed_files() {
let dir = sandbox("git_changed_nN");
seed_repo(&dir);
let mut s = Sim::new(&canon(&dir));
s.key('C');
assert!(s.app.changed_filter());
s.see("a.rs");
s.see("new.txt");
s.select("a.rs");
assert!(s.app.entries[s.app.selected].path.ends_with("a.rs"));
s.key('n');
assert!(s.app.entries[s.app.selected].path.ends_with("new.txt"));
s.key('n');
assert!(s.app.entries[s.app.selected].path.ends_with("a.rs"));
s.key('N');
assert!(s.app.entries[s.app.selected].path.ends_with("new.txt"));
s.key('C');
assert!(!s.app.changed_filter());
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn e2e_sort_by_size_orders_ascending() {
let dir = sandbox("sort_size");
std::fs::write(dir.join("aaa.txt"), vec![b'a'; 300]).unwrap();
std::fs::write(dir.join("bbb.txt"), vec![b'b'; 30]).unwrap();
std::fs::write(dir.join("ccc.txt"), vec![b'c'; 150]).unwrap();
let mut s = Sim::new(&canon(&dir));
let names: Vec<&str> = s
.app
.entries
.iter()
.map(|e| e.path.file_name().and_then(|n| n.to_str()).unwrap_or(""))
.collect();
assert_eq!(names, vec!["aaa.txt", "bbb.txt", "ccc.txt"]);
s.key('s');
s.key('s');
assert!(!s.app.is_sort_menu());
let names: Vec<&str> = s
.app
.entries
.iter()
.map(|e| e.path.file_name().and_then(|n| n.to_str()).unwrap_or(""))
.collect();
assert_eq!(names, vec!["bbb.txt", "ccc.txt", "aaa.txt"]);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn e2e_sort_by_modified_orders_ascending() {
let dir = sandbox("sort_mod");
fn set_mtime(p: &std::path::Path, secs: u64) {
std::fs::write(p, b"x\n").unwrap();
let f = std::fs::OpenOptions::new().write(true).open(p).unwrap();
f.set_modified(std::time::UNIX_EPOCH + std::time::Duration::from_secs(secs))
.unwrap();
}
set_mtime(&dir.join("m_a.txt"), 2_000_000_000);
set_mtime(&dir.join("m_b.txt"), 1_000_000_000);
set_mtime(&dir.join("m_c.txt"), 1_500_000_000);
let mut s = Sim::new(&canon(&dir));
s.key('s');
s.key('m');
assert!(!s.app.is_sort_menu());
let names: Vec<&str> = s
.app
.entries
.iter()
.map(|e| e.path.file_name().and_then(|n| n.to_str()).unwrap_or(""))
.collect();
assert_eq!(names, vec!["m_b.txt", "m_c.txt", "m_a.txt"]);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn e2e_sort_by_ext_orders_ascending() {
let dir = sandbox("sort_ext");
std::fs::write(dir.join("zzz.aaa"), b"1\n").unwrap();
std::fs::write(dir.join("aaa.zzz"), b"2\n").unwrap();
std::fs::write(dir.join("mmm.mmm"), b"3\n").unwrap();
let mut s = Sim::new(&canon(&dir));
let names: Vec<&str> = s
.app
.entries
.iter()
.map(|e| e.path.file_name().and_then(|n| n.to_str()).unwrap_or(""))
.collect();
assert_eq!(names, vec!["aaa.zzz", "mmm.mmm", "zzz.aaa"]);
s.key('s');
s.key('e');
assert!(!s.app.is_sort_menu());
let names: Vec<&str> = s
.app
.entries
.iter()
.map(|e| e.path.file_name().and_then(|n| n.to_str()).unwrap_or(""))
.collect();
assert_eq!(names, vec!["zzz.aaa", "mmm.mmm", "aaa.zzz"]);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn e2e_tree_filter_no_matches_shows_empty_list() {
let dir = sandbox("filter_nomatch");
seed_files(&dir);
let mut s = Sim::new(&canon(&dir));
let all = s.app.entries.len();
s.key('/');
s.keys("qzqzqznope");
assert!(s.app.is_filtering());
assert!(s.app.entries.is_empty(), "{}", s.screen());
s.dont_see("notes.txt");
s.enter();
assert!(s.app.entries.is_empty());
s.esc();
assert_eq!(s.app.entries.len(), all);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn e2e_tree_filter_is_case_insensitive() {
let dir = sandbox("filter_case");
seed_files(&dir);
std::fs::write(dir.join("UPPER.TXT"), "X\n").unwrap();
let mut s = Sim::new(&canon(&dir));
s.key('/');
s.keys("README");
s.see("readme.md");
assert!(s.app.entries.iter().any(|e| e.path.ends_with("readme.md")));
assert!(!s.app.entries.iter().any(|e| e.path.ends_with("UPPER.TXT")));
for _ in 0..8 {
s.press(KeyCode::Backspace, KeyModifiers::NONE);
}
s.keys("upper");
s.see("UPPER.TXT");
assert!(s.app.entries.iter().any(|e| e.path.ends_with("UPPER.TXT")));
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn e2e_table_copy_leader_sub_keys_consume_and_flash() {
let dir = sandbox("table_copy_wiring");
std::fs::write(dir.join("data.csv"), "name,qty\napple,3\nkiwi,7\n").unwrap();
let mut s = Sim::new(&canon(&dir));
s.select("data.csv");
s.enter();
s.see("TABLE");
s.key('j');
s.key('l');
assert_eq!(s.app.table_cursor(), (1, 1));
for sub in ['c', 'r', 'C'] {
s.app.flash = None;
s.key('y');
assert_eq!(
s.app.pending_leader,
Some(crate::keymap::LeaderId::TableCopy)
);
s.key(sub);
assert_eq!(s.app.pending_leader, None, "sub={sub}");
assert!(s.app.flash.is_some(), "sub={sub}");
}
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn e2e_table_copy_full_path_value() {
let dir = sandbox("table_copy_path");
std::fs::write(dir.join("data.csv"), "name,qty\napple,3\n").unwrap();
let mut s = Sim::new(&canon(&dir));
s.select("data.csv");
s.enter();
s.see("TABLE");
let csv = s.app.preview_path.clone().expect("preview path");
assert_eq!(
s.app.copy_string_for(crate::app::CopyKind::Full),
Some(csv.display().to_string())
);
s.key('y');
assert_eq!(
s.app.pending_leader,
Some(crate::keymap::LeaderId::TableCopy)
);
s.key('f');
assert_eq!(s.app.pending_leader, None);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn e2e_bookmark_set_from_preview_targets_shown_file() {
let root = sandbox("bm_preview_target");
let proj = root.join("proj");
std::fs::create_dir_all(&proj).unwrap();
seed_files(&proj);
let proj = canon(&proj);
let mut s = Sim::new(&proj);
s.app.bookmarks = crate::bookmarks::Bookmarks::with_base(root.join("cfg"), &proj);
s.select("notes.txt");
s.keys("ma");
s.see("bookmarked");
s.select("readme.md");
s.key('\'');
s.see("Bookmarks");
s.key('a');
assert_eq!(s.app.mode, Mode::Preview);
assert!(s
.app
.preview_path
.as_deref()
.is_some_and(|p| p.ends_with("notes.txt")));
assert!(s.app.entries[s.app.selected].path.ends_with("readme.md"));
s.keys("mx");
assert!(
s.app
.bookmarks
.get('x')
.is_some_and(|p| p.ends_with("notes.txt")),
"{:?}",
s.app.bookmarks.get('x')
);
assert!(!s
.app
.bookmarks
.get('x')
.is_some_and(|p| p.ends_with("readme.md")));
std::fs::remove_dir_all(&root).ok();
}
#[test]
fn e2e_bookmark_list_enter_jumps_to_file() {
let root = sandbox("bm_list_enter");
let proj = root.join("proj");
std::fs::create_dir_all(&proj).unwrap();
std::fs::write(proj.join("a.txt"), "ALPHA_BODY\n").unwrap();
std::fs::write(proj.join("b.txt"), "BETA_BODY\n").unwrap();
let proj = canon(&proj);
let mut s = Sim::new(&proj);
s.app.bookmarks = crate::bookmarks::Bookmarks::with_base(root.join("cfg"), &proj);
s.select("a.txt");
s.keys("ma");
s.select("b.txt");
s.keys("mb");
s.select("b.txt");
s.enter();
assert_eq!(s.app.mode, Mode::Preview);
s.see("BETA_BODY");
s.key('\'');
s.see("Bookmarks");
assert_eq!(s.app.bookmark_list_sel(), 0);
s.enter();
assert!(!s.app.is_bookmark_list());
assert_eq!(s.app.mode, Mode::Preview);
assert!(
s.app
.preview_path
.as_deref()
.is_some_and(|p| p.ends_with("a.txt")),
"{:?}",
s.app.preview_path
);
s.see("ALPHA_BODY");
std::fs::remove_dir_all(&root).ok();
}
#[test]
fn e2e_pastejump_hash_line_fragment_scrolls() {
let dir = sandbox("pastejump_hash");
let mut body = String::new();
for i in 1..=60 {
if i == 1 {
body.push_str("LINE_ONE_ZZ\n");
} else if i == 40 {
body.push_str("LINE_FORTY_ZZ\n");
} else {
body.push_str(&format!("plain line {i}\n"));
}
}
std::fs::write(dir.join("deep.txt"), body).unwrap();
let dir = canon(&dir);
let mut s = Sim::new(&dir);
s.app.paste_jump_from("deep.txt#L40");
s.draw();
assert_eq!(s.app.mode, Mode::Preview);
assert!(s
.app
.preview_path
.as_deref()
.is_some_and(|p| p.ends_with("deep.txt")));
s.see("LINE_FORTY_ZZ");
s.dont_see("LINE_ONE_ZZ");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn e2e_pastejump_at_ref_relative_opens() {
let dir = sandbox("pastejump_atref");
std::fs::create_dir_all(dir.join("sub")).unwrap();
std::fs::write(dir.join("sub/target.txt"), "AT_REF_BODY\nsecond line\n").unwrap();
let dir = canon(&dir);
let mut s = Sim::new(&dir);
s.app.paste_jump_from("@sub/target.txt");
s.draw();
assert_eq!(s.app.mode, Mode::Preview);
assert!(s
.app
.preview_path
.as_deref()
.is_some_and(|p| p.ends_with("sub/target.txt")));
s.see("AT_REF_BODY");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn e2e_pastejump_no_line_opens_at_top() {
let dir = sandbox("pastejump_top");
let mut body = String::new();
for i in 1..=60 {
if i == 1 {
body.push_str("TOP_MARKER_LINE\n");
} else if i == 50 {
body.push_str("DEEP_MARKER_LINE\n");
} else {
body.push_str(&format!("filler line {i}\n"));
}
}
std::fs::write(dir.join("top.txt"), body).unwrap();
let dir = canon(&dir);
let mut s = Sim::new(&dir);
s.app.paste_jump_from("top.txt");
s.draw();
assert_eq!(s.app.mode, Mode::Preview);
assert!(s
.app
.preview_path
.as_deref()
.is_some_and(|p| p.ends_with("top.txt")));
s.see("TOP_MARKER_LINE");
s.dont_see("DEEP_MARKER_LINE");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn e2e_pastejump_non_github_url_unrecognized() {
let dir = sandbox("pastejump_nongithub");
seed_files(&dir);
let dir = canon(&dir);
let mut s = Sim::new(&dir);
assert_eq!(s.app.mode, Mode::Tree);
assert!(s.app.preview_path.is_none());
s.app.paste_jump_from("https://example.com/some/file.rs");
s.draw();
assert_eq!(s.app.mode, Mode::Tree);
assert!(s.app.preview_path.is_none());
assert!(s.app.flash.is_some());
std::fs::remove_dir_all(&dir).ok();
}
#[cfg(target_os = "macos")]
#[test]
fn e2e_fileop_delete_trash_removes_file() {
let dir = sandbox("fileop_trash");
seed_files(&dir);
let mut s = Sim::new(&canon(&dir));
s.select("notes.txt");
s.key(' ');
s.key('d');
assert!(s.app.is_dialog());
s.key('y');
assert!(!dir.join("notes.txt").exists());
assert!(!s.app.is_dialog());
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn e2e_fileop_rename_to_existing_name_fails() {
let dir = sandbox("fileop_rename_exist");
seed_files(&dir);
let mut s = Sim::new(&canon(&dir));
s.select("notes.txt");
s.key(' ');
s.key('r');
for _ in 0..40 {
s.press(KeyCode::Backspace, KeyModifiers::NONE);
}
s.keys("data.csv");
s.enter();
assert!(dir.join("notes.txt").exists());
assert!(dir.join("data.csv").exists());
assert!(!s.app.is_dialog());
assert!(s.app.flash.is_some());
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn e2e_fileop_create_targets_cursor_file_parent() {
let dir = sandbox("fileop_create_subdir");
std::fs::create_dir_all(dir.join("sub")).unwrap();
std::fs::write(dir.join("sub/inner.txt"), "x\n").unwrap();
std::fs::write(dir.join("root_file.txt"), "y\n").unwrap();
let dir = canon(&dir);
let mut s = Sim::new(&dir);
s.select("sub");
s.enter();
s.select("sub/inner.txt");
s.key(' ');
s.key('n');
s.keys("made.txt");
s.enter();
assert!(dir.join("sub/made.txt").exists());
assert!(!dir.join("made.txt").exists());
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn e2e_task_toggle_works_in_a_document_containing_an_alert() {
let dir = sandbox("md_task_alert");
let body = "# Checklist\n\n- [ ] plain one\n\n> [!NOTE]\n> - [ ] inside alert\n> - [ ] second in alert\n\n- [ ] after alert\n";
std::fs::write(dir.join("todo.md"), body).unwrap();
let mut s = Sim::with_config(&canon(&dir), Config::default());
s.select("todo.md");
s.enter();
let read = || std::fs::read_to_string(dir.join("todo.md")).unwrap();
s.tab();
s.key(' ');
assert!(read().contains("- [x] plain one"), "外側1: {}", read());
s.dont_see("file changed on disk");
s.tab();
s.key(' ');
assert!(
read().contains("> - [x] inside alert"),
"アラート内1: {}",
read()
);
s.tab();
s.key(' ');
assert!(
read().contains("> - [x] second in alert"),
"アラート内2: {}",
read()
);
s.tab();
s.key(' ');
assert!(read().contains("- [x] after alert"), "外側2: {}", read());
s.dont_see("file changed on disk");
let out = read();
assert!(
out.contains("# Checklist") && out.contains("> [!NOTE]"),
"{out}"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn e2e_task_custom_states_cycle_inside_an_alert() {
let dir = sandbox("md_task_alert_states");
std::fs::write(dir.join("todo.md"), "> [!TIP]\n> - [ ] cycle me\n").unwrap();
let mut cfg = Config::default();
cfg.ui.md_task_states = vec![" ".into(), "/".into(), "x".into()];
let mut s = Sim::with_config(&canon(&dir), cfg);
s.select("todo.md");
s.enter();
let read = || std::fs::read_to_string(dir.join("todo.md")).unwrap();
s.tab();
for want in ["> - [/] cycle me", "> - [x] cycle me", "> - [ ] cycle me"] {
s.key(' ');
assert!(read().contains(want), "期待 {want}: {}", read());
}
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn e2e_task_toggle_works_with_a_collapsed_details_block() {
let dir = sandbox("md_task_details");
std::fs::write(
dir.join("todo.md"),
"- [ ] visible\n\n<details>\n<summary>Hidden</summary>\n\n- [ ] collapsed\n\n</details>\n",
)
.unwrap();
let mut s = Sim::with_config(&canon(&dir), Config::default());
s.select("todo.md");
s.enter();
let read = || std::fs::read_to_string(dir.join("todo.md")).unwrap();
s.tab(); s.key(' ');
let out = read();
assert!(
out.contains("- [x] visible") || out.contains("- [ ] collapsed"),
"可視タスクがトグルできる(全キャンセルされない): {out}"
);
s.dont_see("file changed on disk");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn e2e_link_targets_stay_aligned_with_document_order() {
let cases: &[(&str, &str, &[&str])] = &[
(
"inline",
"[a](./a.md) and [b](./b.md)\n",
&["./a.md", "./b.md"],
),
(
"with title",
"[a](./a.md \"T\") [b](./b.md)\n",
&["./a.md", "./b.md"],
),
(
"reference style",
"[a][r1] [b][r2]\n\n[r1]: ./a.md\n[r2]: ./b.md\n",
&["./a.md", "./b.md"],
),
(
"autolink angle",
"<https://x.example/1> then [b](./b.md)\n",
&["https://x.example/1", "./b.md"],
),
(
"bare url autolinked",
"see https://x.example/2 and [b](./b.md)\n",
&["https://x.example/2", "./b.md"],
),
(
"url inside code is not a link",
"`https://x.example/no` but [b](./b.md)\n",
&["./b.md"],
),
(
"url inside a fence is not a link",
"```\nhttps://x.example/no\n```\n\n[b](./b.md)\n",
&["./b.md"],
),
(
"link inside a table cell",
"| a | b |\n|---|---|\n| [x](./x.md) | [y](./y.md) |\n",
&["./x.md", "./y.md"],
),
(
"link inside an alert",
"> [!NOTE]\n> [in](./in.md)\n\n[out](./out.md)\n",
&["./in.md", "./out.md"],
),
(
"heading anchor plus file",
"[h](#section) [f](./f.md)\n",
&["#section", "./f.md"],
),
(
"cjk label",
"[日本語のリンク](./ja.md) [b](./b.md)\n",
&["./ja.md", "./b.md"],
),
(
"emphasis around link",
"**[bold](./x.md)** and *[em](./y.md)*\n",
&["./x.md", "./y.md"],
),
];
for (name, body, want) in cases {
let (s, dir) = md_preview(cfg_code_bg_none(), "links", body);
assert_eq!(
s.app.md_link_targets(),
want.iter().map(|t| t.to_string()).collect::<Vec<_>>(),
"{name}: リンクの並び/対応がソース順とズレている(Enter が別の宛先を開く)\n--- src ---\n{body}"
);
std::fs::remove_dir_all(&dir).ok();
}
}
#[test]
fn e2e_tab_scrolls_whole_block_into_view() {
let mut body = String::from("# Doc\n\n");
for i in 0..20 {
body.push_str(&format!("filler line {i}\n\n"));
}
body.push_str("```rust\n");
for i in 0..8 {
body.push_str(&format!("let line_{i} = {i};\n"));
}
body.push_str("```\n\nafter\n");
let (mut s, dir) = md_preview(cfg_code_bg_none(), "blocks", &body);
let vh = s.app.preview_viewport_for_test().max(1) as usize;
s.tab(); let scroll = s.app.preview_scroll as usize;
let focused = s.app.focused_item().expect("コードブロックにフォーカス");
let start = s.app.md_item_line_for_test(focused);
let end = s.app.md_item_block_end_for_test(focused);
assert!(
end > start,
"コードブロックは複数行にわたる (start={start} end={end})"
);
let (top, _) = s.app.md_visual_span_for_test(start);
let (btop, bh) = s.app.md_visual_span_for_test(end);
let block_bottom = btop + bh;
assert!(
top >= scroll,
"ブロック先頭が上に見切れている: top={top} scroll={scroll}"
);
assert!(
block_bottom <= scroll + vh,
"ブロック末尾が下に見切れている: bottom={block_bottom} scroll={scroll} viewport={vh}"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn e2e_tab_scrolls_open_details_body_into_view() {
let mut body = String::from("# Doc\n\n");
for i in 0..20 {
body.push_str(&format!("filler line {i}\n\n"));
}
body.push_str("<details open>\n<summary>Show me</summary>\n\n");
for i in 0..8 {
body.push_str(&format!("- body line {i}\n"));
}
body.push_str("\n</details>\n");
let (mut s, dir) = md_preview(cfg_code_bg_none(), "detblocks", &body);
let vh = s.app.preview_viewport_for_test().max(1) as usize;
s.tab();
let focused = s.app.focused_item().expect("details にフォーカス");
let start = s.app.md_item_line_for_test(focused);
let end = s.app.md_item_block_end_for_test(focused);
assert!(
end > start,
"開いた details は本文を持つ (start={start} end={end})"
);
let scroll = s.app.preview_scroll as usize;
let (btop, bh) = s.app.md_visual_span_for_test(end);
assert!(
btop + bh <= scroll + vh,
"details 本文の末尾が見切れている: bottom={} scroll={scroll} viewport={vh}",
btop + bh
);
std::fs::remove_dir_all(&dir).ok();
}