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()
}
#[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_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_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_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_block_rescued() {
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>\nhidden body line\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.see("hidden body line"); s.dont_see("<summary>"); s.dont_see("<details>");
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_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_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();
}