use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use ratatui::backend::TestBackend;
use ratatui::Terminal;
use crate::app::{App, Mode};
use crate::config::Config;
use crate::test_support::unique_tmp;
use crate::{handle_key, ui};
struct Sim {
app: App,
term: Terminal<TestBackend>,
quit: bool,
fileop_rx: Option<std::sync::mpsc::Receiver<crate::app::FileOpResult>>,
#[cfg(feature = "git")]
gitop_rx: Option<std::sync::mpsc::Receiver<crate::app::GitOpResult>>,
md_img_rx: Option<std::sync::mpsc::Receiver<crate::app::MdImageResult>>,
md_enc_rx: Option<std::sync::mpsc::Receiver<crate::app::MdEncodeResult>>,
media_rx: Option<std::sync::mpsc::Receiver<crate::app::MediaResult>>,
md_remote_rx: Option<std::sync::mpsc::Receiver<crate::app::RemoteFetch>>,
}
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,
fileop_rx: None,
#[cfg(feature = "git")]
gitop_rx: None,
md_img_rx: None,
md_enc_rx: None,
media_rx: None,
md_remote_rx: None,
};
sim.draw();
sim
}
fn with_async_file_ops(mut self) -> Sim {
let (tx, rx) = std::sync::mpsc::channel();
self.app.attach_fileop_runner(tx);
self.fileop_rx = Some(rx);
self
}
#[cfg(feature = "git")]
fn with_async_git_ops(mut self) -> Sim {
let (tx, rx) = std::sync::mpsc::channel();
self.app.attach_gitop_runner(tx);
self.gitop_rx = Some(rx);
self
}
fn with_picker(mut self) -> Sim {
let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
std::mem::forget(rx); self.app
.attach_image_backend(ratatui_image::picker::Picker::halfblocks(), tx);
self
}
fn with_media(mut self) -> Sim {
let (resize_tx, resize_rx) = tokio::sync::mpsc::unbounded_channel();
std::mem::forget(resize_rx); let picker = ratatui_image::picker::Picker::halfblocks();
self.app.attach_image_backend(picker.clone(), resize_tx);
let (media_tx, media_rx) = std::sync::mpsc::channel();
self.app.attach_media_loader(media_tx);
self.media_rx = Some(media_rx);
let (md_img_tx, md_img_rx) = std::sync::mpsc::channel();
self.app.attach_md_image_loader(md_img_tx);
self.md_img_rx = Some(md_img_rx);
let (md_remote_tx, md_remote_rx) = std::sync::mpsc::channel();
self.app.attach_remote_md_loader(md_remote_tx);
self.md_remote_rx = Some(md_remote_rx);
let (md_enc_tx, md_enc_worker_rx) = std::sync::mpsc::channel();
let (md_enc_res_tx, md_enc_res_rx) = std::sync::mpsc::channel();
self.app.attach_md_encoder(md_enc_tx);
std::thread::spawn(move || {
crate::app::md_encode_worker(picker, md_enc_worker_rx, md_enc_res_tx)
});
self.md_enc_rx = Some(md_enc_res_rx);
self
}
#[track_caller]
fn drain_md_images(&mut self) {
let rx = self
.md_img_rx
.as_ref()
.expect("with_media() を呼んでいない");
let res = rx
.recv_timeout(std::time::Duration::from_secs(5))
.expect("decode worker が結果を返す");
assert!(self.app.apply_md_image(res), "現世代の結果は適用される");
self.draw();
}
#[track_caller]
fn drain_md_encodes(&mut self) {
let rx = self
.md_enc_rx
.as_ref()
.expect("with_media() を呼んでいない");
let res = rx
.recv_timeout(std::time::Duration::from_secs(5))
.expect("encode worker が結果を返す");
assert!(self.app.apply_md_encode(res), "現世代の結果は適用される");
self.draw();
}
#[track_caller]
fn drain_media(&mut self) {
let rx = self.media_rx.as_ref().expect("with_media() を呼んでいない");
let res = rx
.recv_timeout(std::time::Duration::from_secs(5))
.expect("media loader が結果を返す");
assert!(self.app.apply_media(res), "現世代の結果は適用される");
self.draw();
}
#[track_caller]
fn drain_remote(&mut self) {
let rx = self
.md_remote_rx
.as_ref()
.expect("with_media() を呼んでいない");
let res = rx
.recv_timeout(std::time::Duration::from_secs(5))
.expect("remote fetch worker が結果を返す");
assert!(self.app.apply_remote_fetch(res), "常に再描画を要求する");
self.draw();
}
#[track_caller]
fn drain_file_ops(&mut self) {
let rx = self
.fileop_rx
.as_ref()
.expect("with_async_file_ops() を呼んでいない");
let res = rx
.recv_timeout(std::time::Duration::from_secs(5))
.expect("ワーカーが結果を返す");
assert!(self.app.apply_file_op(res), "現世代の結果は適用される");
self.draw();
}
#[cfg(feature = "git")]
#[track_caller]
fn drain_git_ops(&mut self) {
let rx = self
.gitop_rx
.as_ref()
.expect("with_async_git_ops() を呼んでいない");
let res = rx
.recv_timeout(std::time::Duration::from_secs(30))
.expect("ワーカーが結果を返す");
assert!(self.app.apply_git_op(res), "現世代の結果は適用される");
self.draw();
}
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
.tab
.entries
.iter()
.position(|e| e.path.to_string_lossy().ends_with(suffix))
.unwrap_or_else(|| panic!("entry not found: {suffix}"));
self.app.tab.selected = i;
self.draw();
}
fn cell_style(&self, row: u16, col: u16) -> ratatui::style::Style {
let buf = self.term.backend().buffer();
buf[(col, row)].style()
}
fn find_text(&self, needle: &str) -> Option<(u16, u16)> {
let buf = self.term.backend().buffer();
let w = buf.area.width;
let h = buf.area.height;
for row in 0..h {
let mut row_str = String::new();
let mut col_at_byte: Vec<u16> = Vec::new();
for col in 0..w {
let sym = buf[(col, row)].symbol();
for _ in 0..sym.len() {
col_at_byte.push(col);
}
row_str.push_str(sym);
}
if let Some(byte_idx) = row_str.find(needle) {
let col = col_at_byte.get(byte_idx).copied().unwrap_or(0);
return Some((row, col));
}
}
None
}
fn style_of(&self, needle: &str) -> Option<ratatui::style::Style> {
self.find_text(needle).map(|(r, c)| self.cell_style(r, c))
}
#[track_caller]
fn see_styled(&self, needle: &str, pred: impl Fn(&ratatui::style::Style) -> bool, what: &str) {
let Some((row, col)) = self.find_text(needle) else {
panic!(
"画面に「{needle}」が見えるはず({what}を確認できない):\n{}",
self.screen()
);
};
let style = self.cell_style(row, col);
assert!(
pred(&style),
"「{needle}」のスタイルが {what} を満たさない(実際: {style:?}, 位置=({row},{col})):\n{}",
self.screen()
);
}
fn distinct_fg_in_row(&self, row: u16) -> usize {
let buf = self.term.backend().buffer();
let w = buf.area.width;
let mut set = std::collections::HashSet::new();
for col in 0..w {
set.insert(buf[(col, row)].fg);
}
set.len()
}
fn reversed_cells(&self) -> Vec<(u16, u16)> {
use ratatui::style::Modifier;
let buf = self.term.backend().buffer();
let w = buf.area.width;
let h = buf.area.height;
let mut out = Vec::new();
for row in 0..h {
for col in 0..w {
if buf[(col, row)].modifier.contains(Modifier::REVERSED) {
out.push((row, col));
}
}
}
out
}
}
fn sandbox(name: &str) -> std::path::PathBuf {
let dir = crate::test_support::unique_tmp(&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.tab.selected;
s.key('j');
assert_eq!(s.app.tab.selected, first + 1, "j で1つ下へ");
s.key('k');
assert_eq!(s.app.tab.selected, first, "k で戻る");
s.key('G');
assert_eq!(s.app.tab.selected, s.app.tab.entries.len() - 1, "G で末尾");
s.key('g');
assert_eq!(s.app.tab.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.tab.entries.len();
s.key('/');
s.keys("csv");
s.see("data.csv");
s.dont_see("readme.md");
assert!(s.app.tab.entries.len() < all, "絞り込みで件数が減る");
s.enter(); s.see("data.csv");
s.esc(); assert_eq!(s.app.tab.entries.len(), all, "Esc で全件に戻る");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn e2e_filter_over_budget_stays_usable_and_fills_in_from_the_worker() {
crate::app::set_filter_scan_budget(Some(Some(std::time::Duration::ZERO)));
let dir = sandbox("filter_budget");
seed_files(&dir);
for i in 0..6 {
let sub = dir.join(format!("pkg{i}"));
std::fs::create_dir_all(&sub).unwrap();
std::fs::write(sub.join(format!("deep{i}.csv")), "a,b\n").unwrap();
}
let root = canon(&dir);
let mut s = Sim::new(&root);
let (tx, rx) = std::sync::mpsc::channel();
s.app.attach_filter_pool_loader(tx);
s.key('/');
s.keys("deep");
s.see("/deep");
let res = rx.recv_timeout(std::time::Duration::from_secs(10)).unwrap();
assert!(s.app.apply_filter_pool(res), "ワーカーの結果が適用される");
assert!(
rx.try_recv().is_err(),
"受け渡しは1回で完結する(残りは期限なしで完走するので追加の往復は起きない)"
);
s.draw();
for i in 0..6 {
s.see(&format!("deep{i}.csv"));
}
s.dont_see("readme.md");
s.esc();
s.see("readme.md");
crate::app::set_filter_scan_budget(None);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn e2e_filter_cursor_keeps_its_file_when_the_worker_result_reorders_the_list() {
crate::app::set_filter_scan_budget(Some(Some(std::time::Duration::ZERO)));
let dir = sandbox("filter_reorder");
for i in 0..3 {
std::fs::write(dir.join(format!("x_a_b_c_{i}.txt")), b"weak\n").unwrap();
}
std::fs::create_dir_all(dir.join("pkg")).unwrap();
std::fs::write(dir.join("pkg/abc.txt"), b"strong\n").unwrap();
let root = canon(&dir);
let mut s = Sim::new(&root);
let (tx, rx) = std::sync::mpsc::channel();
s.app.attach_filter_pool_loader(tx);
s.key('/');
s.keys("abc");
s.enter(); s.key('j'); let before = s.app.tab.selected;
assert_eq!(before, 1, "土台: カーソルは 2 行目");
let target = s.app.tab.entries[before].path.clone();
let target_name = target.file_name().unwrap().to_string_lossy().into_owned();
assert!(target_name.starts_with("x_a_b_c_"), "土台: {target_name}");
let res = rx.recv_timeout(std::time::Duration::from_secs(10)).unwrap();
assert!(s.app.apply_filter_pool(res), "ワーカーの結果が適用される");
s.draw();
s.see("abc.txt");
let now = s
.app
.tab
.entries
.iter()
.position(|e| e.path == target)
.expect("対象は今も一致する");
assert_ne!(
now, before,
"土台: 到着で順位が入れ替わっていないとこのテストは何も検出しない"
);
assert_eq!(
s.app.tab.entries[s.app.tab.selected].path, target,
"カーソルは同じファイルを指し続ける"
);
s.enter();
assert_eq!(s.app.tab.mode, Mode::Preview);
assert_eq!(
s.app.tab.preview_path.as_deref(),
Some(target.as_path()),
"Enter が開くのはカーソル下のファイル"
);
s.see("weak");
s.key('q');
assert_eq!(
s.app.tab.entries[s.app.tab.selected].path, target,
"戻ってもカーソルは同じファイル"
);
s.key('y');
assert_eq!(
s.app.pending_leader,
Some(crate::keymap::LeaderId::Copy),
"y でコピーリーダーが開く"
);
let copied = s
.app
.copy_string_for(crate::app::CopyKind::Full)
.expect("コピー対象が存在する");
s.key('f');
assert_eq!(s.app.pending_leader, None, "y f でリーダーが消費される");
assert_eq!(
copied,
target.display().to_string(),
"y f がコピーするのはカーソル下のファイル"
);
assert!(
!copied.contains("abc.txt"),
"先頭を奪った別ファイルではない: {copied}"
);
s.key(' ');
s.key('d');
s.key('!');
assert!(!target.exists(), "消えたのはカーソル下のファイル");
assert!(
dir.join("pkg/abc.txt").exists(),
"順位を奪った別ファイルは無傷"
);
crate::app::set_filter_scan_budget(None);
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.tab.root.ends_with("src"),
"l でディレクトリへ潜る(root 変更)"
);
s.see("lib.rs");
s.key('h');
assert_eq!(s.app.tab.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_create_existing_name_flashes_in_ui_language() {
let dir_en = sandbox("file_create_existing_name_en");
seed_files(&dir_en);
let mut cfg_en = Config::default();
cfg_en.ui.lang = "en".into();
let mut s = Sim::with_config(&canon(&dir_en), cfg_en);
s.select("notes.txt");
s.key(' ');
s.key('n'); s.keys("notes.txt"); s.enter();
let flash_en = s.app.flash.clone().unwrap_or_default();
assert!(
flash_en.contains("already exists: "),
"英語 UI では英語で: {flash_en:?}"
);
assert!(
!flash_en.contains("既に存在"),
"英語 UI に日本語が混ざってはならない: {flash_en:?}"
);
assert!(
!dir_en.join("notes.txt.tmp").exists(),
"衝突時は何も作られない"
);
let dir_jp = sandbox("file_create_existing_name_jp");
seed_files(&dir_jp);
let mut cfg_jp = Config::default();
cfg_jp.ui.lang = "jp".into();
let mut s2 = Sim::with_config(&canon(&dir_jp), cfg_jp);
s2.select("notes.txt");
s2.key(' ');
s2.key('n');
s2.keys("notes.txt");
s2.enter();
let flash_jp = s2.app.flash.clone().unwrap_or_default();
assert!(
flash_jp.contains("既に存在します: "),
"日本語 UI では日本語で: {flash_jp:?}"
);
std::fs::remove_dir_all(&dir_en).ok();
std::fs::remove_dir_all(&dir_jp).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.tab.mode, Mode::Preview);
s.see("PREVIEW");
s.see("alpha");
s.key('q');
assert_eq!(s.app.tab.mode, Mode::Tree, "q でツリーへ戻る");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn e2e_preview_one_huge_line_file_stays_responsive() {
let dir = sandbox("preview_huge_line");
seed_files(&dir);
let huge = "x".repeat(4 * 1024 * 1024);
std::fs::write(
dir.join("min.txt"),
format!("{huge}\nsecond line\nlast line\n"),
)
.unwrap();
let mut s = Sim::new(&canon(&dir));
s.select("notes.txt");
s.enter();
s.key('q');
s.select("min.txt");
let open_alloc = crate::mem_tests::allocated_by(|| s.enter());
assert_eq!(s.app.tab.mode, Mode::Preview, "巨大1行でもプレビューに入る");
s.see("PREVIEW");
s.see("xxxxxxxx");
assert!(
open_alloc < 2 * 1024 * 1024,
"4MiB の1行ファイルを開く1フレームが {open_alloc} バイト確保した(行を丸ごと実体化?)"
);
let scroll_alloc = crate::mem_tests::allocated_by(|| {
s.key('j');
s.key('k');
});
assert!(
scroll_alloc < 2 * 1024 * 1024,
"巨大1行のスクロール2回が {scroll_alloc} バイト確保した"
);
s.key('q');
assert_eq!(s.app.tab.mode, Mode::Tree, "q でツリーへ戻る");
let mut cfg = Config::default();
cfg.ui.wrap = false;
let mut s = Sim::with_config(&canon(&dir), cfg);
s.select("min.txt");
s.enter();
s.see("xxxxxxxx");
s.key('G');
s.see("last line");
s.key('g');
s.see("xxxxxxxx");
s.key('v'); s.keys("lll");
s.press(KeyCode::Char('$'), KeyModifiers::NONE);
let end_col = match s.app.preview_selection() {
crate::app::PreviewSelection::Char { start, end } => {
assert_eq!(start.1, 0, "アンカーは行頭");
end.1
}
other => panic!("charwise 選択のはず: {other:?}"),
};
assert!(end_col > 2, "l/$ でキャレットが行に沿って進む: {end_col}");
assert!(
end_col < 100_000,
"$ のキャレット列が行長に比例している(上限が効いていない?): {end_col}"
);
s.esc(); s.see("xxxxxxxx");
s.key('q');
assert_eq!(s.app.tab.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
.tab
.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.tab.mode, Mode::Tree);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn e2e_zip_archive_opens_as_table() {
let dir = sandbox("zip_archive");
{
use std::io::Write as _;
let f = std::fs::File::create(dir.join("bundle.zip")).unwrap();
let mut zw = zip::ZipWriter::new(f);
let opts = zip::write::SimpleFileOptions::default()
.compression_method(zip::CompressionMethod::Stored);
zw.start_file("one.txt", opts).unwrap();
zw.write_all(b"hello").unwrap();
zw.start_file("two.txt", opts).unwrap();
zw.write_all(b"world").unwrap();
zw.finish().unwrap();
}
let mut s = Sim::new(&canon(&dir));
s.select("bundle.zip");
s.enter();
s.see("TABLE");
s.see("one.txt");
s.see("two.txt");
assert!(s.app.is_table_preview());
s.key('q');
assert_eq!(s.app.tab.mode, Mode::Tree);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn e2e_table_cell_popup_shows_full_text_not_truncated() {
let dir = sandbox("cellpopup_long");
let long = "x".repeat(80);
std::fs::write(dir.join("wide.csv"), format!("h1,h2\nshort,{long}\n")).unwrap();
let mut s = Sim::new(&canon(&dir));
s.select("wide.csv");
s.enter();
s.see("TABLE");
s.dont_see(&"x".repeat(40)); s.key('l'); s.enter(); s.see("CELL");
s.see(&"x".repeat(45)); assert_eq!(
s.app.table_cell_view().unwrap().text,
long,
"ポップアップの中身は生の全文(切り詰め無し)"
);
s.key('q');
s.see("TABLE");
s.dont_see("CELL");
assert_eq!(
s.app.tab.mode,
Mode::Preview,
"q はポップアップだけ閉じる(テーブルへ留まる・ツリーへは戻らない)"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn e2e_table_cell_popup_shows_multiline_cell_and_esc_closes() {
let dir = sandbox("cellpopup_multiline");
std::fs::write(
dir.join("addr.csv"),
"name,address\nDoe,\"123 Main St\nSuite 4\nCity\"\n",
)
.unwrap();
let mut s = Sim::new(&canon(&dir));
s.select("addr.csv");
s.enter();
s.see("TABLE");
s.key('l'); s.enter();
s.see("CELL");
assert_eq!(
s.app.table_cell_view().unwrap().text,
"123 Main St\nSuite 4\nCity",
"引用符内の実改行がそのまま保持される"
);
s.esc();
s.dont_see("CELL");
assert_eq!(s.app.tab.mode, Mode::Preview, "Esc もツリーへは戻らない");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn e2e_zip_archive_cell_popup_shows_full_name_via_enter() {
let dir = sandbox("cellpopup_zip");
let long_name = format!("{}.txt", "x".repeat(90));
{
use std::io::Write as _;
let f = std::fs::File::create(dir.join("bundle.zip")).unwrap();
let mut zw = zip::ZipWriter::new(f);
let opts = zip::write::SimpleFileOptions::default()
.compression_method(zip::CompressionMethod::Stored);
zw.start_file(&long_name, opts).unwrap();
zw.write_all(b"hi").unwrap();
zw.finish().unwrap();
}
let mut s = Sim::new(&canon(&dir));
s.select("bundle.zip");
s.enter();
s.see("TABLE");
s.enter(); s.see("CELL");
let view = s.app.table_cell_view().unwrap();
assert_eq!(view.header, "Name");
assert_eq!(view.text, long_name, "長いエントリ名も切り詰め無しで取れる");
s.key('q');
s.see("TABLE");
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.tab.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.tab.mode, Mode::Preview, "1回目の Esc では表に留まる");
s.esc();
assert_eq!(s.app.tab.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.tab.mode, Mode::Preview);
assert!(s
.app
.tab
.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.tab.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.tab.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(), "j のような通常キーではフォロー維持");
s.see("FOLLOW");
s.key('F');
assert!(!s.app.follow_enabled(), "F の再押下で解除");
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.tab.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.tab.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.tab.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.tab.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_worktrees_view_lists_and_enter_switches_root() {
let dir = sandbox("git_worktrees_view");
seed_repo(&dir);
let root = canon(&dir);
let linked = unique_tmp("konoma_e2e_git_worktrees_view_linked");
let _ = std::fs::remove_dir_all(&linked);
let out = std::process::Command::new("git")
.current_dir(&root)
.args([
"worktree",
"add",
"-q",
"-b",
"wt-feature",
linked.to_str().unwrap(),
])
.output()
.unwrap();
assert!(out.status.success(), "git worktree add: {out:?}");
let linked = linked.canonicalize().unwrap();
let mut s = Sim::new(&root);
s.key('o');
s.key('w'); assert!(s.app.is_git_worktrees(), "w でワークツリー一覧へ");
s.see("GIT");
s.see("WORKTREES");
s.see("Git worktrees");
s.see("wt-feature");
s.see("*"); assert_eq!(
s.app.git_worktree_sel(),
0,
"cursor はカレント(main)から始まる"
);
s.key('j'); s.enter();
assert!(!s.app.is_git_worktrees(), "Enter で一覧が閉じる");
assert!(!s.app.is_git_view(), "ハブへも戻らず Tree に着地する");
assert_eq!(s.app.tab.root, linked, "root が切り替わる");
assert_eq!(s.app.tab.open_dir, linked, "open_dir も切り替わる");
assert!(matches!(s.app.tab.mode, Mode::Tree), "切替後は Tree 表示");
std::fs::remove_dir_all(&linked).ok();
std::fs::remove_dir_all(&dir).ok();
}
#[cfg(feature = "git")]
#[test]
fn e2e_git_worktrees_ctrl_t_opens_in_new_tab_leaving_current_tab_untouched() {
let dir = sandbox("git_worktrees_new_tab");
seed_repo(&dir);
let root = canon(&dir);
let linked = unique_tmp("konoma_e2e_git_worktrees_new_tab_linked");
let _ = std::fs::remove_dir_all(&linked);
let out = std::process::Command::new("git")
.current_dir(&root)
.args([
"worktree",
"add",
"-q",
"-b",
"wt-feature",
linked.to_str().unwrap(),
])
.output()
.unwrap();
assert!(out.status.success(), "git worktree add: {out:?}");
let linked = linked.canonicalize().unwrap();
let mut s = Sim::new(&root);
s.key('o');
s.key('w');
s.key('j'); s.ctrl('t');
assert_eq!(s.app.tab_count(), 2, "新規タブが開く");
assert_eq!(s.app.tab.root, linked, "新タブの root が対象ワークツリー");
assert_eq!(s.app.tab.open_dir, linked);
assert!(matches!(s.app.tab.mode, Mode::Tree));
assert!(
!s.app.is_git_worktrees(),
"新タブは素の Tree(一覧を引き継がない)"
);
s.key('[');
assert_eq!(s.app.tab.root, root, "元タブの root は不変");
assert!(
s.app.is_git_worktrees(),
"元タブのワークツリー一覧は開いたまま"
);
std::fs::remove_dir_all(&linked).ok();
std::fs::remove_dir_all(&dir).ok();
}
#[cfg(feature = "git")]
#[test]
fn e2e_git_worktree_create_switches_into_it() {
let dir = sandbox("git_worktree_create");
seed_repo(&dir);
let root = canon(&dir);
let branch_name = unique_tmp("e2e-wt-branch")
.file_name()
.unwrap()
.to_string_lossy()
.into_owned();
let expected = root.parent().unwrap().join(&branch_name);
let _ = std::fs::remove_dir_all(&expected);
let mut s = Sim::new(&root);
s.key('o');
s.key('w');
assert!(s.app.is_git_worktrees());
s.key('n');
assert!(s.app.is_dialog(), "n で入力面に入る");
s.keys(&branch_name);
s.enter();
assert!(!s.app.is_git_worktrees(), "作成後は一覧を閉じる");
let new_root = s.app.tab.root.clone();
assert_ne!(new_root, root, "root が新しいワークツリーへ切替わる");
assert_eq!(s.app.tab.open_dir, new_root, "open_dir も切り替わる");
assert_eq!(new_root, expected.canonicalize().unwrap());
assert!(matches!(s.app.tab.mode, Mode::Tree), "切替後は Tree 表示");
assert!(
crate::git::branch_tip(&root, &branch_name).is_some(),
"新規ブランチが作られる"
);
std::fs::remove_dir_all(&expected).ok();
std::fs::remove_dir_all(&dir).ok();
}
#[cfg(feature = "git")]
#[test]
fn e2e_git_worktrees_enter_on_current_worktree_flashes_and_returns_to_hub() {
let dir = sandbox("git_worktrees_already_current");
seed_repo(&dir);
let root = canon(&dir);
let mut s = Sim::new(&root);
s.key('o');
s.key('w');
assert!(s.app.is_git_worktrees());
s.enter(); s.see("already in this worktree");
assert!(!s.app.is_git_worktrees(), "閉じる");
assert!(
s.app.is_git_view(),
"ハブへ戻る(root は動かないので Tree ではない)"
);
assert_eq!(s.app.tab.root, root, "root は不変");
std::fs::remove_dir_all(&dir).ok();
}
#[cfg(feature = "git")]
#[test]
fn e2e_git_worktrees_refuses_bare_and_prunable_targets() {
let src = sandbox("git_worktrees_refuse_src");
seed_repo(&src);
let src = canon(&src);
let bare = unique_tmp("konoma_e2e_git_worktrees_refuse_bare.git");
let wt = unique_tmp("konoma_e2e_git_worktrees_refuse_wt");
let gone = unique_tmp("konoma_e2e_git_worktrees_refuse_gone");
for p in [&bare, &wt, &gone] {
let _ = std::fs::remove_dir_all(p);
}
let sh = |cwd: &std::path::Path, args: &[&str]| {
let out = std::process::Command::new("git")
.current_dir(cwd)
.args(args)
.output()
.unwrap();
assert!(out.status.success(), "git {args:?}: {out:?}");
};
sh(
&std::env::temp_dir(),
&[
"clone",
"-q",
"--bare",
src.to_str().unwrap(),
bare.to_str().unwrap(),
],
);
sh(
&bare,
&["worktree", "add", "-q", wt.to_str().unwrap(), "master"],
);
sh(
&bare,
&[
"worktree",
"add",
"-q",
"-b",
"gone-branch",
gone.to_str().unwrap(),
],
);
let wt = wt.canonicalize().unwrap();
std::fs::remove_dir_all(&gone).unwrap();
let mut s = Sim::new(&wt);
s.key('o');
s.key('w');
assert!(s.app.is_git_worktrees());
s.see("bare");
let view = s.app.git_worktree_view();
let cur_idx = view.iter().position(|w| w.is_current).expect("current");
let bare_idx = view.iter().position(|w| w.is_bare).expect("bare");
let gone_idx = view.iter().position(|w| w.prunable).expect("prunable");
assert_eq!(
s.app.git_worktree_sel(),
cur_idx,
"cursor はカレント(wt)から始まる"
);
let step = |s: &mut Sim, from: usize, to: usize| {
let delta = to as i32 - from as i32;
for _ in 0..delta.unsigned_abs() {
s.key(if delta > 0 { 'j' } else { 'k' });
}
};
step(&mut s, cur_idx, bare_idx);
s.enter();
assert!(s.app.is_git_worktrees(), "bare は拒否されて一覧のまま");
assert_eq!(s.app.tab.root, wt, "root は不変");
s.see("bare repository");
step(&mut s, bare_idx, gone_idx);
s.enter();
assert!(s.app.is_git_worktrees(), "prunable は拒否されて一覧のまま");
assert_eq!(s.app.tab.root, wt, "root は不変");
s.see("missing");
std::fs::remove_dir_all(&wt).ok();
std::fs::remove_dir_all(&bare).ok();
std::fs::remove_dir_all(&src).ok();
}
#[cfg(feature = "git")]
#[test]
fn e2e_git_worktree_show_changes_opens_detail_with_title_and_returns_to_list() {
let dir = sandbox("git_worktree_show_changes");
seed_repo(&dir);
let root = canon(&dir);
let base = crate::git::branch(&root).expect("sanity: has a branch after seed_repo's commit");
let linked = unique_tmp("konoma_e2e_git_worktree_show_changes_linked");
let _ = std::fs::remove_dir_all(&linked);
let sh = |cwd: &std::path::Path, args: &[&str]| {
let out = std::process::Command::new("git")
.current_dir(cwd)
.args(args)
.output()
.unwrap();
assert!(out.status.success(), "git {args:?}: {out:?}");
};
sh(
&root,
&[
"worktree",
"add",
"-q",
"-b",
"wt-since-feature",
linked.to_str().unwrap(),
],
);
let linked = linked.canonicalize().unwrap();
std::fs::write(linked.join("agent_landed.txt"), "AGENT_LANDED_MARKER\n").unwrap();
sh(&linked, &["add", "-A"]);
sh(&linked, &["commit", "-q", "-m", "agent commit"]);
std::fs::write(linked.join("agent_pending.txt"), "AGENT_PENDING_MARKER\n").unwrap();
let mut cfg = Config::default();
cfg.ui.graph_base_branches = vec![base.clone()];
let mut s = Sim::with_config(&root, cfg);
s.key('o');
s.key('w');
assert!(s.app.is_git_worktrees());
let view = s.app.git_worktree_view();
let cur = s.app.git_worktree_sel();
let target = view
.iter()
.position(|w| w.path == linked)
.expect("linked worktree row");
for _ in 0..target.abs_diff(cur) {
s.key(if target > cur { 'j' } else { 'k' });
}
s.key('d');
assert!(
s.app.is_git_detail(),
"d でこのワークツリーの diff が detail として開く"
);
s.see(&format!("wt-since-feature ← {base}"));
s.see("agent_landed.txt");
s.see("AGENT_LANDED_MARKER");
s.see("agent_pending.txt");
s.see("AGENT_PENDING_MARKER");
s.key('q');
assert!(!s.app.is_git_detail(), "q で detail のみ閉じる");
assert!(
s.app.is_git_worktrees(),
"detail の裏にあったワークツリー一覧へ戻る(ハブでもツリーでもない)"
);
assert!(!s.app.is_git_view());
std::fs::remove_dir_all(&linked).ok();
std::fs::remove_dir_all(&dir).ok();
}
#[cfg(feature = "git")]
#[test]
fn e2e_git_worktree_show_changes_works_on_the_currently_active_worktree() {
let dir = sandbox("git_worktree_show_changes_current");
seed_repo(&dir); let root = canon(&dir);
let mut s = Sim::new(&root); s.key('o');
s.key('w');
assert_eq!(
s.app.git_worktree_sel(),
s.app
.git_worktree_view()
.iter()
.position(|w| w.is_current)
.unwrap(),
"cursor はカレントから始まる"
);
s.key('d');
assert!(
s.app.is_git_detail(),
"現在地のワークツリーでも d は拒否されない(AlreadyCurrent は worktree_goto 専用のガード)"
);
s.see("new.txt"); s.see("_x = 1");
s.key('q');
assert!(s.app.is_git_worktrees(), "一覧へ戻る");
std::fs::remove_dir_all(&dir).ok();
}
#[cfg(feature = "git")]
#[test]
fn e2e_git_worktree_show_changes_refuses_bare_worktree() {
let src = sandbox("git_worktree_show_changes_refuse_bare_src");
seed_repo(&src);
let src = canon(&src);
let bare = unique_tmp("konoma_e2e_git_worktree_show_changes_refuse_bare.git");
let wt = unique_tmp("konoma_e2e_git_worktree_show_changes_refuse_wt");
for p in [&bare, &wt] {
let _ = std::fs::remove_dir_all(p);
}
let sh = |cwd: &std::path::Path, args: &[&str]| {
let out = std::process::Command::new("git")
.current_dir(cwd)
.args(args)
.output()
.unwrap();
assert!(out.status.success(), "git {args:?}: {out:?}");
};
sh(
&std::env::temp_dir(),
&[
"clone",
"-q",
"--bare",
src.to_str().unwrap(),
bare.to_str().unwrap(),
],
);
sh(
&bare,
&["worktree", "add", "-q", wt.to_str().unwrap(), "master"],
);
let wt = wt.canonicalize().unwrap();
let mut s = Sim::new(&wt);
s.key('o');
s.key('w');
let view = s.app.git_worktree_view();
let cur = s.app.git_worktree_sel();
let bare_idx = view.iter().position(|w| w.is_bare).expect("bare row");
for _ in 0..bare_idx.abs_diff(cur) {
s.key(if bare_idx > cur { 'j' } else { 'k' });
}
s.key('d');
assert!(
!s.app.is_git_detail(),
"bare は拒否されて detail は開かない"
);
assert!(s.app.is_git_worktrees(), "一覧は開いたまま");
s.see("bare repository");
std::fs::remove_dir_all(&wt).ok();
std::fs::remove_dir_all(&bare).ok();
std::fs::remove_dir_all(&src).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.tab.entries[s.app.tab.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.tab.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.tab.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.tab.root.join("a.rs");
std::fs::write(&a, "fn a() { let _x = 2; }\n").unwrap();
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.tab.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.tab.root.join("a.rs");
let nt = s.app.tab.root.join("new.txt");
std::fs::write(&a, "fn a() { let _x = 2; }\n").unwrap();
std::fs::write(&nt, "untracked\nAFTER\n").unwrap();
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(変更ファイル回遊)でもフォロー維持(最大粘着)"
);
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();
}
#[cfg(feature = "git")]
#[test]
fn e2e_f_toggles_follow_diff_scope_without_breaking_follow() {
let dir = sandbox("follow_f_scope");
seed_repo(&dir);
let mut s = Sim::new(&canon(&dir));
s.key('F');
let a = s.app.tab.root.join("a.rs");
std::fs::write(&a, "fn a() { let _x = 2; }\n").unwrap();
assert!(s.app.follow_note_change(&a));
s.app.follow_jump(&a);
s.draw();
assert!(s.app.is_git_diff_preview(), "追尾先は全画面 diff で開く");
assert!(s.app.follow_enabled(), "f を押す前は follow が ON");
s.key('f');
assert!(
s.app.follow_enabled(),
"f はフォロー表示オプション=follow を解除してはいけない(回帰)"
);
assert_eq!(
s.app.follow_diff_scope_msg(),
Some(crate::i18n::Msg::FollowShowFull),
"1 回目の f で範囲がフルへ切り替わる"
);
s.key('f');
assert!(
s.app.follow_enabled(),
"2 回目の f でも follow は ON のまま"
);
assert_eq!(
s.app.follow_diff_scope_msg(),
Some(crate::i18n::Msg::FollowShowSince),
"2 回目の f で範囲が開始以降へ戻る"
);
std::fs::remove_dir_all(&dir).ok();
}
#[cfg(feature = "git")]
#[test]
fn e2e_follow_survives_scroll_and_cycle_breaks_only_on_q() {
let dir = sandbox("follow_sticky");
seed_repo(&dir);
let mut s = Sim::new(&canon(&dir));
s.key('F');
assert!(s.app.follow_enabled(), "F でフォロー ON");
let a = s.app.tab.root.join("a.rs");
std::fs::write(&a, "fn a() { let _x = 2; }\n").unwrap();
assert!(s.app.follow_note_change(&a));
s.app.follow_jump(&a);
s.draw();
assert!(s.app.is_git_diff_preview(), "追尾先は全画面 diff で開く");
assert!(s.app.follow_enabled(), "diff を開いた直後は follow ON");
s.key('j');
assert!(s.app.follow_enabled(), "スクロールでフォロー維持");
s.key('h');
assert!(s.app.follow_enabled(), "横スクロールでフォロー維持");
s.key('n');
assert!(
s.app.follow_enabled(),
"n(変更ファイル回遊)でもフォロー維持"
);
s.key('q');
assert!(!s.app.follow_enabled(), "q(diff を出る)でだけフォロー解除");
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
.tab
.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_code_span_examples_are_not_rewritten() {
let body = concat!(
"Write `<kbd>Ctrl</kbd>` to get a keycap, like <kbd>Ctrl</kbd>.\n\n",
"Write `[^1]` for a reference, like this one.[^1]\n\n",
"A line break tag is `<br>`.\n\n",
"[^1]: The note.\n",
);
let (s, dir) = md_preview(cfg_code_bg_none(), "code_span_examples", body);
s.see("<kbd>Ctrl</kbd>");
s.see("[^1]");
s.see("<br>");
s.dont_see("``");
s.see("¹"); s.see("The note."); s.see("Ctrl"); std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn e2e_markdown_indented_and_fenced_code_blocks_protect_the_same_notations() {
let body = concat!(
"Fenced example:\n\n",
"```\n[^1] <kbd>K</kbd>\n```\n\n",
"Indented example:\n\n",
" [^2] <kbd>J</kbd>\n\n",
"Outside: real[^1] and <kbd>Ctrl</kbd>, and another[^2].\n\n",
"[^1]: The note.\n[^2]: The other note.\n",
);
let (s, dir) = md_preview(cfg_code_bg_none(), "fence_and_indent_protect", body);
s.see("[^1] <kbd>K</kbd>"); s.see("[^2] <kbd>J</kbd>"); s.dont_see("<kbd>Ctrl</kbd>"); s.see("¹"); s.see("²"); s.see("The note.");
s.see("The other note.");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn e2e_markdown_br_inside_a_code_block_does_not_split_it() {
let body = concat!(
"Fenced example:\n\n",
"```\nleft<br>right\n```\n\n",
"Indented example:\n\n",
" leftind<br>rightind\n\n",
"Outside: before<br>after.\n",
);
let (s, dir) = md_preview(cfg_code_bg_none(), "br_code_block_integrity", body);
s.see("left<br>right");
s.see("leftind<br>rightind");
let (before_row, _) = s.find_text("before").expect("before is drawn");
let (after_row, _) = s.find_text("after.").expect("after is drawn");
assert!(
after_row > before_row,
"<br> outside a code block still injects a real line break: before_row={before_row}, \
after_row={after_row}\n{}",
s.screen()
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn e2e_markdown_br_in_an_alert_keeps_the_callout_and_its_fence_whole() {
let body = concat!(
"> [!NOTE]\n",
"> line one<br>line two\n",
"> tail line<br>\n",
"> after the break\n",
">\n",
"> ```rust\n",
"> fn inside_the_note() {}\n",
"> ```\n",
"\nAfter the note.\n",
);
let (mut s, dir) = md_preview(cfg_code_bg_none(), "br_alert_callout", body);
for needle in [
"line one",
"line two",
"tail line",
"after the break",
"fn inside_the_note",
] {
let (row, _) = s
.find_text(needle)
.unwrap_or_else(|| panic!("「{needle}」が画面にない:\n{}", s.screen()));
let screen = s.screen();
let text = screen.lines().nth(row as usize).unwrap_or_default();
assert!(
text.contains('▌'),
"「{needle}」の行が callout の外に逃げている: {text:?}\n{screen}"
);
}
s.see("▎");
s.dont_see("```");
s.dont_see("> fn inside_the_note");
s.tab();
assert!(
s.app.md_focused_code(),
"alert 内の fence に Tab でフォーカスできない:\n{}",
s.screen()
);
assert_eq!(
s.app.focused_code_text().as_deref(),
Some("fn inside_the_note() {}"),
"alert 内 fence のコピー内容"
);
let screen = s.screen();
let blank_callout_rows = screen
.lines()
.filter(|r| r.trim_matches(|c| c == '│' || c == ' ') == "▌")
.count();
assert_eq!(
blank_callout_rows, 1,
"callout 本文の空行数が想定外(行末 <br> が段落区切りになっている):\n{screen}"
);
s.see("After the note.");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn e2e_markdown_math_inside_an_indented_code_block_is_not_lifted_as_an_image() {
let body = concat!(
"Indented code with a dollar sign, not math:\n\n",
" literal $x$ stays text\n\n",
"Outside this is real math: $y$ done.\n",
);
let dir = sandbox("math_indented_code");
std::fs::write(dir.join("d.md"), body).unwrap();
let root = canon(&dir);
let mut s = Sim::with_config(&root, cfg_code_bg_none()).with_picker();
s.select("d.md");
s.enter();
assert_eq!(
s.app.md_images().len(),
1,
"the only image placement must be the outside $y$ — the indented block's $x$ must not be \
lifted out as a second one"
);
s.see("literal $x$ stays text"); std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn e2e_markdown_details_summary_then_a_fence_with_no_blank_line_stays_literal() {
let mut cfg = cfg_code_bg_none();
cfg.ui.md_details = "open".into();
let body = concat!(
"<details>\n",
"<summary>s</summary>\n",
"```rust\n[^1] <kbd>K</kbd>\n```\n",
"</details>\n\n",
"Outside: real[^1] and <kbd>Ctrl</kbd>.\n\n",
"[^1]: The note.\n",
);
let (s, dir) = md_preview(cfg, "details_fence_no_blank_e2e", body);
s.see("[^1] <kbd>K</kbd>"); s.dont_see("<kbd>Ctrl</kbd>"); s.see("¹");
s.see("The note.");
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.tab.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.tab.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.tab.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.tab.preview_scroll, 0, "starts at the top");
s.tab(); assert_eq!(s.app.focused_item(), Some(0));
s.enter(); assert!(
s.app.tab.preview_scroll > 0,
"scrolled down toward the heading (was {})",
s.app.tab.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.tab.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_indented_code_block_tab_focus_and_copy() {
let dir = sandbox("md_indented_code_copy");
seed_files(&dir);
std::fs::write(
dir.join("snip.md"),
"Intro paragraph.\n\n```rust\nfn fenced() {}\n```\n\nMore text.\n\n indented one\n indented two\n\nTail.\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(), "1番目=フェンスコードにフォーカス");
assert_eq!(
s.app.focused_code_text().as_deref(),
Some("fn fenced() {}"),
"フェンス側の内容が取得できる(コピー拒否されない)"
);
s.key('y');
s.key('c');
assert!(s.app.flash.is_some(), "フェンス側のコピー通知が出る");
s.tab();
assert!(s.app.md_focused_code(), "2番目=字下げコードにフォーカス");
assert_eq!(
s.app.focused_code_text().as_deref(),
Some("indented one\nindented two"),
"字下げ側の内容が正しく取得できる(フェンス側を誤って取ってこない)"
);
s.key('y');
s.key('c');
assert!(s.app.flash.is_some(), "字下げ側のコピー通知が出る");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn e2e_md_indented_code_block_whose_content_is_a_tag_is_focusable_and_copyable() {
for (label, cfg) in [
("default", Config::default()),
("code_bg=none", cfg_code_bg_none()),
] {
let body = concat!(
"Intro paragraph.\n\n",
" <code>\n",
" still code\n\n",
"Tail with a real <kbd>Ctrl</kbd> outside.\n",
);
let (mut s, dir) = md_preview(cfg, "indented_tag_code", body);
s.see("▎ <code>");
s.see("still code");
s.dont_see("<kbd>Ctrl</kbd>");
s.tab();
assert!(
s.app.md_focused_code(),
"{label}: 字下げコードブロックに Tab でフォーカスできない"
);
assert_eq!(
s.app.focused_code_text().as_deref(),
Some("<code>\nstill code"),
"{label}: コピー対象が生ソースと一致しない"
);
s.key('y');
assert_eq!(
s.app.pending_leader,
Some(crate::keymap::LeaderId::Copy),
"{label}: y はコピーリーダーを開く"
);
s.see("code block");
s.key('c');
assert!(
s.app.pending_leader.is_none(),
"{label}: c で確定=リーダーが閉じる"
);
assert!(s.app.flash.is_some(), "{label}: y c でコピー通知が出る");
std::fs::remove_dir_all(&dir).ok();
}
}
#[test]
fn e2e_md_centered_banner_draws_no_code_gutter() {
let body = concat!(
"<p align=\"center\">\n",
" <a href=\"https://crates.io/crates/demo\">\n",
" <img src=\"https://img.example/v.svg\" alt=\"crates.io badge\">\n",
" </a>\n",
" <br>\n",
" <a href=\"LICENSE-MIT\">\n",
" <img src=\"https://img.example/mit.svg\" alt=\"MIT badge\">\n",
" </a>\n",
"</p>\n",
"\nOrdinary prose after the banner.\n",
);
let (s, dir) = md_preview(cfg_code_bg_none(), "banner_no_gutter", body);
s.dont_see("▎");
s.dont_see("</a>");
s.see("crates.io badge");
s.see("MIT badge");
s.see("Ordinary prose after the banner.");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn e2e_md_banner_nested_in_a_container_draws_no_code_gutter() {
for (label, body) in [
(
"details",
concat!(
"<details open>\n",
"<summary>Badges</summary>\n",
"\n",
"<p align=\"center\">\n",
" <a href=\"https://crates.io/crates/demo\">\n",
" <img src=\"https://img.example/v.svg\" alt=\"crates.io badge\">\n",
" </a>\n",
" <br>\n",
" <a href=\"LICENSE-MIT\">\n",
" <img src=\"https://img.example/mit.svg\" alt=\"MIT badge\">\n",
" </a>\n",
"</p>\n",
"\n",
"</details>\n",
"\nOrdinary prose after the banner.\n",
),
),
(
"blockquote",
concat!(
"> <p align=\"center\">\n",
"> <a href=\"https://crates.io/crates/demo\"><img src=\"https://img.example/v.svg\" alt=\"crates.io badge\"></a>\n",
"> <br>\n",
"> <a href=\"LICENSE-MIT\"><img src=\"https://img.example/mit.svg\" alt=\"MIT badge\"></a>\n",
"> </p>\n",
"\nOrdinary prose after the banner.\n",
),
),
] {
let (s, dir) = md_preview(cfg_code_bg_none(), "banner_nested_no_gutter", body);
s.dont_see("▎");
s.dont_see("</a>");
s.see("crates.io badge");
s.see("MIT badge");
s.see("Ordinary prose after the banner.");
assert!(
!s.screen().contains("```"),
"{label}: 生のフェンスが漏れている"
);
std::fs::remove_dir_all(&dir).ok();
}
}
#[test]
fn e2e_md_code_block_in_a_list_item_is_copyable() {
let dir = sandbox("md_list_indented_fence");
seed_files(&dir);
std::fs::write(
dir.join("snip.md"),
"## Install\n\n1. Fork it:\n\n ```sh\n git clone x\n ```\n\n\
2. Build it.\n\n\
4. Redistribution. You may reproduce and distribute copies of the\n\n\
\x20 (b) You must cause any modified files to carry prominent notices\n\n\
```rust\nfn top_level() {}\n```\n",
)
.unwrap();
let mut s = Sim::new(&canon(&dir));
s.select("snip.md");
s.enter();
s.tab();
assert!(
s.app.md_focused_code(),
"1番目=リスト項目内のフェンスにフォーカス"
);
assert_eq!(
s.app.focused_code_text().as_deref(),
Some("git clone x"),
"リスト項目内のフェンスがコピーできる(以前は文書全体が拒否された)"
);
s.key('y');
s.key('c');
assert!(s.app.flash.is_some(), "コピー通知が出る");
s.tab();
assert!(s.app.md_focused_code(), "2番目=トップレベルのフェンス");
assert_eq!(
s.app.focused_code_text().as_deref(),
Some("fn top_level() {}"),
"ライセンス文体の字下げ段落をコードと数えず、序数がずれない"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn e2e_md_indented_code_block_before_fences_keeps_ordinals_correct() {
let dir = sandbox("md_indented_before_fences");
seed_files(&dir);
std::fs::write(
dir.join("snip.md"),
"Para.\n\n first indented\n\nMid.\n\n```\nsecond fenced\n```\n\n```\nthird fenced\n```\n",
)
.unwrap();
let mut s = Sim::new(&canon(&dir));
s.select("snip.md");
s.enter();
s.tab(); assert_eq!(s.app.focused_code_text().as_deref(), Some("first indented"),);
s.tab(); assert_eq!(s.app.focused_code_text().as_deref(), Some("second fenced"));
s.tab(); assert_eq!(s.app.focused_code_text().as_deref(), Some("third fenced"));
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn e2e_md_indented_code_after_heading_no_blank_keeps_fence_copyable() {
let dir = sandbox("md_heading_then_indented");
seed_files(&dir);
std::fs::write(
dir.join("snip.md"),
"## Usage\n npm install foo\n\n```bash\nnpm test\n```\n",
)
.unwrap();
let mut s = Sim::new(&canon(&dir));
s.select("snip.md");
s.enter();
s.see("Usage");
s.tab();
assert!(
s.app.md_focused_code(),
"1番目=見出し直後(空行なし)の字下げコードにフォーカス"
);
assert_eq!(
s.app.focused_code_text().as_deref(),
Some("npm install foo"),
"見出し直後の字下げコードの内容が取得できる(以前は文書全体のコピーが拒否されていた)"
);
s.key('y');
s.key('c');
assert!(s.app.flash.is_some(), "字下げ側のコピー通知が出る");
s.tab();
assert!(s.app.md_focused_code(), "2番目=フェンスコードにフォーカス");
assert_eq!(
s.app.focused_code_text().as_deref(),
Some("npm test"),
"無関係な実フェンスまで巻き添えで拒否されない"
);
s.key('y');
s.key('c');
assert!(s.app.flash.is_some(), "フェンス側のコピー通知も出る");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn e2e_md_fence_closed_by_its_list_item_still_renders_the_table_below() {
let dir = sandbox("md_container_closed_fence");
seed_files(&dir);
std::fs::write(
dir.join("snip.md"),
"- Removed `Eq`, for example:\n\n ```\n let h = 1;\n ``` ([#2642](https://example.com/pull/2642))\n\n\
Implemented I/O safety:\n\n| Original Type | New Type |\n| ------------- | -------- |\n| AsRawFd | AsFd |\n\n\
```sh\nafter\n```\n",
)
.unwrap();
let mut s = Sim::new(&canon(&dir));
s.select("snip.md");
s.enter();
s.see("┬");
s.see("│ Original Type │ New Type │");
s.see("│ AsRawFd");
s.dont_see("| Original Type | New Type | |");
s.tab();
assert!(
s.app.md_focused_code(),
"1番目=リスト項目内のコードブロック"
);
assert_eq!(
s.app.focused_code_text().as_deref(),
Some("let h = 1;\n``` ([#2642](https://example.com/pull/2642))\n"),
"末尾テキスト付きの行は閉じないので、その行もブロックの中身のまま"
);
s.tab();
assert_eq!(
s.app.focused_code_text().as_deref(),
Some("after"),
"表の後ろの実フェンスが2番目として拾える"
);
s.key('y');
s.key('c');
assert!(s.app.flash.is_some(), "コピーが拒否されない");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn e2e_md_indented_code_block_keeps_table_and_alert_literal() {
let (s, dir) = md_preview(
cfg_code_bg_none(),
"md_indented_literal",
"Example table markup:\n\n | a | b |\n |---|---|\n | 1 | 2 |\n\nExample alert markup:\n\n > [!NOTE]\n > body\n",
);
s.see("| a | b |");
s.see("> [!NOTE]");
s.dont_see("┬");
s.dont_see("▌");
s.see("▎");
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.tab.preview_scroll, 0, "初期スクロール 0");
on.dont_see("ZZLINK"); on.tab(); assert!(
on.app.tab.preview_scroll > 5,
"折返しでフォーカスに追従してスクロール: scroll={}",
on.app.tab.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.tab.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_copy_then_paste_runs_in_background() {
let dir = sandbox("copy_paste_async");
std::fs::create_dir_all(dir.join("pkg/inner")).unwrap();
std::fs::write(dir.join("pkg/one.txt"), "1\n").unwrap();
std::fs::write(dir.join("pkg/inner/two.txt"), "2\n").unwrap();
std::fs::create_dir_all(dir.join("dest")).unwrap();
let dir = canon(&dir);
let mut s = Sim::new(&dir).with_async_file_ops();
s.select("pkg");
s.key(' ');
s.key('c'); s.select("dest");
s.key(' ');
s.key('p');
assert!(
s.app.busy_jobs().contains(&crate::i18n::Msg::BusyFileOp),
"貼付直後はバックグラウンド実行中(busy インジケーターに出る)"
);
s.drain_file_ops();
assert!(
dir.join("dest/pkg/one.txt").is_file() && dir.join("dest/pkg/inner/two.txt").is_file(),
"ディレクトリごとコピーされている"
);
assert!(dir.join("pkg/one.txt").is_file(), "コピー元は残る");
assert!(
s.app
.tab
.entries
.iter()
.any(|e| e.path == dir.join("dest/pkg")),
"貼付先が展開され、コピーがツリーに現れる"
);
s.see("pkg");
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();
}
fn cfg_en() -> Config {
let mut cfg = Config::default();
cfg.ui.lang = "en".into();
cfg
}
#[test]
fn e2e_stale_listing_chip_stays_up_until_the_listing_recovers() {
let dir = sandbox("stale_chip_level");
seed_files(&dir);
let root = canon(&dir);
let mut s = Sim::with_config(&root, cfg_en());
s.see("notes.txt");
s.dont_see("STALE");
assert!(!s.app.tree_stale());
std::fs::remove_dir_all(&root).unwrap();
s.app.refresh_fs_watched(false, &[]);
s.draw();
s.see("STALE");
s.see_styled(
"STALE",
|st| st.bg == Some(ratatui::style::Color::Yellow),
"警告色(黄)の背景",
);
s.see("notes.txt");
let flash = s.app.flash.clone().expect("立ち上がりで一度は伝える");
assert!(flash.contains("out of date"), "{flash}");
for k in ['j', 'k', 'g', 'G', 'j', 'j', 'k'] {
s.key(k);
assert_eq!(s.app.flash, None, "flash はキー入力で消える");
s.see("STALE");
}
std::fs::create_dir_all(&root).unwrap();
std::fs::write(root.join("back.txt"), b"y").unwrap();
s.app.refresh_fs_watched(false, &[]);
s.draw();
assert!(!s.app.tree_stale());
s.dont_see("STALE");
s.see("back.txt");
std::fs::remove_dir_all(&root).ok();
}
#[test]
fn e2e_stale_listing_chip_follows_the_tab_you_are_looking_at() {
let dir = sandbox("stale_chip_tabs");
let base = canon(&dir);
for sub in ["alpha", "beta"] {
std::fs::create_dir_all(base.join(sub)).unwrap();
}
std::fs::write(base.join("alpha/in-alpha.txt"), b"a\n").unwrap();
std::fs::write(base.join("beta/in-beta.txt"), b"b\n").unwrap();
let mut s = Sim::with_config(&base, cfg_en());
s.select("alpha");
s.ctrl('t');
s.key('['); s.select("beta");
s.ctrl('t');
assert_eq!(s.app.tab_count(), 3);
s.see("in-beta.txt");
s.dont_see("STALE");
std::fs::remove_dir_all(base.join("alpha")).unwrap();
s.dont_see("STALE");
s.key('[');
assert_eq!(s.app.active_tab_index(), 1);
s.see("STALE");
s.see("in-alpha.txt");
s.key(']');
assert_eq!(s.app.active_tab_index(), 2);
s.dont_see("STALE");
s.key('[');
s.see("STALE");
std::fs::remove_dir_all(&base).ok();
}
#[test]
fn e2e_stale_listing_chip_shows_in_tree_and_preview() {
let dir = sandbox("stale_chip_modes");
seed_files(&dir);
let root = canon(&dir);
let mut s = Sim::with_config(&root, cfg_en());
s.select("notes.txt");
s.enter();
assert!(matches!(s.app.tab.mode, Mode::Preview));
s.dont_see("STALE");
std::fs::remove_dir_all(&root).unwrap();
s.app.refresh_fs_watched(false, &[]);
s.draw();
assert!(matches!(s.app.tab.mode, Mode::Preview), "プレビューのまま");
s.see("STALE");
s.key('q');
assert!(matches!(s.app.tab.mode, Mode::Tree));
s.see("STALE");
std::fs::remove_dir_all(&root).ok();
}
#[test]
fn e2e_stale_listing_chip_does_not_break_the_tab_bar() {
let dir = sandbox("stale_chip_tabbar");
seed_files(&dir);
let root = canon(&dir);
let mut s = Sim::with_config(&root, cfg_en());
s.keys("tttttttt"); assert_eq!(s.app.tab_count(), 9);
s.key('5');
assert_eq!(s.app.active_tab_index(), 4);
std::fs::remove_dir_all(&root).unwrap();
s.app.refresh_fs_watched(false, &[]);
s.draw();
s.see("STALE");
s.see("‹");
s.see("›");
let label = s.app.tab_label(4);
s.see(&format!("5:{label}"));
let width = s.term.backend().buffer().area.width as usize;
let ctx = crate::ui::status::context_width(&s.app) as usize;
let (chip_row, chip_col) = s.find_text("STALE").expect("チップが描かれている");
assert_eq!(chip_row, 0, "チップは上段(タブバーと同じ行)");
assert!(
chip_col as usize >= width - ctx,
"チップがタブバー側にはみ出している (col={chip_col}, 予約境界={})",
width - ctx
);
let (tab_row, tab_col) = s.find_text(&format!("5:{label}")).expect("アクティブタブ");
assert_eq!(tab_row, 0);
assert!(
(tab_col as usize) < width - ctx,
"タブがコンテキスト側に食い込んでいる (col={tab_col})"
);
std::fs::remove_dir_all(&root).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.tab.mode, Mode::Preview, "ファイルはプレビューで開く");
assert!(
s.app
.tab
.preview_path
.as_deref()
.map(|p| p.ends_with("src/deep.txt"))
.unwrap_or(false),
"deep.txt がプレビュー対象: {:?}",
s.app.tab.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.tab.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.tab.root, repo, "root が repo(workdir)へ切替わる");
assert_eq!(s.app.tab.mode, Mode::Preview, "guide.md をプレビューで開く");
assert!(
s.app
.tab
.preview_path
.as_deref()
.map(|p| p.ends_with("docs/guide.md"))
.unwrap_or(false),
"guide.md がプレビュー対象: {:?}",
s.app.tab.preview_path
);
s.see("GUIDE_MARKER");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn e2e_paste_jump_from_visual_exits_visual_and_lands_in_preview() {
let dir = sandbox("paste_jump_visual");
std::fs::write(dir.join("target.txt"), "PASTE_JUMP_VISUAL_BODY\n").unwrap();
let dir = canon(&dir);
let mut s = Sim::new(&dir);
s.select("target.txt");
s.key('v');
assert!(s.app.is_visual(), "v でツリー Visual に入る");
assert_eq!(s.app.surface(), crate::keymap::Surface::Visual);
let sfc = s.app.surface();
assert_eq!(sfc, crate::keymap::Surface::Visual);
crate::dispatch_action(&mut s.app, crate::keymap::Action::PasteJump, sfc)
.expect("dispatch_action(PasteJump)");
s.draw();
assert!(
!s.app.is_visual(),
"PasteJump を dispatch した後は(ジャンプの成否によらず)ツリーの Visual 選択が終わっている"
);
s.app.enter_visual();
assert!(s.app.is_visual(), "enter_visual で Visual に入り直す");
s.app.paste_jump_from("target.txt");
s.draw();
assert!(
!s.app.is_visual(),
"enter_preview は(呼び出し元が畳み忘れていても)ツリーの Visual を必ず終える"
);
assert_ne!(
s.app.surface(),
crate::keymap::Surface::Visual,
"surface() はもう Visual を返さない"
);
assert_eq!(s.app.tab.mode, Mode::Preview, "プレビューに入っている");
s.see("PASTE_JUMP_VISUAL_BODY");
let cursor_before = s.app.tab.selected;
s.key('j');
assert_eq!(
s.app.tab.selected, cursor_before,
"j はもうツリーの Visual カーソルを動かさない(プレビュー中)"
);
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.tab.mode, Mode::Preview);
assert!(
s.app
.tab
.preview_path
.as_deref()
.map(|p| p.ends_with("target.txt"))
.unwrap_or(false),
"新タブは target.txt を開く: {:?}",
s.app.tab.preview_path
);
s.see("TARGET_FILE_MARKER");
s.key('[');
assert!(
s.app
.tab
.preview_path
.as_deref()
.map(|p| p.ends_with("doc.md"))
.unwrap_or(false),
"元タブは doc.md のまま: {:?}",
s.app.tab.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
.tab
.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 = unique_tmp("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.tab.mode, Mode::Preview));
s.see("alpha content");
s.ctrl('n');
s.see("zulu content");
assert!(s.app.tab.preview_path.as_ref().unwrap().ends_with("z.txt"));
s.ctrl('n');
s.see("inner content");
assert!(s
.app
.tab
.preview_path
.as_ref()
.unwrap()
.ends_with("inner.txt"));
s.ctrl('p');
s.see("zulu content");
assert!(s.app.tab.preview_path.as_ref().unwrap().ends_with("z.txt"));
s.key('q');
assert!(matches!(s.app.tab.mode, Mode::Tree));
assert!(s.app.tab.entries[s.app.tab.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 = unique_tmp("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.tab.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.tab.mode, Mode::Tree));
assert!(
s2.app.tab.entries[s2.app.tab.selected]
.path
.ends_with("b.txt"),
"ツリーカーソルを復元"
);
s2.see("a.txt"); s2.key('[');
assert!(matches!(s2.app.tab.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.tab.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.tab.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.tab.mode, Mode::Preview);
s.esc();
assert_eq!(s.app.tab.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.tab.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();
}
fn paging_body() -> String {
let mut body = String::new();
for i in 1..=400 {
body.push_str(&format!("line{i:03}\n"));
}
body
}
#[test]
fn e2e_preview_page_vim_scheme_first_page_up_after_paging_down_moves_the_window() {
let (mut s, dir) = text_preview(Config::default(), "wpage_vim", "big.txt", &paging_body());
assert!(s.app.is_windowed());
let page = (s.app.tab.preview_viewport as i64 - 1).max(1);
s.ctrl('f');
s.ctrl('f');
s.ctrl('f');
let top_after_downs = s.app.preview_top_line();
assert_eq!(top_after_downs as i64, 3 * page, "Ctrl-f×3 後の窓位置");
s.ctrl('b');
assert_ne!(
s.app.preview_top_line(),
top_after_downs,
"vim: Ctrl-f の直後、最初の Ctrl-b で窓が動かない(バグの再現)"
);
assert_eq!(
s.app.preview_top_line() as i64,
2 * page,
"Ctrl-b は 1 ページ分だけ戻るはず"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn e2e_preview_half_page_vim_scheme_first_half_up_after_half_paging_down_moves_the_window() {
let (mut s, dir) = text_preview(Config::default(), "whalf_vim", "big.txt", &paging_body());
assert!(s.app.is_windowed());
let half = (s.app.tab.preview_viewport as i64 / 2).max(1);
s.ctrl('d');
s.ctrl('d');
let top_after_downs = s.app.preview_top_line();
assert_eq!(top_after_downs as i64, 2 * half, "Ctrl-d×2 後の窓位置");
s.ctrl('u');
assert_ne!(
s.app.preview_top_line(),
top_after_downs,
"vim: Ctrl-d の直後、最初の Ctrl-u で窓が動かない(バグの再現)"
);
assert_eq!(
s.app.preview_top_line() as i64,
half,
"Ctrl-u は半ページ分だけ戻るはず"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn e2e_preview_page_less_scheme_first_page_up_after_paging_down_moves_the_window() {
let mut cfg = Config::default();
cfg.ui.keys = "less".into();
let (mut s, dir) = text_preview(cfg, "wpage_less", "big.txt", &paging_body());
assert!(s.app.is_windowed());
let page = (s.app.tab.preview_viewport as i64 - 1).max(1);
s.key(' ');
s.key('f');
s.key('f');
let top_after_downs = s.app.preview_top_line();
assert_eq!(top_after_downs as i64, 3 * page, "Space,f,f 後の窓位置");
s.key('b');
assert_ne!(
s.app.preview_top_line(),
top_after_downs,
"less: f の直後、最初の b で窓が動かない(バグの再現)"
);
assert_eq!(
s.app.preview_top_line() as i64,
2 * page,
"b は 1 ページ分だけ戻るはず"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn e2e_preview_half_page_less_scheme_first_half_up_after_half_paging_down_moves_the_window() {
let mut cfg = Config::default();
cfg.ui.keys = "less".into();
let (mut s, dir) = text_preview(cfg, "whalf_less", "big.txt", &paging_body());
assert!(s.app.is_windowed());
let half = (s.app.tab.preview_viewport as i64 / 2).max(1);
s.key('d');
s.key('d');
let top_after_downs = s.app.preview_top_line();
assert_eq!(top_after_downs as i64, 2 * half, "d×2 後の窓位置");
s.key('u');
assert_ne!(
s.app.preview_top_line(),
top_after_downs,
"less: d の直後、最初の u で窓が動かない(バグの再現)"
);
assert_eq!(
s.app.preview_top_line() as i64,
half,
"u は半ページ分だけ戻るはず"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn e2e_preview_paging_vim_and_less_schemes_land_on_identical_positions() {
fn drive(scheme: &str) -> (usize, usize, usize, usize) {
let mut cfg = Config::default();
cfg.ui.keys = scheme.into();
let (mut s, dir) = text_preview(
cfg,
&format!("wpage_agree_{scheme}"),
"big.txt",
&paging_body(),
);
assert!(s.app.is_windowed(), "{scheme}");
if scheme == "less" {
s.key('f');
s.key('f');
s.key('f');
} else {
s.ctrl('f');
s.ctrl('f');
s.ctrl('f');
}
let down3 = s.app.preview_top_line();
if scheme == "less" {
s.key('b');
} else {
s.ctrl('b');
}
let up1 = s.app.preview_top_line();
if scheme == "less" {
s.key('d');
s.key('d');
} else {
s.ctrl('d');
s.ctrl('d');
}
let halfdown2 = s.app.preview_top_line();
if scheme == "less" {
s.key('u');
} else {
s.ctrl('u');
}
let halfup1 = s.app.preview_top_line();
std::fs::remove_dir_all(&dir).ok();
(down3, up1, halfdown2, halfup1)
}
let vim = drive("vim");
let less = drive("less");
assert_eq!(
vim, less,
"vim/less スキームで同じキー操作の結果が一致しない"
);
assert_ne!(vim.0, vim.1);
assert_ne!(vim.2, vim.3);
}
#[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.tab.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.tab.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.tab.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.tab.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.tab.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.tab.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")]
fn install_blocking_pre_commit_hook(dir: &std::path::Path) {
let hooks = dir.join(".git").join("hooks");
std::fs::create_dir_all(&hooks).unwrap();
let hook = hooks.join("pre-commit");
std::fs::write(
&hook,
"#!/bin/sh\nn=0\nwhile [ -f \"$(git rev-parse --git-dir)/BLOCK\" ] && [ $n -lt 1000 ]; do\n sleep 0.02\n n=$((n+1))\ndone\nexit 0\n",
)
.unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&hook, std::fs::Permissions::from_mode(0o755)).unwrap();
}
}
#[cfg(feature = "git")]
#[test]
fn e2e_git_stage_and_commit_through_the_background_runner() {
let dir = sandbox("git_async_commit");
seed_repo(&dir);
let mut s = Sim::new(&canon(&dir)).with_async_git_ops();
s.key('o');
assert!(s.app.is_git_view());
assert_eq!(staged_of(&s.app, "a.rs"), Some(false));
s.key('s');
assert!(s.app.git_op_running(), "ステージは裏で走る");
s.drain_git_ops();
assert_eq!(
staged_of(&s.app, "a.rs"),
Some(true),
"適用後に staged になる"
);
s.key('c');
assert!(s.app.is_dialog());
s.keys("ASYNCCOMMITSUBJECT");
s.enter();
assert!(s.app.git_op_running(), "コミットも裏で走る");
s.drain_git_ops();
let commits = crate::git::log(&s.app.tab.root, 200);
assert!(
commits.iter().any(|c| c.summary == "ASYNCCOMMITSUBJECT"),
"コミットが log に載る: {:?}",
commits
.iter()
.map(|c| c.summary.clone())
.collect::<Vec<_>>()
);
assert!(s.app.is_git_view(), "完了後も Git ビューに居る");
assert_eq!(
staged_of(&s.app, "a.rs"),
None,
"コミット済みは一覧から消える"
);
s.key('l');
assert!(s.app.is_git_log());
s.see("ASYNCCOMMITSUBJECT");
std::fs::remove_dir_all(&dir).ok();
}
#[cfg(feature = "git")]
#[test]
fn e2e_ui_keeps_responding_while_a_git_commit_is_blocked() {
let dir = sandbox("git_blocked_commit");
seed_repo(&dir);
install_blocking_pre_commit_hook(&dir);
let root = canon(&dir);
let git_dir = root.join(".git");
std::fs::write(git_dir.join("BLOCK"), b"").unwrap();
crate::git::stage_all(&root).unwrap();
let before = crate::git::log(&root, 200).len();
let mut s = Sim::new(&root).with_async_git_ops();
s.key('o');
s.key('c');
s.keys("BLOCKEDCOMMIT");
s.enter();
assert!(
s.app.git_op_running(),
"コミットは実行中(まだ完了していない)"
);
assert_eq!(
crate::git::log(&root, 200).len(),
before,
"フックが握っている間はコミットが作られていない"
);
s.key('q'); assert!(!s.app.is_git_view(), "git 実行中でも Git ビューを出られる");
let start = s.app.tab.selected;
for _ in 0..3 {
s.key('j');
}
assert!(
s.app.tab.selected > start,
"git 実行中でもツリーのカーソルが動く"
);
s.key('k');
s.see("a.rs"); assert!(s.app.git_op_running(), "ここまでキーを叩いてもまだ実行中");
assert_eq!(
crate::git::log(&root, 200).len(),
before,
"キー処理の間もコミットは作られていない"
);
std::fs::remove_file(git_dir.join("BLOCK")).unwrap();
s.drain_git_ops();
assert!(!s.app.git_op_running(), "適用で実行中フラグが解ける");
let commits = crate::git::log(&root, 200);
assert!(
commits.iter().any(|c| c.summary == "BLOCKEDCOMMIT"),
"解放後にコミットが完了する: {:?}",
commits
.iter()
.map(|c| c.summary.clone())
.collect::<Vec<_>>()
);
std::fs::remove_dir_all(&dir).ok();
}
#[cfg(feature = "git")]
#[test]
fn e2e_quit_during_a_git_write_asks_for_confirmation() {
let dir = sandbox("git_quit_during_write");
seed_repo(&dir);
install_blocking_pre_commit_hook(&dir);
let root = canon(&dir);
std::fs::write(root.join(".git").join("BLOCK"), b"").unwrap();
let mut cfg = Config::default();
cfg.ui.confirm_quit = false;
crate::git::stage_all(&root).unwrap();
let mut s = Sim::with_config(&root, cfg).with_async_git_ops();
s.key('o');
s.key('c');
s.keys("WILLQUIT");
s.enter();
assert!(s.app.git_op_running(), "コミットは裏で実行中");
s.key('Q');
assert!(!s.quit, "確認を出すのでこの時点では終了しない");
assert!(s.app.is_dialog(), "確認ダイアログが出る");
s.see(crate::i18n::tr(
s.app.lang,
crate::i18n::Msg::QuitWhileGitOp,
));
s.key('y');
assert!(s.quit, "y なら終了できる(引き止めない)");
std::fs::remove_file(root.join(".git").join("BLOCK")).ok();
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.tab.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.tab.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.tab.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.tab.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.tab.entries[s.app.tab.selected].path.ends_with("a.rs"));
s.key('n');
assert!(s.app.tab.entries[s.app.tab.selected]
.path
.ends_with("new.txt"));
s.key('n');
assert!(s.app.tab.entries[s.app.tab.selected].path.ends_with("a.rs"));
s.key('N');
assert!(s.app.tab.entries[s.app.tab.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
.tab
.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
.tab
.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
.tab
.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
.tab
.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
.tab
.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.tab.entries.len();
s.key('/');
s.keys("qzqzqznope");
assert!(s.app.is_filtering());
assert!(s.app.tab.entries.is_empty(), "{}", s.screen());
s.dont_see("notes.txt");
s.enter();
assert!(s.app.tab.entries.is_empty());
s.esc();
assert_eq!(s.app.tab.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
.tab
.entries
.iter()
.any(|e| e.path.ends_with("readme.md")));
assert!(!s
.app
.tab
.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
.tab
.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.tab.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.tab.mode, Mode::Preview);
assert!(s
.app
.tab
.preview_path
.as_deref()
.is_some_and(|p| p.ends_with("notes.txt")));
assert!(s.app.tab.entries[s.app.tab.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.tab.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.tab.mode, Mode::Preview);
assert!(
s.app
.tab
.preview_path
.as_deref()
.is_some_and(|p| p.ends_with("a.txt")),
"{:?}",
s.app.tab.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.tab.mode, Mode::Preview);
assert!(s
.app
.tab
.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.tab.mode, Mode::Preview);
assert!(s
.app
.tab
.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.tab.mode, Mode::Preview);
assert!(s
.app
.tab
.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.tab.mode, Mode::Tree);
assert!(s.app.tab.preview_path.is_none());
s.app.paste_jump_from("https://example.com/some/file.rs");
s.draw();
assert_eq!(s.app.tab.mode, Mode::Tree);
assert!(s.app.tab.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("couldn't toggle checkbox");
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("couldn't toggle checkbox");
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("couldn't toggle checkbox");
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.tab.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.tab.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();
}
#[cfg(unix)]
#[test]
fn e2e_delegated_command_text_preview_shows_captured_output() {
let dir = sandbox("cmd_delegate_text");
std::fs::write(dir.join("app.log"), "line one\nline two\n").unwrap();
let root = canon(&dir);
let mut cfg = Config::default();
cfg.preview.rules.insert(
0,
crate::config::Rule {
glob: Some("*.log".into()),
command: Some("cat {path}".into()),
..Default::default()
},
);
let mut s = Sim::with_config(&root, cfg);
s.select("app.log");
s.enter();
s.see("line one");
s.see("line two");
s.see("app.log");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn e2e_ui_keys_less_scheme_uses_plain_letters_not_ctrl() {
let mut cfg = Config::default();
cfg.ui.keys = "less".into();
let (mut s, dir) = text_preview(cfg, "keys_less", "big.txt", &paging_body());
assert!(s.app.is_windowed());
let top0 = s.app.preview_top_line();
s.ctrl('f');
assert_eq!(
s.app.preview_top_line(),
top0,
"less スキームでは Ctrl-f はバインドされていないはず"
);
s.key('f');
let top1 = s.app.preview_top_line();
assert!(top1 > top0, "less: 素の f がページ送りになるはず");
s.key(' ');
let top2 = s.app.preview_top_line();
assert!(top2 > top1, "less: Space もページ送りになるはず");
s.key('b');
let top3 = s.app.preview_top_line();
assert!(top3 < top2, "less: 素の b がページ戻しになるはず");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn e2e_ui_keys_vim_scheme_uses_ctrl_not_plain_letters() {
let mut cfg = Config::default();
cfg.ui.keys = "vim".into();
let (mut s, dir) = text_preview(cfg, "keys_vim", "big.txt", &paging_body());
assert!(s.app.is_windowed());
let top0 = s.app.preview_top_line();
s.key('f');
assert_eq!(
s.app.preview_top_line(),
top0,
"vim スキームでは素の f はバインドされていないはず"
);
s.key(' ');
assert_eq!(
s.app.preview_top_line(),
top0,
"vim スキームでは Space もバインドされていないはず"
);
s.ctrl('f');
let top1 = s.app.preview_top_line();
assert!(top1 > top0, "vim: Ctrl-f がページ送りになるはず");
s.ctrl('b');
let top2 = s.app.preview_top_line();
assert!(top2 < top1, "vim: Ctrl-b がページ戻しになるはず");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn e2e_ui_keys_unknown_value_falls_back_to_vim_scheme() {
let mut cfg = Config::default();
cfg.ui.keys = "emacs".into(); let (mut s, dir) = text_preview(cfg, "keys_unknown", "big.txt", &paging_body());
assert!(s.app.is_windowed());
let top0 = s.app.preview_top_line();
s.key('f');
assert_eq!(
s.app.preview_top_line(),
top0,
"不明な ui.keys は vim にフォールバックするはず(素の f は無効)"
);
s.ctrl('f');
assert!(
s.app.preview_top_line() > top0,
"不明な ui.keys は vim にフォールバックするはず(Ctrl-f が有効)"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn e2e_markdown_tab_focus_reverses_only_the_focused_marker_style() {
let mut cfg = Config::default();
cfg.ui.icons = false; let (mut s, dir) = md_preview(
cfg,
"focus_styles",
"Read the [guide](https://example.com/guide) first.\n\n\
- [ ] pending task\n\n\
```rust\n\
fn main() {}\n\
```\n\n\
<details>\n\
<summary>Extra Info</summary>\n\n\
Some hidden body text.\n\n\
</details>\n",
);
fn reversed(s: &Sim, needle: &str) -> bool {
s.style_of(needle)
.is_some_and(|st| st.add_modifier.contains(ratatui::style::Modifier::REVERSED))
}
assert!(
s.reversed_cells().is_empty(),
"フォーカス前は反転セルが1つも無いはず: {:?}",
s.reversed_cells()
);
assert!(!reversed(&s, "guide"));
assert!(!reversed(&s, "[ ]"));
assert!(!reversed(&s, "rust"));
assert!(!reversed(&s, "▸"));
s.tab();
s.see_styled(
"guide",
|st| st.add_modifier.contains(ratatui::style::Modifier::REVERSED),
"フォーカス中のリンク=反転",
);
assert!(
!reversed(&s, "[ ]"),
"リンクにフォーカス中はチェックボックスは反転しない"
);
assert!(
!reversed(&s, "rust"),
"リンクにフォーカス中はコードブロックは反転しない"
);
assert!(
!reversed(&s, "▸"),
"リンクにフォーカス中は details は反転しない"
);
s.tab();
assert!(
!reversed(&s, "guide"),
"フォーカスが移ればリンクは反転が解ける"
);
s.see_styled(
"[ ]",
|st| st.add_modifier.contains(ratatui::style::Modifier::REVERSED),
"フォーカス中のチェックボックス=反転",
);
assert!(!reversed(&s, "rust"));
assert!(!reversed(&s, "▸"));
s.tab();
assert!(!reversed(&s, "[ ]"));
s.see_styled(
"rust",
|st| st.add_modifier.contains(ratatui::style::Modifier::REVERSED),
"フォーカス中のコードブロック=ヘッダ行が反転",
);
assert!(!reversed(&s, "▸"));
s.tab();
assert!(!reversed(&s, "rust"));
s.see_styled(
"▸",
|st| st.add_modifier.contains(ratatui::style::Modifier::REVERSED),
"フォーカス中の <details>=▸ が反転",
);
s.tab();
s.see_styled(
"guide",
|st| st.add_modifier.contains(ratatui::style::Modifier::REVERSED),
"巡回して先頭(リンク)へ戻る",
);
assert!(!reversed(&s, "▸"));
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn e2e_csv_rainbow_columns_use_distinct_foreground_colors() {
let dir = sandbox("csv_rainbow_style");
std::fs::write(dir.join("wide.csv"), "colA,colB,colC,colD\n1,2,3,4\n").unwrap();
let mut s = Sim::new(&canon(&dir));
s.select("wide.csv");
s.enter();
s.see("TABLE");
let (header_row, _) = s.find_text("colA").expect("header row is drawn");
assert!(
s.distinct_fg_in_row(header_row) >= 3,
"4列のヘッダ行は3色以上の前景色が混在するはず(レインボー)"
);
s.key('q');
std::fs::remove_dir_all(&dir).ok();
}
#[derive(Debug, Clone, Copy)]
enum Coverage {
Covered(&'static str),
CoveredElsewhere(&'static str),
CoveredByUnitTest(&'static str),
}
const UI_CONFIG_COVERAGE: &[(&str, Coverage)] = &[
(
"show_hidden",
Coverage::Covered("e2e_ui_show_hidden_config_starts_with_dotfiles_visible"),
),
(
"filter_mode",
Coverage::Covered("e2e_ui_filter_mode_fuzzy_vs_substring_noncontiguous_query"),
),
(
"tabbar",
Coverage::Covered("e2e_ui_tabbar_always_vs_auto_with_a_single_tab"),
),
(
"icons",
Coverage::CoveredElsewhere("e2e_md_task_markers_render_ascii_or_nf_by_icons"),
),
(
"wrap",
Coverage::CoveredElsewhere("e2e_md_wrapped_focus_follows_offscreen_item"),
),
(
"line_numbers",
Coverage::Covered("e2e_ui_line_numbers_toggle_shows_gutter"),
),
(
"git_gutter",
Coverage::Covered("e2e_ui_git_gutter_toggle_shows_or_hides_change_marker"),
),
(
"tab_width",
Coverage::Covered("e2e_ui_tab_width_changes_expansion_columns"),
),
(
"syntax_highlight",
Coverage::Covered("e2e_ui_syntax_highlight_toggle_changes_color_diversity"),
),
(
"preview_loading",
Coverage::Covered("e2e_ui_preview_loading_indicator_vs_progressive_for_cold_language"),
),
(
"path_style",
Coverage::Covered("e2e_ui_path_style_config_sets_initial_style"),
),
(
"keys",
Coverage::CoveredElsewhere(
"e2e_ui_keys_less_scheme_uses_plain_letters_not_ctrl / e2e_ui_keys_vim_scheme_uses_ctrl_not_plain_letters / e2e_ui_keys_unknown_value_falls_back_to_vim_scheme",
),
),
(
"lang",
Coverage::Covered("e2e_ui_lang_toggle_switches_footer_translation"),
),
(
"statusbar",
Coverage::Covered("e2e_ui_statusbar_layout_split_vs_bottom_moves_context_row"),
),
(
"theme",
Coverage::Covered("e2e_ui_theme_code_bg_toggle_changes_code_block_background"),
),
(
"image_render_scale",
Coverage::Covered("e2e_ui_image_render_scale_shrinks_display_rect"),
),
(
"svg_max_px",
Coverage::Covered("e2e_ui_svg_max_px_changes_apparent_display_size"),
),
(
"sort",
Coverage::Covered("e2e_ui_sort_config_sets_initial_order_without_pressing_s"),
),
(
"details",
Coverage::CoveredElsewhere("e2e_details_columns_show_size_and_modified"),
),
(
"graph_max_branches",
Coverage::Covered("e2e_ui_graph_max_branches_caps_legend_entries"),
),
(
"graph_base_branches",
Coverage::Covered("e2e_ui_graph_base_branches_config_auto_selects_base"),
),
(
"commit_meta_align",
Coverage::Covered("e2e_ui_commit_meta_align_right_vs_inline_moves_author_column"),
),
(
"confirm_quit",
Coverage::CoveredElsewhere(
"e2e_last_tab_q_quits_when_confirm_off / e2e_last_tab_q_confirms_when_confirm_on",
),
),
(
"confirm_bookmark_overwrite",
Coverage::CoveredElsewhere(
"e2e_bookmark_overwrite_prompts_confirm_then_applies / e2e_bookmark_overwrite_off_applies_silently",
),
),
(
"follow_view",
Coverage::Covered(
"e2e_follow_opens_full_screen_diff (default) / e2e_ui_follow_view_file_shows_content_preview_not_diff",
),
),
(
"csv_rainbow",
Coverage::CoveredElsewhere(
"e2e_csv_rainbow_columns_use_distinct_foreground_colors / e2e_csv_table_renders_and_navigates_without_rainbow",
),
),
(
"md_task_states",
Coverage::CoveredElsewhere("e2e_markdown_task_cycles_custom_states"),
),
(
"md_autolink",
Coverage::CoveredElsewhere(
"e2e_markdown_autolink_and_emoji_full_pipeline (on) / e2e_markdown_autolink_emoji_alerts_toggle_off (off)",
),
),
(
"md_alerts",
Coverage::CoveredElsewhere(
"e2e_markdown_alert_all_five_types_and_aliases (on) / e2e_markdown_autolink_emoji_alerts_toggle_off (off)",
),
),
(
"md_emoji",
Coverage::CoveredElsewhere(
"e2e_markdown_autolink_and_emoji_full_pipeline (on) / e2e_markdown_autolink_emoji_alerts_toggle_off (off)",
),
),
(
"md_frontmatter",
Coverage::CoveredElsewhere(
"e2e_markdown_front_matter_renders_as_metadata (on) / e2e_markdown_front_matter_off_leaves_body_intact (off)",
),
),
(
"md_footnotes",
Coverage::CoveredElsewhere(
"e2e_markdown_footnotes_render_superscript_and_section (on) / e2e_markdown_footnotes_off_stays_literal (off)",
),
),
(
"md_inline_html",
Coverage::Covered(
"e2e_markdown_inline_html_renders (on) / e2e_ui_md_inline_html_off_keeps_raw_tags",
),
),
(
"md_details",
Coverage::Covered(
"e2e_md_html_details_collapse_and_toggle (auto) / e2e_ui_md_details_config_forces_open_despite_missing_attribute",
),
),
(
"math",
Coverage::Covered("e2e_ui_math_image_mode_lifts_expression_text_mode_keeps_it_inline"),
),
(
"math_color",
Coverage::Covered("e2e_ui_math_color_changes_rendered_pixel_hue"),
),
(
"busy_indicator",
Coverage::Covered(
"e2e_busy_indicator_absent_when_idle (idle invariant) / e2e_ui_busy_indicator_shows_while_media_loads_hides_when_disabled",
),
),
(
"mermaid",
Coverage::Covered("e2e_ui_mermaid_image_mode_shows_caption_text_mode_does_not"),
),
(
"mermaid_theme",
Coverage::Covered("e2e_ui_mermaid_theme_changes_rendered_pixel_brightness"),
),
(
"mermaid_rows",
Coverage::Covered("e2e_ui_mermaid_rows_changes_reserved_diagram_height"),
),
(
"restore_tabs",
Coverage::CoveredElsewhere(
"e2e_session_restore_reopens_previous_tabs (on, via Sim) / session_actions::restore_tabs_off_neither_saves_nor_restores (off, via App::new)",
),
),
(
"restore_single_tab",
Coverage::CoveredByUnitTest(
"session_actions::restore_single_tab_true_saves_a_lone_tab / restore_single_tab_false_deletes_a_lone_tab_session / restore_single_tab_false_still_saves_multi_tab / restore_single_tab_false_skips_and_deletes_stale_single_tab_file",
),
),
];
fn extract_ui_config_field_names() -> Vec<String> {
let src = include_str!("config/mod.rs");
let marker = "pub struct UiConfig {";
let start = src
.find(marker)
.expect("UiConfig struct not found in config/mod.rs — did it move or get renamed?");
let body_start = start + marker.len() - 1; let bytes = src.as_bytes();
let mut depth = 0i32;
let mut end = body_start;
for (i, &b) in bytes[body_start..].iter().enumerate() {
match b {
b'{' => depth += 1,
b'}' => {
depth -= 1;
if depth == 0 {
end = body_start + i;
break;
}
}
_ => {}
}
}
assert!(end > body_start, "matching closing brace not found");
let body = &src[body_start..end];
let mut names = Vec::new();
for line in body.lines() {
let t = line.trim_start();
if let Some(rest) = t.strip_prefix("pub ") {
if let Some(colon) = rest.find(':') {
let name = rest[..colon].trim();
if !name.is_empty() {
names.push(name.to_string());
}
}
}
}
names
}
#[test]
fn ui_config_field_extraction_finds_at_least_35_fields() {
let names = extract_ui_config_field_names();
assert!(
names.len() >= 35,
"抽出数が少なすぎる(安全弁): {} 件 — パーサが壊れている可能性: {:?}",
names.len(),
names
);
}
#[test]
fn ui_config_coverage_table_is_complete() {
let extracted = extract_ui_config_field_names();
assert!(
extracted.len() >= 35,
"抽出数が少なすぎる(安全弁): {} 件",
extracted.len()
);
let extracted: std::collections::BTreeSet<&str> =
extracted.iter().map(|s| s.as_str()).collect();
let table: std::collections::BTreeSet<&str> =
UI_CONFIG_COVERAGE.iter().map(|(name, _)| *name).collect();
let missing_from_table: Vec<&&str> = extracted.difference(&table).collect();
let stale_in_table: Vec<&&str> = table.difference(&extracted).collect();
assert!(
missing_from_table.is_empty() && stale_in_table.is_empty(),
"UiConfig のフィールドと UI_CONFIG_COVERAGE テーブルが不一致。\n\
テーブルに無い(新規フィールド?): {missing_from_table:?}\n\
構造体に無い(削除/改名?): {stale_in_table:?}"
);
assert_eq!(
table.len(),
UI_CONFIG_COVERAGE.len(),
"UI_CONFIG_COVERAGE に重複キーがある"
);
}
#[test]
fn ui_config_coverage_table_breakdown() {
let mut covered = 0usize;
let mut covered_elsewhere = 0usize;
let mut covered_by_unit_test = 0usize;
for (field, cov) in UI_CONFIG_COVERAGE {
match cov {
Coverage::Covered(test) => {
assert!(!test.is_empty(), "{field}: Covered のテスト名が空");
covered += 1;
}
Coverage::CoveredElsewhere(test) => {
assert!(!test.is_empty(), "{field}: CoveredElsewhere のテスト名が空");
covered_elsewhere += 1;
}
Coverage::CoveredByUnitTest(test) => {
assert!(
!test.is_empty(),
"{field}: CoveredByUnitTest のテスト名が空"
);
covered_by_unit_test += 1;
}
}
}
eprintln!(
"UI_CONFIG_COVERAGE breakdown: total={} Covered={covered} CoveredElsewhere={covered_elsewhere} CoveredByUnitTest={covered_by_unit_test}",
UI_CONFIG_COVERAGE.len(),
);
}
#[test]
fn e2e_ui_show_hidden_config_starts_with_dotfiles_visible() {
let dir = sandbox("ui_show_hidden_cfg");
seed_files(&dir);
let root = canon(&dir);
let s_default = Sim::new(&root); s_default.dont_see(".hidden.txt");
let mut cfg = Config::default();
cfg.ui.show_hidden = true;
let s_on = Sim::with_config(&root, cfg);
s_on.see(".hidden.txt"); std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn e2e_ui_show_hidden_new_tab_resets_to_config_default() {
let dir_on = sandbox("ui_show_hidden_new_tab_on");
seed_files(&dir_on);
let root_on = canon(&dir_on);
let mut cfg_on = Config::default();
cfg_on.ui.show_hidden = true;
let mut s_on = Sim::with_config(&root_on, cfg_on);
s_on.see(".hidden.txt");
s_on.key('.'); s_on.dont_see(".hidden.txt");
s_on.key('t'); s_on.see(".hidden.txt"); std::fs::remove_dir_all(&dir_on).ok();
let dir_off = sandbox("ui_show_hidden_new_tab_off");
seed_files(&dir_off);
let root_off = canon(&dir_off);
let mut s_off = Sim::new(&root_off);
s_off.dont_see(".hidden.txt");
s_off.key('.'); s_off.see(".hidden.txt");
s_off.key('t'); s_off.dont_see(".hidden.txt"); std::fs::remove_dir_all(&dir_off).ok();
}
#[test]
fn e2e_ui_show_hidden_dot_key_toggles_from_either_config_default() {
let dir_on = sandbox("ui_show_hidden_toggle_from_on");
seed_files(&dir_on);
let root_on = canon(&dir_on);
let mut cfg_on = Config::default();
cfg_on.ui.show_hidden = true;
let mut s_on = Sim::with_config(&root_on, cfg_on);
s_on.see(".hidden.txt");
s_on.key('.');
s_on.dont_see(".hidden.txt");
s_on.key('.'); s_on.see(".hidden.txt");
std::fs::remove_dir_all(&dir_on).ok();
let dir_off = sandbox("ui_show_hidden_toggle_from_off");
seed_files(&dir_off);
let root_off = canon(&dir_off);
let mut s_off = Sim::new(&root_off); s_off.dont_see(".hidden.txt");
s_off.key('.');
s_off.see(".hidden.txt");
s_off.key('.'); s_off.dont_see(".hidden.txt");
std::fs::remove_dir_all(&dir_off).ok();
}
#[test]
fn e2e_ui_filter_mode_fuzzy_vs_substring_noncontiguous_query() {
let dir = sandbox("ui_filter_mode_cfg");
std::fs::write(dir.join("app_resolver.rs"), "x").unwrap();
let root = canon(&dir);
let mut s_fuzzy = Sim::new(&root);
s_fuzzy.key('/');
s_fuzzy.keys("aprs");
s_fuzzy.see("app_resolver.rs");
let mut cfg = Config::default();
cfg.ui.filter_mode = "substring".into();
let mut s_sub = Sim::with_config(&root, cfg);
s_sub.key('/');
s_sub.keys("aprs");
s_sub.dont_see("app_resolver.rs");
assert_eq!(
s_sub.app.tab.entries.len(),
0,
"substring は非連続クエリに一致しないはず"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn e2e_ui_tabbar_always_vs_auto_with_a_single_tab() {
let dir = sandbox("ui_tabbar_cfg");
seed_files(&dir);
let root = canon(&dir);
let s_auto = Sim::new(&root); let label = s_auto.app.tab_label(0);
let chip = format!("1:{label}");
s_auto.dont_see(&chip);
let mut cfg = Config::default();
cfg.ui.tabbar = "always".into();
let s_always = Sim::with_config(&root, cfg);
s_always.see(&chip); std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn e2e_ui_line_numbers_toggle_shows_gutter() {
let dir = sandbox("ui_line_numbers_cfg");
std::fs::write(dir.join("f.txt"), "alpha\nbeta\ngamma\n").unwrap();
let root = canon(&dir);
let mut s_off = Sim::new(&root); s_off.select("f.txt");
s_off.enter();
s_off.dont_see(" 1 alpha");
let mut cfg = Config::default();
cfg.ui.line_numbers = true;
let mut s_on = Sim::with_config(&root, cfg);
s_on.select("f.txt");
s_on.enter();
s_on.see(" 1 alpha");
std::fs::remove_dir_all(&dir).ok();
}
#[cfg(feature = "git")]
#[test]
fn e2e_ui_git_gutter_toggle_shows_or_hides_change_marker() {
let dir = sandbox("ui_git_gutter_cfg");
seed_repo(&dir);
let dir = canon(&dir);
std::fs::write(dir.join("a.rs"), "fn a() {}\nfn a2() {}\n").unwrap();
let mut cfg_on = Config::default();
cfg_on.ui.syntax_highlight = false; let mut s_on = Sim::with_config(&dir, cfg_on);
s_on.select("a.rs");
s_on.enter();
s_on.see("▌");
let mut cfg_off = Config::default();
cfg_off.ui.syntax_highlight = false;
cfg_off.ui.git_gutter = false;
let mut s_off = Sim::with_config(&dir, cfg_off);
s_off.select("a.rs");
s_off.enter();
s_off.dont_see("▌");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn e2e_ui_tab_width_changes_expansion_columns() {
let dir = sandbox("ui_tab_width_cfg");
std::fs::write(dir.join("f.txt"), "\tX\n").unwrap();
let root = canon(&dir);
let mut s4 = Sim::new(&root); s4.select("f.txt");
s4.enter();
s4.see("→ X");
let mut cfg = Config::default();
cfg.ui.tab_width = 8;
let mut s8 = Sim::with_config(&root, cfg); s8.select("f.txt");
s8.enter();
s8.see("→ X");
s8.dont_see("→ X");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn e2e_ui_syntax_highlight_toggle_changes_color_diversity() {
let dir = sandbox("ui_syntax_hl_cfg");
let path = dir.join("code.rs");
std::fs::write(&path, "fn main() { let x = 1; }\n").unwrap();
crate::preview::code::warm_file("rs", &path);
let root = canon(&dir);
let mut s_on = Sim::new(&root); s_on.select("code.rs");
s_on.enter();
let (row_on, _) = s_on.find_text("fn main").expect("code line drawn");
let variety_on = s_on.distinct_fg_in_row(row_on);
let mut cfg = Config::default();
cfg.ui.syntax_highlight = false;
let mut s_off = Sim::with_config(&root, cfg);
s_off.select("code.rs");
s_off.enter();
let (row_off, _) = s_off.find_text("fn main").expect("code line drawn");
let variety_off = s_off.distinct_fg_in_row(row_off);
assert!(
variety_on > variety_off,
"既定はハイライトで複数色/off は単色のはず: on={variety_on} off={variety_off}"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn e2e_ui_preview_loading_indicator_vs_progressive_for_cold_language() {
let dir = sandbox("ui_cold_lang_cfg");
std::fs::write(dir.join("f.zzzcoldlang"), "UNIQUEBODYTEXT123\n").unwrap();
let root = canon(&dir);
let rule = crate::config::Rule {
glob: Some("*.zzzcoldlang".into()),
builtin: Some("code".into()),
..Default::default()
};
let mut cfg_ind = Config::default();
cfg_ind.preview.rules.insert(0, rule.clone());
let mut s_ind = Sim::with_config(&root, cfg_ind);
s_ind.select("f.zzzcoldlang");
s_ind.enter();
assert!(
s_ind.app.is_highlight_pending(),
"登録したての拡張子はコールドのはず"
);
s_ind.see("loading"); s_ind.dont_see("UNIQUEBODYTEXT123");
let mut cfg_prog = Config::default();
cfg_prog.preview.rules.insert(0, rule);
cfg_prog.ui.preview_loading = "progressive".into();
let mut s_prog = Sim::with_config(&root, cfg_prog);
s_prog.select("f.zzzcoldlang");
s_prog.enter();
assert!(s_prog.app.is_highlight_pending());
s_prog.see("UNIQUEBODYTEXT123");
s_prog.dont_see("loading");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn e2e_ui_path_style_config_sets_initial_style() {
use crate::app::PathStyle;
let dir = sandbox("ui_path_style_cfg");
seed_files(&dir);
let root = canon(&dir);
let mut cfg = Config::default();
cfg.ui.path_style = "full".into();
let s = Sim::with_config(&root, cfg);
assert_eq!(
s.app.path_style,
PathStyle::Full,
"config で起動時から full のはず(既定は relative — e2e_path_style_cycles参照)"
);
s.see("path:abs");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn e2e_ui_lang_toggle_switches_footer_translation() {
let dir = sandbox("ui_lang_cfg");
seed_files(&dir);
let root = canon(&dir);
let mut cfg_en = Config::default();
cfg_en.ui.lang = "en".into();
let s_en = Sim::with_config(&root, cfg_en);
s_en.see("quit");
let mut cfg_jp = Config::default();
cfg_jp.ui.lang = "jp".into();
let s_jp = Sim::with_config(&root, cfg_jp);
s_jp.dont_see("quit");
s_jp.see("終"); std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn e2e_ui_statusbar_layout_split_vs_bottom_moves_context_row() {
let dir = sandbox("ui_statusbar_cfg");
seed_files(&dir);
let root = canon(&dir);
let s_split = Sim::new(&root); let (row_split, _) = s_split.find_text("TREE").expect("TREE chip shown");
assert_eq!(row_split, 0, "split: 上段(0行目)に TREE チップ");
let mut cfg = Config::default();
cfg.ui.statusbar = "bottom".into();
let s_bottom = Sim::with_config(&root, cfg);
let (row_bottom, _) = s_bottom
.find_text("TREE")
.expect("TREE chip shown (combined bottom line)");
assert_ne!(
row_bottom, 0,
"bottom: 上段は消え最終行にまとめて表示されるはず"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn e2e_ui_theme_code_bg_toggle_changes_code_block_background() {
let body = "```rust\nfn a(){}\n```\n";
let (s_on, dir1) = md_preview(Config::default(), "ui_theme_bg_on", body);
let bg_on = s_on.style_of("fn a").map(|st| st.bg);
std::fs::remove_dir_all(&dir1).ok();
let (s_off, dir2) = md_preview(cfg_code_bg_none(), "ui_theme_bg_off", body);
let bg_off = s_off.style_of("fn a").map(|st| st.bg);
std::fs::remove_dir_all(&dir2).ok();
assert!(
matches!(bg_on, Some(Some(ratatui::style::Color::Rgb(..)))),
"既定のコード背景は実色のはず: {bg_on:?}"
);
assert_eq!(
bg_off,
Some(Some(ratatui::style::Color::Reset)),
"code_bg=none は背景なし(Reset)のはず: {bg_off:?}"
);
assert_ne!(bg_on, bg_off);
}
#[test]
fn e2e_ui_image_render_scale_shrinks_display_rect() {
let dir = sandbox("ui_image_render_scale_cfg");
let img = image::DynamicImage::ImageRgb8(image::RgbImage::from_pixel(
800,
600,
image::Rgb([200, 50, 50]),
));
img.save(dir.join("p.png")).unwrap();
let root = canon(&dir);
let inner = ratatui::layout::Rect::new(0, 0, 80, 20);
let mut s_default = Sim::new(&root).with_picker(); s_default.select("p.png");
s_default.enter();
let target_default = s_default
.app
.prepare_image(inner)
.expect("画像がデコードされ target rect が計算されるはず");
let mut cfg = Config::default();
cfg.ui.image_render_scale = 0.3;
let mut s_small = Sim::with_config(&root, cfg).with_picker();
s_small.select("p.png");
s_small.enter();
let target_small = s_small
.app
.prepare_image(inner)
.expect("画像がデコードされ target rect が計算されるはず");
assert!(
target_small.width < target_default.width && target_small.height < target_default.height,
"image_render_scale=0.3 は既定1.0より小さい表示矩形になるはず: small={target_small:?} default={target_default:?}"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn e2e_ui_svg_max_px_changes_apparent_display_size() {
let dir = sandbox("ui_svg_max_px_cfg");
std::fs::copy("samples/sample.svg", dir.join("p.svg")).unwrap();
let root = canon(&dir);
let inner = ratatui::layout::Rect::new(0, 0, 80, 20);
let mut s_default = Sim::new(&root).with_picker(); s_default.select("p.svg");
s_default.enter();
let target_default = s_default
.app
.prepare_image(inner)
.expect("SVG がラスタライズされ target rect が計算されるはず");
let mut cfg = Config::default();
cfg.ui.svg_max_px = 40;
let mut s_small = Sim::with_config(&root, cfg).with_picker();
s_small.select("p.svg");
s_small.enter();
let target_small = s_small
.app
.prepare_image(inner)
.expect("SVG がラスタライズされ target rect が計算されるはず");
assert!(
target_small.width < target_default.width || target_small.height < target_default.height,
"svg_max_px=40 は既定800より小さい見かけサイズになるはず: small={target_small:?} default={target_default:?}"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn e2e_ui_sort_config_sets_initial_order_without_pressing_s() {
let dir = sandbox("ui_sort_cfg");
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 root = canon(&dir);
fn names(s: &Sim) -> Vec<&str> {
s.app
.tab
.entries
.iter()
.map(|e| e.path.file_name().and_then(|n| n.to_str()).unwrap_or(""))
.collect()
}
let s_default = Sim::new(&root); assert_eq!(names(&s_default), vec!["aaa.txt", "bbb.txt", "ccc.txt"]);
let mut cfg = Config::default();
cfg.ui.sort.key = "size".into();
let s_cfg = Sim::with_config(&root, cfg);
assert_eq!(
names(&s_cfg),
vec!["bbb.txt", "ccc.txt", "aaa.txt"],
"sort.key=\"size\" は起動時からサイズ順のはず(sメニュー不要)"
);
assert!(
!s_cfg.app.is_sort_menu(),
"メニューを開かず既に反映済みのはず"
);
std::fs::remove_dir_all(&dir).ok();
}
#[cfg(feature = "git")]
#[test]
fn e2e_ui_graph_max_branches_caps_legend_entries() {
let dir = sandbox("ui_graph_max_branches_cfg");
seed_repo_history(&dir); let dir = canon(&dir);
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(&["branch", "b1"]);
run(&["branch", "b2"]);
run(&["branch", "b3"]);
let mut cfg = Config::default();
cfg.ui.graph_max_branches = 2;
let mut s = Sim::with_config(&dir, cfg);
s.key('o');
s.key('g');
s.see("hidden)");
let mut cfg2 = Config::default();
cfg2.ui.graph_max_branches = 0; let mut s2 = Sim::with_config(&dir, cfg2);
s2.key('o');
s2.key('g');
s2.dont_see("hidden)");
std::fs::remove_dir_all(&dir).ok();
}
#[cfg(feature = "git")]
#[test]
fn e2e_ui_graph_base_branches_config_auto_selects_base() {
let dir = sandbox("ui_graph_base_branches_cfg");
seed_repo(&dir);
let root = canon(&dir);
let base = crate::git::branch(&root).expect("has a branch after seed_repo's commit");
let mut s_default = Sim::new(&root); s_default.key('o');
s_default.key('g');
s_default.dont_see("⌖ base:");
let mut cfg = Config::default();
cfg.ui.graph_base_branches = vec![base];
let mut s_cfg = Sim::with_config(&root, cfg);
s_cfg.key('o');
s_cfg.key('g');
s_cfg.see("⌖ base:"); std::fs::remove_dir_all(&dir).ok();
}
#[cfg(feature = "git")]
#[test]
fn e2e_ui_commit_meta_align_right_vs_inline_moves_author_column() {
let dir = sandbox("ui_commit_meta_align_cfg");
std::fs::create_dir_all(&dir).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", "AuthorZZZ"]);
std::fs::write(dir.join("a.rs"), "fn a(){}\n").unwrap();
run(&["add", "-A"]);
run(&["commit", "-q", "-m", "shortsubj"]);
let dir = canon(&dir);
let mut s_right = Sim::new(&dir); s_right.key('o');
s_right.key('l');
let (_, col_right) = s_right
.find_text("AuthorZZZ")
.expect("author shown in the log row");
let mut cfg = Config::default();
cfg.ui.commit_meta_align = "inline".into();
let mut s_inline = Sim::with_config(&dir, cfg); s_inline.key('o');
s_inline.key('l');
let (_, col_inline) = s_inline
.find_text("AuthorZZZ")
.expect("author shown in the log row");
assert!(
col_right > col_inline,
"right は列揃え(右寄り)/inline は subject 直後(左寄り)のはず: right_col={col_right} inline_col={col_inline}"
);
std::fs::remove_dir_all(&dir).ok();
}
#[cfg(feature = "git")]
#[test]
fn e2e_ui_follow_view_file_shows_content_preview_not_diff() {
let dir = sandbox("ui_follow_view_file_cfg");
seed_repo(&dir); let mut cfg = Config::default();
cfg.ui.follow_view = "file".into();
let mut s = Sim::with_config(&canon(&dir), cfg);
s.key('F');
assert!(s.app.follow_enabled(), "F でフォロー ON");
let a = s.app.tab.root.join("a.rs");
std::fs::write(&a, "fn a() { let _x = 2; }\n").unwrap();
assert!(s.app.follow_note_change(&a));
s.app.follow_jump(&a);
s.draw();
assert!(
!s.app.is_git_diff_preview(),
"follow_view=\"file\" は diff でなく通常プレビューのはず(既定は diff — e2e_follow_opens_full_screen_diff参照)"
);
assert_eq!(s.app.tab.mode, Mode::Preview);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn e2e_ui_md_inline_html_off_keeps_raw_tags() {
let body = "H<sub>2</sub>O\n";
let mut cfg = Config::default();
cfg.ui.md_inline_html = false;
let (s, dir) = md_preview(cfg, "ui_inline_html_off", body);
s.see("H2O"); s.dont_see("H₂O");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn e2e_ui_md_details_config_forces_open_despite_missing_attribute() {
let dir = sandbox("ui_md_details_cfg");
std::fs::write(
dir.join("d.md"),
"<details>\n<summary>Sum</summary>\n\nBODYMARKER\n\n</details>\n",
)
.unwrap();
let root = canon(&dir);
let mut s_auto = Sim::new(&root);
s_auto.select("d.md");
s_auto.enter();
s_auto.dont_see("BODYMARKER");
let mut cfg = Config::default();
cfg.ui.md_details = "open".into();
let mut s_open = Sim::with_config(&root, cfg);
s_open.select("d.md");
s_open.enter();
s_open.see("BODYMARKER");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn e2e_ui_math_image_mode_lifts_expression_text_mode_keeps_it_inline() {
let dir = sandbox("ui_math_mode_cfg");
std::fs::write(dir.join("d.md"), "before $E=mc^2$ after\n").unwrap();
let root = canon(&dir);
let mut s_image = Sim::new(&root).with_picker(); s_image.select("d.md");
s_image.enter();
assert_eq!(
s_image.app.md_images().len(),
1,
"画像モードは math 用のプレースメントを1つ持つはず"
);
s_image.dont_see("E=mc^2");
let mut cfg = Config::default();
cfg.ui.math = "text".into();
let mut s_text = Sim::with_config(&root, cfg).with_picker();
s_text.select("d.md");
s_text.enter();
assert!(
s_text.app.md_images().is_empty(),
"text モードは画像プレースメント無しのはず"
);
s_text.see("$E=mc^2$");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn e2e_ui_math_color_changes_rendered_pixel_hue() {
let dir = sandbox("ui_math_color_cfg");
std::fs::write(dir.join("d.md"), "$E=mc^2$\n").unwrap();
let root = canon(&dir);
fn render_and_get_rgbs(cfg: Config, dir: &std::path::Path) -> Vec<(u8, u8, u8)> {
let mut s = Sim::with_config(dir, cfg).with_picker();
let (enc_tx, enc_worker_rx) = std::sync::mpsc::channel();
let (enc_res_tx, enc_res_rx) = std::sync::mpsc::channel();
s.app.attach_md_encoder(enc_tx);
let picker = ratatui_image::picker::Picker::halfblocks();
std::thread::spawn(move || crate::app::md_encode_worker(picker, enc_worker_rx, enc_res_tx));
s.select("d.md");
s.enter(); let res = enc_res_rx
.recv_timeout(std::time::Duration::from_secs(5))
.expect("encode worker returns a result");
assert!(s.app.apply_md_encode(res), "現世代の結果は適用されるはず");
s.draw(); let buf = s.term.backend().buffer();
let mut out = Vec::new();
for cell in buf.content().iter() {
if let Some(ratatui::style::Color::Rgb(r, g, b)) = cell.style().fg {
out.push((r, g, b));
}
}
out
}
let rgbs_default = render_and_get_rgbs(Config::default(), &root); assert!(
!rgbs_default.is_empty(),
"既定色でも実ピクセルが描かれるはず"
);
assert!(
rgbs_default
.iter()
.all(|&(r, g, b)| r.abs_diff(g) <= 2 && g.abs_diff(b) <= 2),
"既定(#d0d0d0)は無彩色(r≈g≈b)のはず: {rgbs_default:?}"
);
let mut cfg_red = Config::default();
cfg_red.ui.math_color = "#ff0000".into();
let rgbs_red = render_and_get_rgbs(cfg_red, &root);
assert!(!rgbs_red.is_empty(), "赤色設定でも実ピクセルが描かれるはず");
assert!(
rgbs_red.iter().any(|&(r, g, b)| r > 20 && g == 0 && b == 0),
"math_color=#ff0000 は赤チャンネルのみのはず: {rgbs_red:?}"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn e2e_ui_mermaid_image_mode_shows_caption_text_mode_does_not() {
let dir = sandbox("ui_mermaid_mode_cfg");
std::fs::write(
dir.join("d.md"),
"# Doc\n\n```mermaid\nflowchart TD\nA-->B\n```\n\nafter\n",
)
.unwrap();
let root = canon(&dir);
let mut s_image = Sim::new(&root).with_picker(); s_image.select("d.md");
s_image.enter();
s_image.see("◇ mermaid");
assert_eq!(s_image.app.md_images().len(), 1);
let mut cfg = Config::default();
cfg.ui.mermaid = "text".into();
let mut s_text = Sim::with_config(&root, cfg).with_picker();
s_text.select("d.md");
s_text.enter();
s_text.dont_see("◇ mermaid");
assert!(s_text.app.md_images().is_empty());
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn e2e_ui_mermaid_theme_changes_rendered_pixel_brightness() {
let dir = sandbox("ui_mermaid_theme_cfg");
std::fs::write(dir.join("d.md"), "```mermaid\nflowchart TD\nA-->B\n```\n").unwrap();
let root = canon(&dir);
fn render_and_get_fgs(cfg: Config, dir: &std::path::Path) -> Vec<(u8, u8, u8)> {
let mut s = Sim::with_config(dir, cfg).with_picker();
let (enc_tx, enc_worker_rx) = std::sync::mpsc::channel();
let (enc_res_tx, enc_res_rx) = std::sync::mpsc::channel();
s.app.attach_md_encoder(enc_tx);
let picker = ratatui_image::picker::Picker::halfblocks();
std::thread::spawn(move || crate::app::md_encode_worker(picker, enc_worker_rx, enc_res_tx));
s.select("d.md");
s.enter();
let res = enc_res_rx
.recv_timeout(std::time::Duration::from_secs(5))
.expect("encode worker returns a result");
assert!(s.app.apply_md_encode(res));
s.draw();
let buf = s.term.backend().buffer();
let mut out = Vec::new();
for cell in buf.content().iter() {
if let Some(ratatui::style::Color::Rgb(r, g, b)) = cell.style().fg {
out.push((r, g, b));
}
}
out
}
let mut cfg_dark = Config::default();
cfg_dark.ui.mermaid_theme = "dark".into();
cfg_dark.ui.mermaid_rows = 6; let fg_dark = render_and_get_fgs(cfg_dark, &root);
assert!(
!fg_dark.is_empty(),
"dark テーマでも実ピクセルが描かれるはず"
);
let mut cfg_neutral = Config::default();
cfg_neutral.ui.mermaid_theme = "neutral".into();
cfg_neutral.ui.mermaid_rows = 6;
let fg_neutral = render_and_get_fgs(cfg_neutral, &root);
assert!(
!fg_neutral.is_empty(),
"neutral テーマでも実ピクセルが描かれるはず"
);
let avg = |v: &[(u8, u8, u8)]| -> f64 {
v.iter()
.map(|&(r, g, b)| (r as f64 + g as f64 + b as f64) / 3.0)
.sum::<f64>()
/ v.len() as f64
};
let avg_dark = avg(&fg_dark);
let avg_neutral = avg(&fg_neutral);
assert!(
avg_dark < avg_neutral,
"dark は neutral より暗い(低RGB)はず: dark_avg={avg_dark:.1} neutral_avg={avg_neutral:.1}"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn e2e_ui_mermaid_rows_changes_reserved_diagram_height() {
let dir = sandbox("ui_mermaid_rows_cfg");
std::fs::write(
dir.join("d.md"),
"```mermaid\nflowchart TD\nA-->B-->C-->D-->E-->F\n```\n",
)
.unwrap();
let root = canon(&dir);
let mut s_default = Sim::new(&root).with_picker(); s_default.select("d.md");
s_default.enter();
let rows_default = s_default.app.md_images()[0].rows;
let mut cfg = Config::default();
cfg.ui.mermaid_rows = 4;
let mut s_small = Sim::with_config(&root, cfg).with_picker();
s_small.select("d.md");
s_small.enter();
let rows_small = s_small.app.md_images()[0].rows;
assert!(
rows_small < rows_default,
"mermaid_rows=4 は既定24より小さい高さになるはず: small={rows_small} default={rows_default}"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn e2e_ui_busy_indicator_shows_while_media_loads_hides_when_disabled() {
let dir = sandbox("ui_busy_indicator_cfg");
std::fs::copy("samples/sample.svg", dir.join("p.svg")).unwrap();
let root = canon(&dir);
let (tx_on, rx_on) = std::sync::mpsc::channel();
let mut s_on = Sim::new(&root).with_picker();
s_on.app.attach_media_loader(tx_on);
std::mem::forget(rx_on); s_on.select("p.svg");
s_on.enter();
assert!(s_on.app.is_media_loading());
s_on.see("loading media");
let (tx_off, rx_off) = std::sync::mpsc::channel();
let mut cfg = Config::default();
cfg.ui.busy_indicator = false;
let mut s_off = Sim::with_config(&root, cfg).with_picker();
s_off.app.attach_media_loader(tx_off);
std::mem::forget(rx_off);
s_off.select("p.svg");
s_off.enter();
assert!(s_off.app.is_media_loading());
s_off.dont_see("loading media");
std::fs::remove_dir_all(&dir).ok();
}
fn drawn_rgb_fgs(term: &Terminal<TestBackend>) -> Vec<(u8, u8, u8)> {
let buf = term.backend().buffer();
let mut out = Vec::new();
for cell in buf.content().iter() {
if let Some(ratatui::style::Color::Rgb(r, g, b)) = cell.style().fg {
out.push((r, g, b));
}
}
out
}
#[test]
fn e2e_ui_inline_local_image_decodes_and_encodes_through_real_worker_threads() {
let dir = sandbox("inline_local_image_real_workers");
image::DynamicImage::ImageRgb8(image::RgbImage::from_pixel(
300,
120,
image::Rgb([10, 210, 10]),
))
.save(dir.join("pic.png"))
.unwrap();
std::fs::write(
dir.join("d.md"),
"before\n\n\n\nafter\n",
)
.unwrap();
let root = canon(&dir);
let mut s = Sim::new(&root).with_media();
s.select("d.md");
s.enter();
let placements = s.app.md_images();
assert_eq!(
placements.len(),
1,
"サイズはファイルの寸法から即座に(デコード前でも)確定するはず"
);
let (url, cols, rows) = (
placements[0].url.clone(),
placements[0].cols,
placements[0].rows,
);
assert!(cols > 0 && rows > 0);
assert!(
s.app.md_image_proto(&url, cols, rows, 0, rows).is_none(),
"デコードが終わる前はまだ protocol が無いはず"
);
s.drain_md_images(); s.drain_md_encodes();
assert!(
s.app.md_image_proto(&url, cols, rows, 0, rows).is_some(),
"エンコードが終われば protocol が立つはず"
);
let greens: Vec<_> = drawn_rgb_fgs(&s.term)
.into_iter()
.filter(|&(r, g, b)| g > 150 && r < 60 && b < 60)
.collect();
assert!(
!greens.is_empty(),
"実ピクセル(緑)が描画バッファに届いているはず: {:?}",
drawn_rgb_fgs(&s.term)
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn e2e_ui_mermaid_fence_tab_enter_opens_correct_ordinal_fullscreen_q_returns() {
let dir = sandbox("mermaid_fence_real_async");
std::fs::write(
dir.join("d.md"),
"# Doc\n\n```mermaid\nflowchart TD\nA-->B\n```\n\nmiddle text\n\n```mermaid\nflowchart TD\nC-->D\n```\n\ntail\n",
)
.unwrap();
let root = canon(&dir);
let mut s = Sim::new(&root).with_media();
s.select("d.md");
s.enter(); assert!(
s.app.md_images().is_empty(),
"デコード前はまだ画像プレースメント無し"
);
assert_eq!(
s.app.focused_item(),
None,
"Loading中はまだ Tab で辿れるアイテムが無いはず"
);
s.drain_md_images();
s.drain_md_images();
let placements = s.app.md_images();
assert_eq!(
placements.len(),
2,
"両方の図が画像プレースメントを持つはず"
);
s.tab();
assert_eq!(s.app.focused_item(), Some(0));
let scroll_before = s.app.tab.preview_scroll;
let focus_before = s.app.focused_item();
s.enter();
assert!(
matches!(
s.app.tab.preview_kind,
Some(crate::preview::PreviewKind::MermaidFence(0))
),
"1つ目のTabアイテムは序数0の図を開くはず: {:?}",
s.app.tab.preview_kind
);
s.drain_media(); assert!(
s.app
.prepare_image(ratatui::layout::Rect::new(0, 0, 80, 20))
.is_some(),
"フルスクリーン描画用のラスタが実際にデコードされているはず"
);
s.key('q');
assert!(
matches!(
s.app.tab.preview_kind,
Some(crate::preview::PreviewKind::Markdown(_))
),
"q でMarkdownへ戻るはず: {:?}",
s.app.tab.preview_kind
);
assert_eq!(
s.app.tab.preview_scroll, scroll_before,
"スクロール位置が復元されるはず"
);
assert_eq!(
s.app.focused_item(),
focus_before,
"フォーカスが復元されるはず"
);
s.tab();
assert_eq!(s.app.focused_item(), Some(1));
s.enter();
assert!(
matches!(
s.app.tab.preview_kind,
Some(crate::preview::PreviewKind::MermaidFence(1))
),
"2つ目のTabアイテムは序数1の図を開くはず: {:?}",
s.app.tab.preview_kind
);
s.drain_media();
assert!(s
.app
.prepare_image(ratatui::layout::Rect::new(0, 0, 80, 20))
.is_some());
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn e2e_ui_math_expression_decodes_through_real_worker_and_reserves_image_row() {
let dir = sandbox("math_real_worker");
std::fs::write(dir.join("d.md"), "before $E=mc^2$ after\n").unwrap();
let root = canon(&dir);
let mut s = Sim::new(&root).with_media();
s.select("d.md");
s.enter();
assert!(s.app.md_images().is_empty());
s.dont_see("E=mc^2");
s.see("loading");
s.drain_md_images(); let placements = s.app.md_images();
assert_eq!(
placements.len(),
1,
"デコード完了後に画像プレースメントが立つはず"
);
let (url, cols, rows) = (
placements[0].url.clone(),
placements[0].cols,
placements[0].rows,
);
assert!(
crate::preview::markdown::is_math_url(&url),
"合成キー math:// が使われているはず: {url}"
);
s.drain_md_encodes();
assert!(
s.app.md_image_proto(&url, cols, rows, 0, rows).is_some(),
"エンコード完了後は protocol が立つはず"
);
let visible_ink: Vec<_> = drawn_rgb_fgs(&s.term)
.into_iter()
.filter(|&(r, g, b)| r > 20 || g > 20 || b > 20)
.collect();
assert!(
!visible_ink.is_empty(),
"純黒(不可視)でない実インクが描かれるはず: {:?}",
drawn_rgb_fgs(&s.term)
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn e2e_ui_mermaid_and_math_render_failure_degrades_safely_to_raw_source() {
let dir = sandbox("mermaid_math_failure_real_worker");
std::fs::write(
dir.join("d.md"),
"```mermaid\ndefinitely not a diagram !!!\n```\n\n$\\frac{$\n",
)
.unwrap();
let root = canon(&dir);
let mut s = Sim::new(&root).with_media();
s.select("d.md");
s.enter();
s.see("loading");
s.drain_md_images();
s.drain_md_images();
assert!(
s.app.md_images().is_empty(),
"失敗した図/式は画像プレースメントを持たないはず"
);
s.dont_see("loading…");
s.see("definitely not a diagram");
s.see("\\frac{");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn e2e_ui_inline_gif_animates_through_real_decode_worker() {
let dir = sandbox("inline_gif_real_worker");
{
use image::codecs::gif::GifEncoder;
use image::{Delay, Frame, Rgba, RgbaImage};
let out = std::fs::File::create(dir.join("anim.gif")).unwrap();
let mut enc = GifEncoder::new(out);
let frames = [Rgba([220, 20, 20, 255]), Rgba([20, 20, 220, 255])] .into_iter()
.map(|color| {
Frame::from_parts(
RgbaImage::from_pixel(120, 80, color),
0,
0,
Delay::from_numer_denom_ms(100, 1),
)
});
enc.encode_frames(frames).unwrap();
}
std::fs::write(dir.join("d.md"), "\n").unwrap();
let root = canon(&dir);
let mut s = Sim::new(&root).with_media();
s.select("d.md");
s.enter();
let placements = s.app.md_images();
assert_eq!(placements.len(), 1);
let (url, cols, rows) = (
placements[0].url.clone(),
placements[0].cols,
placements[0].rows,
);
s.drain_md_images(); assert!(
s.app.md_gif_poll_timeout().is_some(),
"アニメGIFは巡回のポーリング待ちを要求するはず"
);
s.drain_md_encodes();
assert!(s.app.md_image_proto(&url, cols, rows, 0, rows).is_some());
let reds: Vec<_> = drawn_rgb_fgs(&s.term)
.into_iter()
.filter(|&(r, g, b)| r > 150 && g < 60 && b < 60)
.collect();
assert!(
!reds.is_empty(),
"1コマ目(赤)の実ピクセルが描かれるはず: {:?}",
drawn_rgb_fgs(&s.term)
);
assert!(
!s.app.advance_md_gifs_if_due(),
"最初の呼び出しはタイマーを起動するだけで、まだ進めないはず"
);
std::thread::sleep(std::time::Duration::from_millis(150)); assert!(
s.app.advance_md_gifs_if_due(),
"遅延経過後は次フレームへ進むはず"
);
s.draw(); s.drain_md_encodes();
let blues: Vec<_> = drawn_rgb_fgs(&s.term)
.into_iter()
.filter(|&(r, g, b)| b > 150 && r < 60 && g < 60)
.collect();
assert!(
!blues.is_empty(),
"2コマ目(青)へ切り替わった実ピクセルが描かれるはず: {:?}",
drawn_rgb_fgs(&s.term)
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn e2e_ui_remote_image_fetch_failure_without_network_degrades_to_placeholder() {
let dir = sandbox("remote_image_no_network");
std::fs::write(
dir.join("d.md"),
"\n",
)
.unwrap();
let root = canon(&dir);
let mut s = Sim::new(&root).with_media();
s.select("d.md");
s.enter();
s.see("loading");
assert!(s.app.md_images().is_empty());
s.drain_remote();
s.dont_see("loading");
s.see("http://127.0.0.1:1/nope.png"); assert!(
s.app.md_images().is_empty(),
"失敗した画像はプレースメントを持たないはず"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn e2e_yc_copies_code_in_a_document_with_a_multiline_footnote() {
let payload = "/groundwork <仕様書> <NotionURL> <資料dir>";
let doc = format!(
"Intro[^1] text.\n\n```sh\n{payload}\n```\n\n[^1]: [label](\n https://example.com/x)\n"
);
let (mut s, dir) = md_preview(cfg_code_bg_none(), "fnote", &doc);
let mut found = false;
for _ in 0..8 {
s.tab();
if s.app.md_focused_code() {
found = true;
break;
}
}
assert!(found, "コードブロックにフォーカスできる\n{}", s.screen());
assert_eq!(
s.app.focused_code_text().as_deref(),
Some(payload),
"`y c` が拒否されず、ファイルどおりの中身を返す"
);
s.key('y');
s.see("code block");
s.key('c');
assert!(
!s.app
.flash
.as_deref()
.unwrap_or_default()
.contains("couldn't"),
"コピーが拒否されていない(flash={:?})",
s.app.flash
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn e2e_multiline_footnote_leaves_no_phantom_code_block() {
let doc = "Intro[^1] text.\n\n[^1]: [label](\n https://example.com/x)\n";
let (s, dir) = md_preview(cfg_code_bg_none(), "phantom", doc);
let screen = s.screen();
assert!(
!screen.contains('▎'),
"本文に幽霊コードブロックが出ていない\n{screen}"
);
assert!(
screen.contains("label"),
"脚注の節に定義が入っている\n{screen}"
);
assert_eq!(
s.app.md_link_targets(),
vec!["https://example.com/x".to_string()],
"折り返された URL まで含めて1本のリンクとして復元される\n{screen}"
);
let mut s = s;
for _ in 0..6 {
s.tab();
assert!(
!s.app.md_focused_code(),
"コードブロックとして巡回対象になっていない\n{}",
s.screen()
);
}
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn e2e_yc_copies_when_preprocessing_creates_the_code_block() {
let doc = "before\n\n<del></del>\n\nafter\n";
let (mut s, dir) = md_preview(cfg_code_bg_none(), "delfence", doc);
let mut found = false;
for _ in 0..6 {
s.tab();
if s.app.md_focused_code() {
found = true;
break;
}
}
assert!(
found,
"前処理が作ったコードブロックにフォーカスできる\n{}",
s.screen()
);
assert!(
s.app.focused_code_text().is_some(),
"`y c` が拒否されない(生ファイルを読むと 0 個に見えて全拒否になる)"
);
std::fs::remove_dir_all(&dir).ok();
}