use super::*;
use crate::config::Config;
fn item_target(it: &MdItem) -> &str {
match &it.kind {
MdItemKind::Link { target } => target,
MdItemKind::Task { .. } => panic!("expected a link item"),
MdItemKind::CodeBlock => panic!("expected a link item"),
}
}
#[test]
fn path_styles_format_as_expected() {
let open = std::env::temp_dir().join("konoma_app_test_open");
std::fs::create_dir_all(&open).unwrap();
let mut app = App::new(open.clone(), Config::default()).unwrap();
let file = open.join("src").join("main.rs");
app.path_style = PathStyle::Full;
assert_eq!(app.format_path(&file), file.display().to_string());
app.path_style = PathStyle::Relative;
assert_eq!(
app.format_path(&file),
"konoma_app_test_open/src/main.rs".to_string()
);
if let Some(home) = std::env::var_os("HOME") {
let p = PathBuf::from(home).join("work").join("konoma");
app.path_style = PathStyle::Home;
assert_eq!(app.format_path(&p), "~/work/konoma".to_string());
}
std::fs::remove_dir_all(&open).ok();
}
#[test]
fn format_path_relative_uses_dotdot_outside_open_dir() {
let work = std::env::temp_dir().join("konoma_relpath_test_work");
let _ = std::fs::remove_dir_all(&work);
let a = work.join("A");
std::fs::create_dir_all(&a).unwrap();
std::fs::create_dir_all(work.join("B")).unwrap();
let mut app = App::new(a.clone(), Config::default()).unwrap();
app.path_style = PathStyle::Relative;
assert_eq!(
app.format_path(&a.join("x.rs")),
"A/x.rs",
"配下は起動dir名を先頭に"
);
assert_eq!(
app.format_path(&work.join("B").join("aaa.md")),
"../B/aaa.md",
"兄弟ディレクトリは ../ を付ける"
);
assert_eq!(app.format_path(&work), "..", "上位は ..");
std::fs::remove_dir_all(&work).ok();
}
#[test]
fn dialog_create_rename_and_delete_is_gated() {
let dir = std::env::temp_dir().join("konoma_dialog_ops_test");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("a.txt"), b"x").unwrap();
let mut app = App::new(dir.clone(), Config::default()).unwrap();
app.rebuild_tree().unwrap();
assert_eq!(app.entries[app.selected].path, dir.join("a.txt"));
app.start_create();
assert!(
app.is_dialog() && !app.dialog_is_confirm(),
"入力ダイアログ"
);
for c in "new.txt".chars() {
app.dialog_input_push(c);
}
app.dialog_submit().unwrap();
assert!(!app.is_dialog(), "確定でダイアログは閉じる");
assert!(dir.join("new.txt").is_file(), "ファイルが作成される");
assert_eq!(
app.entries[app.selected].path,
dir.join("new.txt"),
"作成物が選択される"
);
app.start_create();
for c in "sub/".chars() {
app.dialog_input_push(c);
}
app.dialog_submit().unwrap();
assert!(dir.join("sub").is_dir(), "末尾 / でフォルダ作成");
let ai = app
.entries
.iter()
.position(|e| e.path == dir.join("a.txt"))
.unwrap();
app.selected = ai;
app.start_rename();
assert_eq!(
app.dialog_view().map(|v| v.2.to_string()),
Some("a.txt".to_string()),
"現在名がプリフィル"
);
for _ in 0.."a.txt".len() {
app.dialog_input_backspace();
}
for c in "renamed.txt".chars() {
app.dialog_input_push(c);
}
app.dialog_submit().unwrap();
assert!(
dir.join("renamed.txt").is_file() && !dir.join("a.txt").exists(),
"リネームが反映される"
);
let ri = app
.entries
.iter()
.position(|e| e.path == dir.join("renamed.txt"))
.unwrap();
app.selected = ri;
app.start_delete();
assert!(
app.is_dialog() && app.dialog_is_confirm(),
"削除は y/n 確認"
);
app.dialog_confirm(false).unwrap(); assert!(!app.is_dialog(), "確認後ダイアログは閉じる");
assert!(
dir.join("renamed.txt").is_file(),
"キャンセルでファイルは残る"
);
app.start_create();
for c in "renamed.txt".chars() {
app.dialog_input_push(c);
}
app.dialog_submit().unwrap();
assert!(!app.is_dialog());
assert!(app.flash.is_some(), "衝突は失敗として通知される");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn dialog_input_cursor_moves_and_edits_midstring() {
let dir = std::env::temp_dir().join("konoma_dialog_cursor_test");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("a.txt"), b"x").unwrap();
let mut app = App::new(dir.clone(), Config::default()).unwrap();
app.rebuild_tree().unwrap();
let buf = |app: &App| app.dialog_view().map(|v| v.2.to_string()).unwrap();
app.start_create();
app.dialog_input_push('a');
app.dialog_input_push('c');
app.dialog_cursor_left();
app.dialog_input_push('b');
assert_eq!(buf(&app), "abc", "カーソル位置に中間挿入");
app.dialog_input_backspace();
assert_eq!(buf(&app), "ac");
app.dialog_cursor_home();
app.dialog_input_delete();
assert_eq!(buf(&app), "c");
app.dialog_cancel();
app.start_create();
app.dialog_input_push('あ');
app.dialog_cursor_left();
app.dialog_input_push('x');
assert_eq!(buf(&app), "xあ", "マルチバイトでも中間挿入できる");
app.dialog_cancel();
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn single_toggle_picks_scattered_items() {
let dir = std::env::temp_dir().join("konoma_single_toggle_test");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
for n in ["a.txt", "b.txt", "c.txt"] {
std::fs::write(dir.join(n), b"x").unwrap();
}
let mut app = App::new(dir.clone(), Config::default()).unwrap();
app.rebuild_tree().unwrap();
assert!(!app.has_selection());
app.selected = 0;
app.toggle_select();
assert!(app.is_selected(&dir.join("a.txt")));
assert_eq!(app.selected, 1, "V は下へ進む");
app.selected = 2;
app.toggle_select();
assert_eq!(app.marked_count(), 2);
assert!(app.is_selected(&dir.join("c.txt")) && !app.is_selected(&dir.join("b.txt")));
app.selected = 0;
app.toggle_select();
assert!(!app.is_selected(&dir.join("a.txt")));
assert_eq!(app.marked_count(), 1);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn visual_range_selects_and_batch_deletes() {
let dir = std::env::temp_dir().join("konoma_visual_test");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
for n in ["a.txt", "b.txt", "c.txt", "d.txt"] {
std::fs::write(dir.join(n), b"x").unwrap();
}
let mut app = App::new(dir.clone(), Config::default()).unwrap();
app.rebuild_tree().unwrap();
assert_eq!(app.entries.len(), 4);
app.selected = 0;
app.enter_visual();
assert!(app.is_visual());
app.tree_next(); app.tree_next(); assert!(app.is_in_visual_range(0) && app.is_in_visual_range(2));
assert!(!app.is_in_visual_range(3), "d は範囲外");
assert_eq!(app.marked_count(), 3, "範囲(a..c)で3件");
app.exit_visual_commit();
assert!(!app.is_visual());
assert_eq!(app.marked_count(), 3);
assert!(app.is_selected(&dir.join("a.txt")) && app.is_selected(&dir.join("c.txt")));
app.start_delete();
assert!(app.dialog_is_confirm());
app.dialog_delete_permanent().unwrap();
assert!(dir.join("d.txt").exists() && !dir.join("a.txt").exists());
assert!(!app.has_selection(), "一括操作後は選択クリア");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn visual_scope_a_selects_same_dir_level_only() {
let dir = std::env::temp_dir().join("konoma_visual_scope_test");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("sub")).unwrap();
std::fs::write(dir.join("a.txt"), b"x").unwrap();
std::fs::write(dir.join("b.txt"), b"x").unwrap();
std::fs::write(dir.join("sub").join("inner.txt"), b"x").unwrap();
let mut app = App::new(dir.clone(), Config::default()).unwrap();
app.rebuild_tree().unwrap();
let sub_idx = app
.entries
.iter()
.position(|e| e.path == dir.join("sub"))
.unwrap();
app.selected = sub_idx;
app.tree_activate().unwrap(); app.rebuild_tree().unwrap();
let a_idx = app
.entries
.iter()
.position(|e| e.path == dir.join("a.txt"))
.unwrap();
app.selected = a_idx;
app.enter_visual();
app.visual_select_scope(false); assert!(app.is_selected(&dir.join("a.txt")));
assert!(app.is_selected(&dir.join("b.txt")));
assert!(app.is_selected(&dir.join("sub")));
assert!(
!app.is_selected(&dir.join("sub").join("inner.txt")),
"入れ子(別階層)は a では入らない"
);
assert!(!app.is_visual(), "スコープ選択後はビジュアルを抜ける");
app.clear_selection();
app.selected = a_idx;
app.enter_visual();
app.visual_select_scope(true);
assert!(
app.is_selected(&dir.join("sub").join("inner.txt")),
"A は入れ子も"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn batch_rename_numbers_in_sort_order_and_keeps_ext() {
let dir = std::env::temp_dir().join("konoma_batchrename_app_test");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("zebra.txt"), b"z").unwrap();
std::fs::write(dir.join("apple.md"), b"a").unwrap();
std::fs::write(dir.join("mango.rs"), b"m").unwrap();
let mut app = App::new(dir.clone(), Config::default()).unwrap();
app.rebuild_tree().unwrap();
app.visual_select_scope(true);
assert_eq!(app.marked_count(), 3);
app.start_batch_rename();
assert!(app.is_dialog() && !app.dialog_is_confirm() && !app.dialog_is_preview());
for c in "img_{n}".chars() {
app.dialog_input_push(c);
}
app.dialog_submit().unwrap();
assert!(app.dialog_is_preview(), "確定でプレビューへ遷移");
let (_, pairs, _) = app.dialog_preview_view().unwrap();
assert_eq!(pairs[0], "apple.md → img_1.md");
assert_eq!(pairs[1], "mango.rs → img_2.rs");
assert_eq!(pairs[2], "zebra.txt → img_3.txt");
app.dialog_preview_apply().unwrap();
assert!(!app.is_dialog());
assert!(dir.join("img_1.md").is_file());
assert!(dir.join("img_2.rs").is_file());
assert!(dir.join("img_3.txt").is_file());
assert!(!dir.join("apple.md").exists());
assert!(!app.has_selection(), "適用後は選択クリア");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn batch_rename_collision_reopens_input() {
let dir = std::env::temp_dir().join("konoma_batchrename_collision_test");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("a.txt"), b"a").unwrap();
std::fs::write(dir.join("b.txt"), b"b").unwrap();
let mut app = App::new(dir.clone(), Config::default()).unwrap();
app.rebuild_tree().unwrap();
app.visual_select_scope(true);
app.start_batch_rename();
for c in "dup".chars() {
app.dialog_input_push(c);
}
app.dialog_submit().unwrap();
assert!(!app.dialog_is_preview(), "衝突時はプレビューへ行かない");
assert!(
app.is_dialog() && !app.dialog_is_confirm(),
"入力ダイアログのまま"
);
assert_eq!(
app.dialog_view().map(|v| v.2.to_string()),
Some("dup".to_string()),
"テンプレは保持される"
);
assert!(app.flash.is_some(), "衝突は失敗として通知");
assert!(dir.join("a.txt").exists() && dir.join("b.txt").exists());
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn parse_dropped_paths_unescapes_splits_and_filters() {
let tmp = std::env::temp_dir().join("konoma_parse_drop_test");
let _ = std::fs::remove_dir_all(&tmp);
std::fs::create_dir_all(&tmp).unwrap();
let a = tmp.join("a b.txt"); std::fs::write(&a, b"x").unwrap();
let c = tmp.join("c.txt");
std::fs::write(&c, b"y").unwrap();
let text = format!(
"{} {}",
a.to_string_lossy().replace(' ', "\\ "),
c.to_string_lossy()
);
let got = parse_dropped_paths(&text);
assert_eq!(got.len(), 2, "実在2パスを解く: {got:?}");
assert!(got.contains(&a) && got.contains(&c));
assert!(parse_dropped_paths("just some pasted text, not a path").is_empty());
std::fs::remove_dir_all(&tmp).ok();
}
#[test]
fn drop_paste_opens_dialog_then_copy_and_move() {
let dir = std::env::temp_dir().join("konoma_drop_flow_test");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("sub")).unwrap();
let ext = std::env::temp_dir().join("konoma_drop_src");
let _ = std::fs::remove_dir_all(&ext);
std::fs::create_dir_all(&ext).unwrap();
let src1 = ext.join("drop one.txt");
let src2 = ext.join("drop two.txt");
std::fs::write(&src1, b"a").unwrap();
std::fs::write(&src2, b"b").unwrap();
let mut app = App::new(dir.clone(), Config::default()).unwrap();
app.rebuild_tree().unwrap();
assert!(app.entries[app.selected].is_dir, "カーソルが sub(dir)");
app.handle_paste(src1.to_string_lossy().replace(' ', "\\ "));
assert!(
app.is_dialog() && app.confirm_is_drop(),
"ドロップ確認が開く"
);
app.drop_apply(false).unwrap();
assert!(!app.is_dialog(), "実行でダイアログが閉じる");
assert!(
dir.join("sub").join("drop one.txt").is_file(),
"sub へコピー"
);
assert!(src1.is_file(), "コピーは元を残す");
app.handle_paste(src2.to_string_lossy().replace(' ', "\\ "));
assert!(app.confirm_is_drop());
app.drop_apply(true).unwrap();
assert!(dir.join("sub").join("drop two.txt").is_file(), "sub へ移動");
assert!(!src2.exists(), "移動は元を消す");
app.handle_paste("not a real path".to_string());
assert!(!app.is_dialog(), "テキストペーストでは開かない");
std::fs::remove_dir_all(&dir).ok();
std::fs::remove_dir_all(&ext).ok();
}
#[test]
fn paste_into_filter_inserts_text_not_drop() {
let dir = std::env::temp_dir().join("konoma_paste_filter_test");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("a.txt"), b"x").unwrap();
let mut app = App::new(dir.clone(), Config::default()).unwrap();
app.rebuild_tree().unwrap();
app.start_filter();
assert!(app.is_filtering());
app.handle_paste("abc".to_string());
assert_eq!(
app.filter_query(),
Some("abc"),
"ペーストが絞り込みへ挿入される"
);
assert!(!app.is_dialog(), "ドロップダイアログは開かない");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn reanchor_root_sets_current_position_as_anchor() {
let base = std::env::temp_dir().join("konoma_reanchor_test");
let _ = std::fs::remove_dir_all(&base);
std::fs::create_dir_all(base.join("sub")).unwrap();
std::fs::write(base.join("sub").join("f.txt"), b"x").unwrap();
let base_c = base.canonicalize().unwrap();
let mut app = App::new(base_c.clone(), Config::default()).unwrap();
app.rebuild_tree().unwrap();
assert_eq!(app.open_dir, base_c);
app.tree_descend().unwrap();
assert_eq!(app.root, base_c.join("sub"), "root が sub へ");
assert_eq!(app.open_dir, base_c, "open_dir はまだ base(launch)");
let f = base_c.join("sub").join("f.txt");
assert!(
app.format_path(&f).contains("sub"),
"再アンカー前は sub/ 込み表示: {}",
app.format_path(&f)
);
app.reanchor_root();
assert!(!app.is_dialog(), "ダイアログは出ない(入力なし)");
assert_eq!(
app.open_dir,
base_c.join("sub"),
"open_dir が sub へ再アンカー"
);
assert_eq!(app.root, base_c.join("sub"), "root は変わらない");
assert_eq!(app.format_path(&f), "sub/f.txt");
app.reanchor_root();
assert_eq!(app.open_dir, base_c.join("sub"), "変化なし");
std::fs::remove_dir_all(&base).ok();
}
#[cfg(feature = "git")]
#[test]
fn gitignored_entries_detected_and_dimmed() {
use ratatui::backend::TestBackend;
use ratatui::style::Modifier;
use ratatui::Terminal;
let dir = std::env::temp_dir().join("konoma_gitignore_test");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
git2::Repository::init(&dir).unwrap();
std::fs::write(dir.join(".gitignore"), b"ignored.log\nnode_modules/\n").unwrap();
std::fs::write(dir.join("tracked.txt"), b"x").unwrap();
std::fs::write(dir.join("ignored.log"), b"y").unwrap();
std::fs::create_dir_all(dir.join("node_modules").join("pkg")).unwrap();
std::fs::write(dir.join("node_modules").join("pkg").join("i.js"), b"z").unwrap();
let canon = dir.canonicalize().unwrap();
let mut app = App::new(canon.clone(), Config::default()).unwrap();
app.rebuild_tree().unwrap();
app.refresh_git_if_needed();
assert!(app.is_ignored(&canon.join("ignored.log")), "ignored.log");
assert!(app.is_ignored(&canon.join("node_modules")), "node_modules");
assert!(
app.is_ignored(&canon.join("node_modules").join("pkg").join("i.js")),
"node_modules 配下も祖先一致で ignored"
);
assert!(!app.is_ignored(&canon.join("tracked.txt")), "tracked");
assert!(
!app.is_ignored(&canon.join(".gitignore")),
".gitignore 自体"
);
let dim_in_row = |app: &mut App, needle: &str| -> Option<bool> {
let mut term = Terminal::new(TestBackend::new(50, 12)).unwrap();
term.draw(|f| crate::ui::render(f, app)).unwrap();
let buf = term.backend().buffer();
for y in 0..12u16 {
let row: String = (0..50)
.map(|x| buf.cell((x, y)).unwrap().symbol().to_string())
.collect();
if row.contains(needle) {
let any_dim =
(0..50).any(|x| buf.cell((x, y)).unwrap().modifier.contains(Modifier::DIM));
return Some(any_dim);
}
}
None
};
assert_eq!(
dim_in_row(&mut app, "ignored.log"),
Some(true),
"ignored は暗い"
);
assert_eq!(
dim_in_row(&mut app, "tracked.txt"),
Some(false),
"tracked は暗くない"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn diff_layout_parse_and_resolve() {
assert_eq!(DiffLayout::parse("unified"), DiffLayout::Unified);
assert_eq!(DiffLayout::parse("vertical"), DiffLayout::Unified);
assert_eq!(DiffLayout::parse("split"), DiffLayout::Split);
assert_eq!(DiffLayout::parse("side-by-side"), DiffLayout::Split);
assert_eq!(DiffLayout::parse("Horizontal"), DiffLayout::Split);
assert_eq!(DiffLayout::parse("auto"), DiffLayout::Auto);
assert_eq!(DiffLayout::parse("nonsense"), DiffLayout::Unified);
assert!(!DiffLayout::Unified.is_split(999), "縦は常に false");
assert!(DiffLayout::Split.is_split(10), "横は常に true");
assert!(DiffLayout::Auto.is_split(90), "Auto: 広いと横");
assert!(!DiffLayout::Auto.is_split(89), "Auto: 狭いと縦");
assert_eq!(DiffLayout::Unified.next(), DiffLayout::Split);
assert_eq!(DiffLayout::Split.next(), DiffLayout::Auto);
assert_eq!(DiffLayout::Auto.next(), DiffLayout::Unified);
}
#[cfg(feature = "git")]
#[test]
fn diff_from_tree_and_worktree_detail_and_cycle() {
let dir = std::env::temp_dir().join("konoma_diff_entrypoints");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
init_git_repo(&dir);
let git = |args: &[&str]| {
std::process::Command::new("git")
.current_dir(&dir)
.args(args)
.output()
.unwrap();
};
std::fs::write(dir.join("a.txt"), b"v1\n").unwrap();
git(&["add", "-A"]);
git(&["commit", "-m", "init"]);
std::fs::write(dir.join("a.txt"), b"v2\n").unwrap(); std::fs::write(dir.join("b.txt"), b"new\n").unwrap();
let canon = dir.canonicalize().unwrap();
let mut app = App::new(canon.clone(), Config::default()).unwrap();
app.rebuild_tree().unwrap();
app.refresh_git_if_needed();
let ai = app
.entries
.iter()
.position(|e| e.path == canon.join("a.txt"))
.expect("a.txt がツリーに無い");
app.selected = ai;
app.tree_open_git_diff();
assert!(
app.is_git_diff_preview(),
"ツリーから diff プレビューが開く"
);
assert!(!app.git_diff_lines().is_empty(), "diff 行がある");
app.close_git_diff();
app.open_git_view();
app.open_worktree_detail();
assert!(app.is_git_detail(), "全変更 diff(詳細)が開く");
assert_eq!(
app.git_detail_title(),
Some("Uncommitted changes"),
"タイトル上書き"
);
let headers = app
.git_detail_lines()
.iter()
.filter(|l| {
matches!(l.kind, crate::git::DiffLineKind::Context)
&& l.old_no.is_none()
&& l.new_no.is_none()
})
.count();
assert!(
headers >= 2,
"全変更 diff が複数ファイル: headers={headers}"
);
assert!(!app.diff_is_split(200), "初期は unified(既定設定)");
app.cycle_diff_layout();
assert!(app.diff_is_split(200), "→ split");
app.cycle_diff_layout();
assert!(
app.diff_is_split(200) && !app.diff_is_split(40),
"→ auto(幅で縦横)"
);
app.cycle_diff_layout();
assert!(!app.diff_is_split(200), "→ unified に戻る");
std::fs::remove_dir_all(&dir).ok();
}
#[cfg(feature = "git")]
#[test]
fn diff_horizontal_scroll_reveals_long_line() {
use ratatui::backend::TestBackend;
use ratatui::Terminal;
let dir = std::env::temp_dir().join("konoma_diff_hscroll");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
init_git_repo(&dir);
let git = |args: &[&str]| {
std::process::Command::new("git")
.current_dir(&dir)
.args(args)
.output()
.unwrap();
};
std::fs::write(dir.join("a.txt"), b"short\n").unwrap();
git(&["add", "-A"]);
git(&["commit", "-m", "init"]);
let long = format!("START{}END\n", "x".repeat(200));
std::fs::write(dir.join("a.txt"), long.as_bytes()).unwrap();
let canon = dir.canonicalize().unwrap();
let mut app = App::new(canon.clone(), Config::default()).unwrap();
app.rebuild_tree().unwrap();
app.refresh_git_if_needed();
let ai = app
.entries
.iter()
.position(|e| e.path == canon.join("a.txt"))
.unwrap();
app.selected = ai;
app.tree_open_git_diff();
assert!(app.is_git_diff_preview());
let dump = |app: &mut App| -> String {
let mut term = Terminal::new(TestBackend::new(40, 10)).unwrap();
term.draw(|f| crate::ui::render(f, app)).unwrap();
let buf = term.backend().buffer();
buf.content().iter().map(|c| c.symbol()).collect()
};
let s0 = dump(&mut app);
assert!(s0.contains("START"), "行頭に START");
assert!(!s0.contains("END"), "行頭で END は画面外");
app.preview_hscroll_end();
let se = dump(&mut app);
assert!(se.contains("END"), "$ で行末(END)が見える");
app.preview_hscroll_home();
let sh = dump(&mut app);
assert!(sh.contains("START") && !sh.contains("END"), "0 で行頭へ");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn copy_cut_paste_flow() {
let dir = std::env::temp_dir().join("konoma_clipboard_test");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("dst")).unwrap();
std::fs::write(dir.join("a.txt"), b"A").unwrap();
std::fs::write(dir.join("b.txt"), b"B").unwrap();
let mut app = App::new(dir.clone(), Config::default()).unwrap();
app.rebuild_tree().unwrap();
let idx =
|app: &App, p: &std::path::Path| app.entries.iter().position(|e| e.path == p).unwrap();
app.selected = idx(&app, &dir.join("a.txt"));
app.toggle_select();
app.copy_selection();
assert!(app.clipboard_label().is_some(), "コピーで積まれる");
assert!(!app.has_selection(), "コピーで選択クリア");
app.selected = idx(&app, &dir.join("dst"));
app.paste().unwrap();
assert!(dir.join("dst").join("a.txt").is_file(), "コピー先に出来る");
assert!(dir.join("a.txt").is_file(), "コピー元は残る");
assert!(app.clipboard_label().is_some(), "コピーは貼っても消えない");
app.selected = idx(&app, &dir.join("b.txt"));
app.toggle_select();
app.cut_selection();
app.selected = idx(&app, &dir.join("dst"));
app.paste().unwrap();
assert!(dir.join("dst").join("b.txt").is_file(), "カット先に出来る");
assert!(!dir.join("b.txt").exists(), "カット元は消える(移動)");
assert!(app.clipboard_label().is_none(), "カットは貼ると消費される");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn dialog_delete_permanent_removes_immediately() {
let dir = std::env::temp_dir().join("konoma_dialog_perm_test");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("gone.txt"), b"x").unwrap();
let mut app = App::new(dir.clone(), Config::default()).unwrap();
app.rebuild_tree().unwrap();
let i = app
.entries
.iter()
.position(|e| e.path == dir.join("gone.txt"))
.unwrap();
app.selected = i;
app.start_delete();
assert!(
app.dialog_is_confirm() && app.dialog_allow_permanent(),
"削除確認は完全削除(!)を許可する"
);
app.dialog_delete_permanent().unwrap();
assert!(!app.is_dialog(), "完全削除でダイアログは閉じる");
assert!(
!dir.join("gone.txt").exists(),
"完全削除でファイルが消える(ゴミ箱を経由しない)"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn tree_page_clamps_within_bounds() {
let dir = std::env::temp_dir().join("konoma_tree_page_test");
std::fs::create_dir_all(&dir).unwrap();
let mut app = App::new(dir.clone(), Config::default()).unwrap();
app.entries = (0..100)
.map(|i| Entry {
path: dir.join(format!("f{i}")),
is_dir: false,
depth: 0,
expanded: false,
})
.collect();
app.tree_viewport = 20;
app.selected = 0;
app.tree_page(1); assert_eq!(app.selected, 19);
app.tree_half_page(1); assert_eq!(app.selected, 29);
app.tree_last();
assert_eq!(app.selected, 99);
app.tree_page(1);
assert_eq!(app.selected, 99);
app.tree_first();
app.tree_page(-1);
assert_eq!(app.selected, 0);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn clamp_cursor_no_overflow_at_extremes() {
assert_eq!(clamp_cursor(3, i32::MAX, 10), 9, "末尾へクランプ");
assert_eq!(clamp_cursor(3, i32::MIN, 10), 0, "先頭へクランプ");
assert_eq!(clamp_cursor(0, i32::MAX, 1), 0, "1件は 0 のまま");
assert_eq!(clamp_cursor(5, -1, 10), 4, "通常の1行戻り");
assert_eq!(clamp_cursor(5, 1, 10), 6, "通常の1行進み");
assert_eq!(clamp_cursor(7, i32::MAX, 0), 0, "len==0 は常に 0");
assert_eq!(clamp_cursor(7, i32::MIN, 0), 0, "len==0 は常に 0");
}
#[test]
fn tree_move_extremes_no_panic() {
let dir = std::env::temp_dir().join("konoma_tree_move_extremes_test");
std::fs::create_dir_all(&dir).unwrap();
let mut app = App::new(dir.clone(), Config::default()).unwrap();
app.entries = (0..50)
.map(|i| Entry {
path: dir.join(format!("f{i}")),
is_dir: false,
depth: 0,
expanded: false,
})
.collect();
app.selected = 10;
app.tree_move(i32::MAX);
assert_eq!(app.selected, 49, "末尾へ");
app.tree_move(i32::MIN);
assert_eq!(app.selected, 0, "先頭へ");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn git_branch_move_extremes_no_panic() {
let dir = std::env::temp_dir().join("konoma_branch_move_extremes_test");
std::fs::create_dir_all(&dir).unwrap();
let mut app = App::new(dir.clone(), Config::default()).unwrap();
app.git_branches = Some(
(0..6)
.map(|i| crate::git::BranchInfo {
name: format!("br{i}"),
is_current: i == 0,
})
.collect(),
);
app.git_branch_sel = 2; app.git_branch_move(i32::MAX);
assert_eq!(app.git_branch_sel, 5, "末尾へ");
app.git_branch_move(i32::MIN);
assert_eq!(app.git_branch_sel, 0, "先頭へ");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn git_graph_move_extremes_no_panic() {
let dir = std::env::temp_dir().join("konoma_graph_move_extremes_test");
std::fs::create_dir_all(&dir).unwrap();
let mut app = App::new(dir.clone(), Config::default()).unwrap();
let commit = |id: &str| crate::git::GraphRow {
graph: Vec::new(),
commit: Some(id.to_string()),
short: String::new(),
subject: String::new(),
author: String::new(),
date: String::new(),
refs: String::new(),
worktree: false,
};
let connector = || crate::git::GraphRow {
graph: Vec::new(),
commit: None,
short: String::new(),
subject: String::new(),
author: String::new(),
date: String::new(),
refs: String::new(),
worktree: false,
};
app.git_graph = Some(vec![
commit("a"),
connector(),
commit("b"),
connector(),
commit("c"),
]);
app.git_graph_sel = 2; app.git_graph_move(i32::MAX);
assert_eq!(app.git_graph_sel, 4, "末尾コミット行へ");
app.git_graph_move(i32::MIN);
assert_eq!(app.git_graph_sel, 0, "先頭コミット行へ");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn git_log_move_extremes_no_panic() {
let dir = std::env::temp_dir().join("konoma_log_move_extremes_test");
std::fs::create_dir_all(&dir).unwrap();
let mut app = App::new(dir.clone(), Config::default()).unwrap();
app.git_log = Some(
(0..5)
.map(|i| crate::git::CommitInfo {
id: format!("id{i}"),
short: format!("s{i}"),
summary: String::new(),
author: String::new(),
time_epoch: 0,
})
.collect(),
);
app.git_log_sel = 1; app.git_log_move(i32::MAX);
assert_eq!(app.git_log_sel, 4, "末尾へ");
app.git_log_move(i32::MIN);
assert_eq!(app.git_log_sel, 0, "先頭へ");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn git_view_move_extremes_no_panic() {
let dir = std::env::temp_dir().join("konoma_view_move_extremes_test");
std::fs::create_dir_all(&dir).unwrap();
let mut app = App::new(dir.clone(), Config::default()).unwrap();
app.git_view_entries = (0..4)
.map(|i| crate::git::ChangeEntry {
path: dir.join(format!("f{i}")),
status: crate::git::FileStatus::Modified,
staged: false,
})
.collect();
app.git_view_sel = 1; app.git_view_move(i32::MAX);
assert_eq!(app.git_view_sel, 3, "末尾へ");
app.git_view_move(i32::MIN);
assert_eq!(app.git_view_sel, 0, "先頭へ");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn centered_fit_centers_and_downscales() {
let inner = Rect {
x: 0,
y: 0,
width: 80,
height: 24,
};
let r = centered_rect((40, 15), inner, false);
assert_eq!((r.width, r.height), (40, 15));
assert_eq!((r.x, r.y), (20, 4));
let r = centered_rect((200, 100), inner, false);
assert_eq!((r.width, r.height), (48, 24));
assert_eq!((r.x, r.y), (16, 0));
let r = centered_rect((40, 15), inner, true);
assert_eq!((r.width, r.height), (64, 24));
assert_eq!((r.x, r.y), (8, 0));
}
fn app_with_image() -> App {
let dir = std::env::temp_dir().join("konoma_img_state_test");
std::fs::create_dir_all(&dir).unwrap();
let mut app = App::new(dir, Config::default()).unwrap();
app.image_src = Some(image::DynamicImage::new_rgb8(400, 300));
app.preview_kind = Some(PreviewKind::Image(PathBuf::from("x.png")));
app.picker = Some(ratatui_image::picker::Picker::halfblocks());
let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
Box::leak(Box::new(rx));
app.img_tx = Some(tx);
app
}
fn inner(w: u16, h: u16) -> Rect {
Rect {
x: 0,
y: 0,
width: w,
height: h,
}
}
fn app_with_kitty() -> App {
let dir = std::env::temp_dir().join("konoma_gif_kitty_test");
std::fs::create_dir_all(&dir).unwrap();
let mut app = App::new(dir, Config::default()).unwrap();
let mut picker = ratatui_image::picker::Picker::halfblocks();
picker.set_protocol_type(ratatui_image::picker::ProtocolType::Kitty);
app.picker = Some(picker);
let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
Box::leak(Box::new(rx));
app.img_tx = Some(tx);
app
}
#[test]
fn media_load_is_async_then_applied() {
let p = Path::new("samples/sample.svg");
if !p.exists() {
return; }
let mut app = app_with_kitty();
let (tx, rx) = std::sync::mpsc::channel();
app.attach_media_loader(tx);
let kind = PreviewKind::Svg(p.to_path_buf());
app.preview_kind = Some(kind.clone());
app.start_media_load(&kind, p);
assert!(app.is_media_loading(), "開始直後は loading");
assert!(!app.is_image_preview(), "まだ読み込めていない");
let result = rx
.recv_timeout(std::time::Duration::from_secs(10))
.expect("worker should return");
assert!(app.apply_media(result), "結果を反映");
assert!(!app.is_media_loading());
assert!(app.is_image_preview(), "SVG ラスタが image_src に載る");
}
#[test]
fn media_loading_renders_shared_spinner() {
use ratatui::backend::TestBackend;
use ratatui::Terminal;
let mut app = app_with_kitty(); let (tx, rx) = std::sync::mpsc::channel();
Box::leak(Box::new(rx)); app.attach_media_loader(tx);
let path = PathBuf::from("/does-not-exist.svg");
let kind = PreviewKind::Svg(path.clone());
app.preview_kind = Some(kind.clone());
app.preview_path = Some(path.clone());
app.mode = Mode::Preview;
app.start_media_load(&kind, &path);
assert!(app.is_media_loading(), "読み込み中になる");
let mut term = Terminal::new(TestBackend::new(60, 12)).unwrap();
term.draw(|f| crate::ui::preview::render(f, &mut app, f.area()))
.unwrap();
let s: String = term
.backend()
.buffer()
.content()
.iter()
.map(|c| c.symbol())
.collect();
assert!(s.contains("loading"), "ローディング文言が出る: {s:?}");
let spinners = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
assert!(
spinners.iter().any(|g| s.contains(g)),
"共通スピナーのコマが出る: {s:?}"
);
assert!(!s.contains("<svg"), "読み込み中に生 XML を出さない");
}
#[test]
fn pdf_page_navigation_clamps_and_indicates() {
let dir = std::env::temp_dir().join("konoma_pdf_nav_test");
std::fs::create_dir_all(&dir).unwrap();
let mut app = App::new(dir, Config::default()).unwrap();
app.preview_kind = Some(PreviewKind::Pdf(PathBuf::from("/x/doc.pdf")));
app.mode = Mode::Preview;
app.pdf_pages = None;
app.pdf_page = 1;
assert!(!app.pdf_can_navigate());
app.pdf_next_page();
assert_eq!(app.pdf_page, 1, "総数不明では動かない");
assert_eq!(app.pdf_page_indicator(), None);
app.pdf_pages = Some(1);
assert!(!app.pdf_can_navigate(), "単ページは移動不可");
app.pdf_next_page();
assert_eq!(app.pdf_page, 1);
app.pdf_pages = Some(3);
app.pdf_page = 1;
assert!(app.pdf_can_navigate());
assert_eq!(app.pdf_page_indicator(), Some((1, 3)));
app.pdf_next_page();
assert_eq!(app.pdf_page, 2);
app.pdf_next_page();
assert_eq!(app.pdf_page, 3);
app.pdf_next_page();
assert_eq!(app.pdf_page, 3, "末尾でクランプ");
assert_eq!(app.pdf_page_indicator(), Some((3, 3)));
app.pdf_prev_page();
assert_eq!(app.pdf_page, 2);
app.pdf_prev_page();
app.pdf_prev_page();
assert_eq!(app.pdf_page, 1, "先頭でクランプ");
app.image_zoom = 4.0;
app.pdf_next_page();
assert_eq!(app.pdf_page, 2);
assert_eq!(app.image_zoom, 1.0, "ページ移動で fit に戻る");
}
#[test]
fn pdf_next_page_renders_off_thread() {
let p = Path::new("samples/sample.pdf");
if !p.exists() {
return; }
let Some(pages) = crate::preview::pdf::page_count(p) else {
return; };
if pages < 2 {
return;
}
let mut app = app_with_kitty();
let (tx, rx) = std::sync::mpsc::channel();
app.attach_media_loader(tx);
let kind = PreviewKind::Pdf(p.to_path_buf());
app.preview_kind = Some(kind.clone());
app.preview_path = Some(p.to_path_buf());
app.mode = Mode::Preview;
app.pdf_pages = Some(pages);
app.pdf_page = 1;
app.start_media_load(&kind, p);
let r1 = rx
.recv_timeout(std::time::Duration::from_secs(15))
.expect("page1 worker should return");
assert!(app.apply_media(r1));
assert!(app.is_image_preview(), "page1 が image_src に載る");
app.pdf_next_page();
assert_eq!(app.pdf_page, 2);
assert!(app.is_media_loading(), "ページ送りで再読み込みが始まる");
let r2 = rx
.recv_timeout(std::time::Duration::from_secs(15))
.expect("page2 worker should return");
assert!(app.apply_media(r2));
assert!(app.is_image_preview(), "page2 が image_src に載る");
}
#[test]
fn stale_media_result_is_ignored() {
let mut app = app_with_kitty();
app.preview_kind = Some(PreviewKind::Svg(PathBuf::from("x.svg")));
let img =
image::DynamicImage::ImageRgb8(image::RgbImage::from_pixel(8, 8, image::Rgb([1, 2, 3])));
let stale = MediaResult {
gen: app.media_gen.wrapping_sub(1),
payload: Some(MediaPayload::Static(img)),
};
assert!(!app.apply_media(stale), "古い世代は反映しない");
assert!(app.image_src.is_none(), "状態は変わらない");
}
#[test]
fn gif_emits_kitty_image_data_per_frame() {
use ratatui::buffer::Buffer;
use ratatui::widgets::Widget;
use ratatui_image::Image;
let mut app = app_with_kitty();
app.preview_kind = Some(PreviewKind::Image(PathBuf::from("x.gif")));
let red = image::DynamicImage::ImageRgb8(image::RgbImage::from_pixel(
40,
30,
image::Rgb([255, 0, 0]),
));
let green = image::DynamicImage::ImageRgb8(image::RgbImage::from_pixel(
40,
30,
image::Rgb([0, 255, 0]),
));
app.gif_frames = vec![
(red, std::time::Duration::from_millis(50)),
(green, std::time::Duration::from_millis(50)),
];
app.gif_idx = 0;
assert!(app.is_gif_active(), "2 フレームならアニメ扱い");
assert!(
app.is_image_preview(),
"GIF も画像プレビュー(ズーム/パン有効)"
);
let area = inner(40, 20);
let t0 = app.prepare_gif(area).expect("frame0 layout");
let proto0 = app.gif_protocol().expect("frame0 should be encoded");
let mut buf0 = Buffer::empty(area);
Image::new(proto0).render(t0, &mut buf0);
let dump0: String = buf0.content.iter().map(|c| c.symbol()).collect();
assert!(
dump0.contains("_G"),
"frame0 は kitty graphics 転送(_G)を吐くはず(空でない描画)"
);
app.gif_idx = 1;
let t1 = app.prepare_gif(area).expect("frame1 layout");
let proto1 = app.gif_protocol().expect("frame1 should be encoded");
let mut buf1 = Buffer::empty(area);
Image::new(proto1).render(t1, &mut buf1);
let dump1: String = buf1.content.iter().map(|c| c.symbol()).collect();
assert!(
dump1.contains("_G"),
"frame1 も kitty graphics 転送を吐くはず"
);
assert_ne!(dump0, dump1, "別フレームは別の画像データにエンコードされる");
}
#[test]
fn zoom_clamps_between_1_and_16() {
let mut app = app_with_image();
assert_eq!(app.image_zoom, 1.0);
app.image_zoom_by(0.5); assert_eq!(app.image_zoom, 1.0);
for _ in 0..40 {
app.image_zoom_by(1.25);
}
assert_eq!(app.image_zoom, 16.0);
app.image_zoom_reset();
assert_eq!(app.image_zoom, 1.0);
assert_eq!(app.image_center, (0.5, 0.5));
}
#[test]
fn fit_is_natural_size_and_centered_at_z1() {
let mut app = app_with_image();
let r = app.prepare_image(inner(200, 40)).unwrap();
assert_eq!((r.width, r.height), (40, 15), "z=1 は natural サイズ");
assert_eq!(r.x, (200 - 40) / 2);
assert_eq!(r.y, (40 - 15) / 2);
assert_eq!(app.image_vis_frac, (1.0, 1.0));
}
#[test]
fn zoom_grows_then_clips_and_enables_pan() {
let mut app = app_with_image();
let inner = inner(200, 40);
app.image_zoom = 2.0;
let r = app.prepare_image(inner).unwrap();
assert_eq!((r.width, r.height), (80, 30));
assert_eq!(app.image_vis_frac, (1.0, 1.0));
app.image_zoom = 4.0;
let r = app.prepare_image(inner).unwrap();
assert_eq!(r.height, 40, "高さは viewport に制限");
assert_eq!(r.width, 160);
assert!(app.image_vis_frac.1 < 1.0, "縦が見切れ=パン可");
assert_eq!(app.image_vis_frac.0, 1.0, "横はまだ収まる");
assert_eq!(r.x, (200 - 160) / 2);
}
#[test]
fn pan_noop_when_not_clipped_and_moves_when_clipped() {
let mut app = app_with_image();
let inner = inner(200, 40);
app.image_pan(1.0, 1.0);
app.prepare_image(inner).unwrap();
assert_eq!(app.image_center, (0.5, 0.5));
app.image_zoom = 12.0;
app.prepare_image(inner).unwrap(); for _ in 0..50 {
app.image_pan(1.0, 1.0);
app.prepare_image(inner).unwrap();
}
let (fw, fh) = app.image_vis_frac;
assert!(fw < 1.0 && fh < 1.0, "両軸見切れ");
assert!((app.image_center.0 - (1.0 - fw / 2.0)).abs() < 1e-6);
assert!((app.image_center.1 - (1.0 - fh / 2.0)).abs() < 1e-6);
}
#[test]
fn tabbar_appears_with_multiple_tabs() {
use ratatui::backend::TestBackend;
use ratatui::Terminal;
let dir = std::env::temp_dir().join("konoma_tabbar_test");
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("f.txt"), b"x").unwrap();
let mut app = App::new(dir.clone(), Config::default()).unwrap();
let render = |app: &mut App| -> String {
let mut term = Terminal::new(TestBackend::new(80, 6)).unwrap();
term.draw(|f| crate::ui::render(f, app)).unwrap();
term.backend()
.buffer()
.content()
.iter()
.map(|c| c.symbol())
.collect()
};
assert!(!render(&mut app).contains("1:konoma_tabbar_test"));
app.tab_new().unwrap();
let s = render(&mut app);
assert!(s.contains("1:konoma_tabbar_test"), "タブ1が無い: {s:?}");
assert!(s.contains("2:konoma_tabbar_test"), "タブ2が無い: {s:?}");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn markdown_links_collected_and_local_link_opens_in_konoma() {
use ratatui::backend::TestBackend;
use ratatui::Terminal;
let dir = std::env::temp_dir().join("konoma_links_open_test");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("target.md"), b"# Target\n\nhi\n").unwrap();
std::fs::write(
dir.join("doc.md"),
b"See [go](./target.md) and [web](https://example.com/x).\n",
)
.unwrap();
let mut app = App::new(dir.canonicalize().unwrap(), Config::default()).unwrap();
app.selected = app
.entries
.iter()
.position(|e| e.path.ends_with("doc.md"))
.unwrap();
app.tree_activate().unwrap();
let mut term = Terminal::new(TestBackend::new(72, 8)).unwrap();
term.draw(|f| crate::ui::render(f, &mut app)).unwrap();
assert_eq!(app.md_items.len(), 2, "リンク数");
assert!(item_target(&app.md_items[0]).ends_with("target.md"));
assert_eq!(item_target(&app.md_items[1]), "https://example.com/x");
app.md_focus_move(1);
assert_eq!(app.focused_item, Some(0));
app.md_activate_focused().unwrap();
assert!(
app.preview_path
.as_deref()
.is_some_and(|p| p.ends_with("target.md")),
"ローカルリンクで target.md に遷移していない: {:?}",
app.preview_path
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn md_focus_follows_offscreen_items_when_wrapped() {
use ratatui::backend::TestBackend;
use ratatui::Terminal;
let dir = std::env::temp_dir().join("konoma_md_focus_wrap_test");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let long = "word ".repeat(200); std::fs::write(
dir.join("doc.md"),
format!(
"{long}
[link](https://example.com/x)
"
),
)
.unwrap();
let mut app = App::new(dir.canonicalize().unwrap(), Config::default()).unwrap();
assert!(app.cfg.ui.wrap, "前提: 既定は折返しON");
app.selected = app.entries.iter().position(|e| !e.is_dir).unwrap();
app.tree_activate().unwrap();
let mut term = Terminal::new(TestBackend::new(40, 12)).unwrap();
term.draw(|f| crate::ui::render(f, &mut app)).unwrap();
assert_eq!(app.md_items.len(), 1);
assert_eq!(app.preview_scroll, 0);
app.md_focus_move(1);
assert!(
app.preview_scroll > 5,
"折返し文書でフォーカスに追従してスクロールする: scroll={}",
app.preview_scroll
);
term.draw(|f| crate::ui::render(f, &mut app)).unwrap();
let buf = term.backend().buffer().clone();
let screen: String = buf.content().iter().map(|c| c.symbol()).collect();
assert!(
screen.contains("link"),
"フォーカスしたリンクが画面内に描画される"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn md_task_toggle_cycles_and_writes_file() {
use ratatui::backend::TestBackend;
use ratatui::Terminal;
let dir = std::env::temp_dir().join("konoma_md_task_toggle_test");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let f = dir.join("todo.md");
std::fs::write(&f, "# t\n\n- [ ] 未着手のタスク\n- [X] 済み\n").unwrap();
let mut app = App::new(dir.canonicalize().unwrap(), Config::default()).unwrap();
app.selected = app
.entries
.iter()
.position(|e| e.path.ends_with("todo.md"))
.unwrap();
app.tree_activate().unwrap();
let mut term = Terminal::new(TestBackend::new(72, 12)).unwrap();
term.draw(|fr| crate::ui::render(fr, &mut app)).unwrap();
assert_eq!(app.md_items.len(), 2);
assert!(app.md_has_tasks());
app.md_focus_move(1);
assert!(app.md_focused_task());
app.md_toggle_focused_task();
let s = std::fs::read_to_string(&f).unwrap();
assert!(s.contains("- [x] 未着手のタスク"), "{s}");
assert!(s.contains("- [X] 済み"), "他行は不変: {s}");
term.draw(|fr| crate::ui::render(fr, &mut app)).unwrap();
app.md_toggle_focused_task();
let s = std::fs::read_to_string(&f).unwrap();
assert!(s.contains("- [ ] 未着手のタスク"), "{s}");
term.draw(|fr| crate::ui::render(fr, &mut app)).unwrap();
app.md_focus_move(1);
app.md_toggle_focused_task();
let s = std::fs::read_to_string(&f).unwrap();
assert!(s.contains("- [ ] 済み"), "{s}");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn md_task_toggle_custom_states_cycle() {
use ratatui::backend::TestBackend;
use ratatui::Terminal;
let dir = std::env::temp_dir().join("konoma_md_task_custom_test");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let f = dir.join("todo.md");
std::fs::write(&f, "- [ ] a\n").unwrap();
let mut cfg = Config::default();
cfg.ui.md_task_states = vec![" ".into(), "/".into(), "x".into()];
let mut app = App::new(dir.canonicalize().unwrap(), cfg).unwrap();
app.selected = app.entries.iter().position(|e| !e.is_dir).unwrap();
app.tree_activate().unwrap();
let mut term = Terminal::new(TestBackend::new(60, 8)).unwrap();
let cycle = |app: &mut App, term: &mut Terminal<TestBackend>| {
term.draw(|fr| crate::ui::render(fr, app)).unwrap();
if app.focused_item.is_none() {
app.md_focus_move(1);
}
app.md_toggle_focused_task();
std::fs::read_to_string(&f).unwrap()
};
assert_eq!(cycle(&mut app, &mut term), "- [/] a\n");
assert_eq!(cycle(&mut app, &mut term), "- [x] a\n");
assert_eq!(cycle(&mut app, &mut term), "- [ ] a\n");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn md_task_toggle_aborts_when_file_changed_externally() {
use ratatui::backend::TestBackend;
use ratatui::Terminal;
let dir = std::env::temp_dir().join("konoma_md_task_abort_test");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let f = dir.join("todo.md");
std::fs::write(&f, "- [ ] a\n").unwrap();
let mut app = App::new(dir.canonicalize().unwrap(), Config::default()).unwrap();
app.selected = app.entries.iter().position(|e| !e.is_dir).unwrap();
app.tree_activate().unwrap();
let mut term = Terminal::new(TestBackend::new(60, 8)).unwrap();
term.draw(|fr| crate::ui::render(fr, &mut app)).unwrap();
app.md_focus_move(1);
std::fs::write(&f, "- [x] a\n").unwrap();
app.md_toggle_focused_task();
assert_eq!(
std::fs::read_to_string(&f).unwrap(),
"- [x] a\n",
"書かない"
);
assert!(app.flash.is_some(), "flash で通知");
term.draw(|fr| crate::ui::render(fr, &mut app)).unwrap(); std::fs::write(&f, "- [x] a\n- [ ] b\n").unwrap(); app.flash = None;
app.md_toggle_focused_task();
assert_eq!(
std::fs::read_to_string(&f).unwrap(),
"- [x] a\n- [ ] b\n",
"個数不一致でも書かない"
);
assert!(app.flash.is_some());
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn md_task_toggle_noop_in_raw_source_and_preserves_crlf() {
use ratatui::backend::TestBackend;
use ratatui::Terminal;
let dir = std::env::temp_dir().join("konoma_md_task_raw_test");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let f = dir.join("todo.md");
std::fs::write(&f, "- [ ] a\r\n\r\ntail\r\n").unwrap();
let mut app = App::new(dir.canonicalize().unwrap(), Config::default()).unwrap();
app.selected = app.entries.iter().position(|e| !e.is_dir).unwrap();
app.tree_activate().unwrap();
let mut term = Terminal::new(TestBackend::new(60, 8)).unwrap();
term.draw(|fr| crate::ui::render(fr, &mut app)).unwrap();
app.md_focus_move(1);
app.toggle_md_raw();
assert!(!app.md_focused_task());
app.md_toggle_focused_task();
assert_eq!(
std::fs::read_to_string(&f).unwrap(),
"- [ ] a\r\n\r\ntail\r\n",
"raw 中は書かない"
);
app.toggle_md_raw();
app.md_toggle_focused_task();
assert_eq!(
std::fs::read_to_string(&f).unwrap(),
"- [x] a\r\n\r\ntail\r\n"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn md_items_mix_links_and_tasks_in_document_order() {
use ratatui::backend::TestBackend;
use ratatui::Terminal;
let dir = std::env::temp_dir().join("konoma_md_items_mix_test");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(
dir.join("doc.md"),
"[l1](https://x/1)\n\n- [ ] t1\n\n[l2](https://x/2)\n",
)
.unwrap();
let mut app = App::new(dir.canonicalize().unwrap(), Config::default()).unwrap();
app.selected = app.entries.iter().position(|e| !e.is_dir).unwrap();
app.tree_activate().unwrap();
let mut term = Terminal::new(TestBackend::new(60, 10)).unwrap();
term.draw(|fr| crate::ui::render(fr, &mut app)).unwrap();
let kinds: Vec<bool> = app
.md_items
.iter()
.map(|it| matches!(it.kind, MdItemKind::Task { .. }))
.collect();
assert_eq!(kinds, vec![false, true, false], "Link→Task→Link の順");
app.md_focus_move(1);
assert!(!app.md_focused_task());
app.md_focus_move(1);
assert!(app.md_focused_task(), "2番目=チェックボックス");
app.md_focus_move(1);
assert!(!app.md_focused_task());
app.md_focus_move(1);
assert_eq!(app.focused_item, Some(0), "巡回で先頭へ戻る");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn md_code_block_is_tab_focusable_and_copies_source() {
use ratatui::backend::TestBackend;
use ratatui::Terminal;
let dir = std::env::temp_dir().join("konoma_md_code_focus_test");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(
dir.join("doc.md"),
"[l1](https://x/1)\n\n```rust\nfn main() {}\nlet x = 1;\n```\n\n- [ ] t1\n",
)
.unwrap();
let mut app = App::new(dir.canonicalize().unwrap(), Config::default()).unwrap();
app.selected = app.entries.iter().position(|e| !e.is_dir).unwrap();
app.tree_activate().unwrap();
let mut term = Terminal::new(TestBackend::new(60, 12)).unwrap();
term.draw(|fr| crate::ui::render(fr, &mut app)).unwrap();
let kinds: Vec<&str> = app
.md_items
.iter()
.map(|it| match it.kind {
MdItemKind::Link { .. } => "link",
MdItemKind::Task { .. } => "task",
MdItemKind::CodeBlock => "code",
})
.collect();
assert_eq!(kinds, vec!["link", "code", "task"], "Link→Code→Task の順");
assert!(app.focused_code_text().is_none());
app.md_focus_move(1); assert!(!app.md_focused_code());
app.md_focus_move(1); assert!(app.md_focused_code(), "2番目=コードブロック");
assert_eq!(
app.focused_code_text().as_deref(),
Some("fn main() {}\nlet x = 1;"),
"生ソース(装飾/ハイライトを含まない)をコピー"
);
term.draw(|fr| crate::ui::render(fr, &mut app)).unwrap();
{
use ratatui::style::Modifier;
let buf = term.backend().buffer();
let w = buf.area.width;
let header_row = (0..buf.area.height).find(|&y| {
(0..w)
.map(|x| buf[(x, y)].symbol())
.collect::<String>()
.contains("rust")
});
let y = header_row.expect("rust ヘッダ行が描画されている");
let reversed = (0..w)
.filter(|&x| buf[(x, y)].symbol().trim() != "" || buf[(x, y)].symbol() == "▎")
.any(|x| buf[(x, y)].modifier.contains(Modifier::REVERSED));
assert!(
reversed,
"フォーカス中コードブロックのヘッダ行が反転していない"
);
}
app.md_copy_focused_code();
app.md_activate_focused().unwrap();
app.md_focus_move(1); assert!(!app.md_focused_code());
assert!(app.focused_code_text().is_none());
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn links_collapse_to_label_only_with_optional_icon() {
use ratatui::style::{Color, Modifier, Style};
let blue = Style::new()
.fg(Color::Blue)
.add_modifier(Modifier::UNDERLINED);
let lines = vec![Line::from(vec![
Span::raw("See "),
Span::raw("the docs"),
Span::raw(" ("),
Span::styled("https://x/y", blue),
Span::raw(")."),
])];
let (off, targets) = collapse_links(lines.clone(), false);
assert_eq!(targets, vec!["https://x/y".to_string()]);
let joined: String = off[0].spans.iter().map(|s| s.content.as_ref()).collect();
assert_eq!(joined, "See the docs.", "URL 併記が消えてラベルのみ");
assert_eq!(
off[0].spans.iter().filter(|s| is_link_span(s)).count(),
1,
"リンク span は1つ"
);
let (on, _) = collapse_links(lines, true);
let link = on[0].spans.iter().find(|s| is_link_span(s)).unwrap();
assert!(
link.content.starts_with(crate::ui::icons::link_icon()),
"リンクアイコンが前置されていない: {:?}",
link.content
);
}
#[test]
fn decorate_links_highlights_focused() {
use ratatui::style::{Color, Modifier, Style};
let dir = std::env::temp_dir().join("konoma_links_hl_test");
std::fs::create_dir_all(&dir).unwrap();
let mut app = App::new(dir.clone(), Config::default()).unwrap();
let link = |t: &str| {
Span::styled(
t.to_string(),
Style::new()
.fg(Color::Blue)
.add_modifier(Modifier::UNDERLINED),
)
};
let lines = vec![
Line::from(vec![Span::raw("a "), link("u0")]),
Line::from(vec![Span::raw("b "), link("u1")]),
];
app.focused_item = Some(1);
let out = app.decorate_md_items(lines);
assert_eq!(app.md_items.len(), 2);
let rev = |line: &Line<'static>| -> bool {
line.spans
.iter()
.any(|s| is_link_span(s) && s.style.add_modifier.contains(Modifier::REVERSED))
};
assert!(!rev(&out[0]), "未フォーカスのリンクは反転しない");
assert!(rev(&out[1]), "フォーカス中リンクが反転していない");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn code_file_decorates_with_colored_syntax() {
use ratatui::style::Color;
let dir = std::env::temp_dir().join("konoma_code_decorate_test");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("a.rs"), b"fn main() { let x = 1; }\n").unwrap();
let mut app = App::new(dir.canonicalize().unwrap(), Config::default()).unwrap();
let i = app.entries.iter().position(|e| !e.is_dir).unwrap();
app.selected = i;
app.tree_activate().unwrap();
assert!(
matches!(app.preview_kind, Some(PreviewKind::Code(_))),
"Code 種別に解決されない"
);
let lines = app.decorated_lines(80);
assert!(!lines.is_empty(), "装飾行が空");
let colored = lines
.iter()
.flat_map(|l| l.spans.iter())
.any(|s| matches!(s.style.fg, Some(Color::Rgb(_, _, _))));
assert!(colored, "シンタックスハイライトの前景色が付いていない");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn tree_filter_finds_recursively_then_clears() {
let dir = std::env::temp_dir().join("konoma_tree_filter_test");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("src/ui")).unwrap();
std::fs::write(dir.join("src/ui/tree.rs"), b"x").unwrap();
std::fs::write(dir.join("src/main.rs"), b"x").unwrap();
std::fs::write(dir.join("README.md"), b"x").unwrap();
let mut app = App::new(dir.canonicalize().unwrap(), Config::default()).unwrap();
let normal_count = app.entries.len();
app.start_filter();
assert!(app.is_filtering());
assert!(
app.entries.is_empty(),
"クエリ空のとき結果は0件であるべき(全展開しない)"
);
for c in "TREE".chars() {
app.filter_input_push(c); }
assert_eq!(app.filter_query(), Some("TREE"));
assert_eq!(app.entries.len(), 1, "tree 一致は1件");
assert!(app.entries[0].path.ends_with("tree.rs"));
app.filter_input_backspace();
assert_eq!(app.filter_query(), Some("TRE"));
assert!(app.entries.iter().any(|e| e.path.ends_with("tree.rs")));
app.filter_commit();
assert!(!app.is_filtering());
assert!(app.filter_query().is_some());
app.filter_clear();
assert!(app.filter_query().is_none());
assert_eq!(app.entries.len(), normal_count, "通常ツリーに復帰");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn small_code_file_is_also_windowed() {
let dir = std::env::temp_dir().join("konoma_small_windowed_test");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("a.rs"), b"fn main() {}\n").unwrap();
let mut app = App::new(dir.canonicalize().unwrap(), Config::default()).unwrap();
let i = app.entries.iter().position(|e| !e.is_dir).unwrap();
app.selected = i;
app.tree_activate().unwrap();
assert!(
app.is_windowed(),
"小さい Code ファイルも windowed であるべき"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn line_numbers_gutter_tracks_position() {
use std::io::Write;
let dir = std::env::temp_dir().join("konoma_linenum_test");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("a.rs");
{
let mut f = std::fs::File::create(&path).unwrap();
for i in 1..=30 {
writeln!(f, "let v{i} = {i};").unwrap();
}
}
let mut cfg = Config::default();
cfg.ui.line_numbers = true;
let mut app = App::new(dir.canonicalize().unwrap(), cfg).unwrap();
let i = app
.entries
.iter()
.position(|e| e.path.ends_with("a.rs"))
.unwrap();
app.selected = i;
app.tree_activate().unwrap();
app.preview_viewport = 5;
let gutter_num = |lines: &[Line<'static>], row: usize| -> String {
lines[row].spans[0].content.trim().to_string()
};
let top = app.windowed_lines(5, 80);
assert_eq!(gutter_num(&top, 0), "1", "先頭行番号が 1 でない");
assert_eq!(gutter_num(&top, 1), "2");
app.preview_to_bottom();
let bot = app.windowed_lines(5, 80);
assert_eq!(
gutter_num(&bot, 0),
"26",
"G 後の先頭行番号が総行数基準でない"
);
assert_eq!(gutter_num(&bot, 4), "30", "末尾行番号が 30 でない");
let mut app2 = App::new(dir.canonicalize().unwrap(), Config::default()).unwrap();
app2.selected = app2
.entries
.iter()
.position(|e| e.path.ends_with("a.rs"))
.unwrap();
app2.tree_activate().unwrap();
app2.preview_viewport = 5;
let off = app2.windowed_lines(5, 80);
let first: String = off[0].spans[0].content.to_string();
assert!(
!first.trim().starts_with('1'),
"OFF なのに行番号ガター: {first:?}"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn large_code_file_uses_windowed_reading() {
use ratatui::style::Color;
use std::io::Write;
let dir = std::env::temp_dir().join("konoma_windowed_test");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("big.rs");
{
let mut f = std::fs::File::create(&path).unwrap();
for i in 1..=9000 {
writeln!(f, "fn func_{i:05}() -> i32 {{ {i} }} // line {i}").unwrap();
}
}
let mut app = App::new(dir.canonicalize().unwrap(), Config::default()).unwrap();
let i = app
.entries
.iter()
.position(|e| e.path.ends_with("big.rs"))
.unwrap();
app.selected = i;
app.tree_activate().unwrap();
assert!(app.is_windowed(), "大きいファイルが windowed にならない");
app.preview_viewport = 5;
let joined = |lines: &[Line<'static>]| -> String {
lines
.iter()
.flat_map(|l| l.spans.iter())
.map(|s| s.content.as_ref())
.collect()
};
let top = app.windowed_lines(5, 80);
assert!(joined(&top).contains("func_00001"), "先頭が出ない");
app.preview_to_bottom();
let bot = app.windowed_lines(5, 80);
let bot_text = joined(&bot);
assert!(bot_text.contains("func_09000"), "末尾が出ない: {bot_text}");
assert!(!bot_text.contains("func_00001"), "末尾なのに先頭が見える");
let colored = bot
.iter()
.flat_map(|l| l.spans.iter())
.any(|s| matches!(s.style.fg, Some(Color::Rgb(_, _, _))));
assert!(colored, "windowed の着色が無い");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn tab_label_reflects_tree_root_or_preview_file() {
let dir = std::env::temp_dir().join("konoma_tab_label_test");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("doc.md"), b"# hi\n").unwrap();
let mut app = App::new(dir.canonicalize().unwrap(), Config::default()).unwrap();
assert_eq!(app.tab_label(0), "konoma_tab_label_test");
let idx = app.entries.iter().position(|e| !e.is_dir).unwrap();
app.selected = idx;
app.tree_activate().unwrap();
assert_eq!(app.mode, Mode::Preview);
assert_eq!(app.tab_label(0), "doc.md", "Preview 中はファイル名");
app.tab_new().unwrap();
assert_eq!(
app.tab_label(1),
"konoma_tab_label_test",
"新規タブはルート名"
);
assert_eq!(
app.tab_label(0),
"doc.md",
"非アクティブの Preview タブはファイル名"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn tab_switch_preserves_and_restores_preview() {
let dir = std::env::temp_dir().join("konoma_tab_preview_restore_test");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("doc.md"), b"# Hello\n\nbody\n").unwrap();
let mut app = App::new(dir.canonicalize().unwrap(), Config::default()).unwrap();
let idx = app.entries.iter().position(|e| !e.is_dir).unwrap();
app.selected = idx;
app.tree_activate().unwrap();
assert_eq!(app.mode, Mode::Preview);
let previewed = app.preview_path.clone();
assert!(previewed.is_some());
app.tab_new().unwrap();
assert_eq!(app.mode, Mode::Tree, "新規タブは Tree");
assert!(app.preview_path.is_none(), "新規タブはプレビュー無し");
app.tab_cycle(-1);
assert_eq!(app.mode, Mode::Preview, "戻ると Preview が復元される");
assert_eq!(app.preview_path, previewed, "プレビュー対象も復元される");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn tabs_create_switch_close_preserve_state() {
let dir = std::env::temp_dir().join("konoma_tabs_test");
std::fs::create_dir_all(dir.join("a")).unwrap();
std::fs::create_dir_all(dir.join("b")).unwrap();
std::fs::write(dir.join("f.txt"), b"x").unwrap();
let mut app = App::new(dir.clone(), Config::default()).unwrap();
assert_eq!(app.tab_count(), 1);
app.tree_next();
let sel1 = app.selected;
assert!(sel1 > 0);
app.tab_new().unwrap();
assert_eq!(app.tab_count(), 2);
assert_eq!(app.active_tab_index(), 1);
assert_eq!(app.selected, 0);
app.tree_last();
let sel2 = app.selected;
app.tab_cycle(-1);
assert_eq!(app.active_tab_index(), 0);
assert_eq!(app.selected, sel1, "タブ1の選択が保たれる");
app.tab_goto(1);
assert_eq!(app.active_tab_index(), 1);
assert_eq!(app.selected, sel2, "タブ2の選択が保たれる");
app.tab_close();
assert_eq!(app.tab_count(), 1);
assert_eq!(app.active_tab_index(), 0);
app.tab_close();
assert_eq!(app.tab_count(), 1);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn tab_selection_is_per_tab_root_change_clears_clipboard_is_global() {
let dir = std::env::temp_dir().join("konoma_tab_selection_perq_test");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("sub")).unwrap();
for n in ["a.txt", "b.txt", "c.txt"] {
std::fs::write(dir.join(n), b"x").unwrap();
}
std::fs::write(dir.join("sub").join("x.txt"), b"x").unwrap();
let mut app = App::new(dir.clone(), Config::default()).unwrap();
let idx =
|app: &App, p: std::path::PathBuf| app.entries.iter().position(|e| e.path == p).unwrap();
app.selected = idx(&app, dir.join("a.txt"));
app.toggle_select(); app.selected = idx(&app, dir.join("b.txt"));
app.toggle_select(); assert!(app.is_selected(&dir.join("a.txt")) && app.is_selected(&dir.join("b.txt")));
assert_eq!(app.op_targets().len(), 2, "A の op_targets は選択2件");
app.tab_new().unwrap();
assert!(
!app.has_selection(),
"新規タブBは選択空(旧Aの選択をリークしない)"
);
let bt = app.op_targets();
assert_eq!(bt.len(), 1, "B はカーソル1件");
assert!(
bt[0].starts_with(&dir) && !bt.iter().any(|p| *p == dir.join("a.txt")),
"B の op_targets は旧Aの選択(a.txt)を返さない"
);
app.tab_cycle(-1);
assert_eq!(app.active_tab_index(), 0);
assert!(
app.is_selected(&dir.join("a.txt")) && app.is_selected(&dir.join("b.txt")),
"A へ戻ると選択が復元される"
);
assert_eq!(app.op_targets().len(), 2);
app.selected = idx(&app, dir.join("sub"));
app.tree_descend().unwrap();
assert_eq!(app.root, dir.join("sub"), "sub が新 root");
assert!(!app.has_selection(), "root 変更(l)で選択クリア");
for t in app.op_targets() {
assert!(
t.starts_with(dir.join("sub")),
"op_targets が別 root(旧 dir)のパスを返さない: {t:?}"
);
}
app.selected = idx(&app, dir.join("sub").join("x.txt"));
app.toggle_select();
assert!(app.has_selection(), "sub 内で選択を積む");
app.tree_leave().unwrap();
assert_eq!(app.root, dir, "親へ戻る");
assert!(!app.has_selection(), "root 変更(h)で選択クリア");
app.selected = idx(&app, dir.join("a.txt"));
app.toggle_select();
app.copy_selection(); assert!(
app.clipboard_label().is_some(),
"コピーで clipboard に積まれる"
);
app.tab_new().unwrap();
assert!(
app.clipboard_label().is_some(),
"clipboard はタブ跨ぎで保持(global=TabState に入れない)"
);
app.tab_cycle(-1);
assert!(app.clipboard_label().is_some(), "戻っても clipboard 保持");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn refresh_rereads_directory_listing() {
let dir = std::env::temp_dir().join("konoma_refresh_test");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("a.txt"), b"x").unwrap();
let mut app = App::new(dir.clone(), Config::default()).unwrap();
let before = app.entries.len();
std::fs::write(dir.join("b.txt"), b"y").unwrap(); app.refresh().unwrap();
assert!(
app.entries.len() > before,
"新規ファイルが一覧に反映されない"
);
assert!(app.entries.iter().any(|e| e.path.ends_with("b.txt")));
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn refresh_prunes_deleted_paths_from_selection() {
let dir = std::env::temp_dir().join("konoma_refresh_prune_sel");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
for n in ["a.txt", "b.txt", "c.txt"] {
std::fs::write(dir.join(n), b"x").unwrap();
}
let mut app = App::new(dir.clone(), Config::default()).unwrap();
for n in ["a.txt", "b.txt", "c.txt"] {
app.selection.insert(dir.join(n));
}
assert_eq!(app.op_targets().len(), 3, "選択3件が一括対象");
std::fs::remove_file(dir.join("b.txt")).unwrap();
app.refresh().unwrap();
assert!(app.is_selected(&dir.join("a.txt")), "残存 a は選択維持");
assert!(app.is_selected(&dir.join("c.txt")), "残存 c は選択維持");
assert!(
!app.is_selected(&dir.join("b.txt")),
"消えた b は剪定される"
);
assert_eq!(app.op_targets().len(), 2, "一括対象は実在2件のみ");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn refresh_reloads_active_preview() {
let dir = std::env::temp_dir().join("konoma_refresh_reload_preview");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("doc.md"), b"# v1\n").unwrap();
let mut app = App::new(dir.clone(), Config::default()).unwrap();
let idx = app
.entries
.iter()
.position(|e| e.path.ends_with("doc.md"))
.unwrap();
app.selected = idx;
app.tree_activate().unwrap(); assert!(matches!(app.mode, Mode::Preview), "Preview モードへ");
let _ = app.decorated_lines(80); assert!(app.md_cache.is_some(), "プレビューでキャッシュが張られる");
std::fs::write(dir.join("doc.md"), b"# v2 changed\n").unwrap(); app.refresh().unwrap();
assert!(
app.md_cache.is_none(),
"refresh で装飾キャッシュが無効化され再読込される"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn media_preview_reloads_only_when_file_changes() {
use image::RgbImage;
let dir = std::env::temp_dir().join("konoma_media_reload_test");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let pic = dir.join("pic.png");
RgbImage::new(2, 2).save(&pic).unwrap(); let mut app = App::new(dir.clone(), Config::default()).unwrap();
let idx = app
.entries
.iter()
.position(|e| e.path.ends_with("pic.png"))
.unwrap();
app.selected = idx;
app.tree_activate().unwrap(); assert!(
matches!(
app.preview_kind,
Some(crate::preview::PreviewKind::Image(_))
),
"画像として開く"
);
assert!(
app.preview_media_mtime.is_some(),
"ロード時に基準 mtime を記録"
);
let gen0 = app.media_gen;
app.refresh().unwrap();
assert_eq!(
app.media_gen, gen0,
"未変更なら再ロードしない(mtime ガード)"
);
app.preview_media_mtime = None;
app.refresh().unwrap();
assert_ne!(
app.media_gen, gen0,
"変更検知で再ロード(clear_image が media_gen を進める)"
);
assert!(
app.preview_media_mtime.is_some(),
"再ロードで基準 mtime を更新"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn sort_menu_changes_order_by_key_reverse_and_dirs_first() {
let dir = std::env::temp_dir().join("konoma_sort_test");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::fs::create_dir_all(dir.join("zdir")).unwrap(); std::fs::write(dir.join("a.md"), vec![0u8; 30]).unwrap(); std::fs::write(dir.join("b.txt"), vec![0u8; 10]).unwrap(); std::fs::write(dir.join("c.log"), vec![0u8; 50]).unwrap(); let mut app = App::new(dir.clone(), Config::default()).unwrap();
let names = |a: &App| {
a.entries
.iter()
.map(|e| e.path.file_name().unwrap().to_string_lossy().to_string())
.collect::<Vec<_>>()
};
assert_eq!(names(&app), vec!["zdir", "a.md", "b.txt", "c.log"]);
app.open_sort_menu();
app.sort_menu_key('s').unwrap();
assert_eq!(names(&app), vec!["zdir", "b.txt", "a.md", "c.log"]);
assert!(!app.is_sort_menu(), "基準選択(n/s/m/e)でメニューは閉じる");
app.open_sort_menu();
app.sort_menu_key('r').unwrap();
assert!(app.is_sort_menu(), "トグル(r/.)はメニューを開いたまま");
assert_eq!(names(&app), vec!["zdir", "c.log", "a.md", "b.txt"]);
app.sort_menu_key('.').unwrap(); app.sort_menu_key('r').unwrap(); app.sort_menu_key('n').unwrap(); assert_eq!(names(&app), vec!["a.md", "b.txt", "c.log", "zdir"]);
app.open_sort_menu();
app.sort_menu_key('e').unwrap();
assert_eq!(names(&app), vec!["zdir", "c.log", "a.md", "b.txt"]);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn sort_config_sets_initial_order() {
let dir = std::env::temp_dir().join("konoma_sort_config_test");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::fs::create_dir_all(dir.join("zdir")).unwrap();
std::fs::write(dir.join("a.md"), vec![0u8; 30]).unwrap();
std::fs::write(dir.join("b.txt"), vec![0u8; 10]).unwrap();
std::fs::write(dir.join("c.log"), vec![0u8; 50]).unwrap();
let mut cfg = Config::default();
cfg.ui.sort.key = "size".into();
cfg.ui.sort.dirs_first = false;
let app = App::new(dir.clone(), cfg).unwrap();
assert_eq!(app.sort.key, SortKey::Size);
assert!(!app.sort.dirs_first);
let names: Vec<String> = app
.entries
.iter()
.map(|e| e.path.file_name().unwrap().to_string_lossy().to_string())
.collect();
let pos = |n: &str| names.iter().position(|x| x == n).unwrap();
assert!(pos("b.txt") < pos("a.md"), "size 昇順: b(10) < a(30)");
assert!(pos("a.md") < pos("c.log"), "size 昇順: a(30) < c(50)");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn bookmark_set_and_jump_via_marks() {
let base = std::env::temp_dir().join("konoma_bm_app_base");
let _ = std::fs::remove_dir_all(&base);
let proj = std::env::temp_dir().join("konoma_bm_app_proj");
let _ = std::fs::remove_dir_all(&proj);
std::fs::create_dir_all(proj.join("sub")).unwrap();
std::fs::write(proj.join("f.txt"), b"hello").unwrap();
let mut app = App::new(proj.clone(), Config::default()).unwrap();
app.bookmarks = crate::bookmarks::Bookmarks::with_base(base.clone(), &app.open_dir);
let sub_idx = app
.entries
.iter()
.position(|e| e.path.ends_with("sub"))
.unwrap();
let file_idx = app
.entries
.iter()
.position(|e| e.path.ends_with("f.txt"))
.unwrap();
app.selected = sub_idx;
app.start_mark_set();
assert!(app.is_marking());
app.mark_input('a');
assert!(!app.is_marking(), "登録後はマーク待ちが解除される");
assert_eq!(app.bookmarks.get('a'), Some(proj.join("sub")));
app.selected = file_idx;
app.start_mark_set();
app.mark_input('b');
assert_eq!(app.bookmarks.get('b'), Some(proj.join("f.txt")));
app.open_bookmark_list();
app.bookmark_jump_letter('a');
assert!(!app.is_bookmark_list(), "ジャンプで一覧が閉じる");
assert_eq!(
app.root,
proj.join("sub"),
"ディレクトリのブックマークはそこへ移動"
);
assert_eq!(app.mode, Mode::Tree);
app.root = proj.clone();
let _ = app.rebuild_tree();
let root_before = app.root.clone();
app.open_bookmark_list();
app.bookmark_jump_letter('b');
assert_eq!(
app.mode,
Mode::Preview,
"ファイルのブックマークはプレビューで開く"
);
let f = proj.join("f.txt");
assert_eq!(app.preview_path.as_deref(), Some(f.as_path()));
assert_eq!(
app.root, root_before,
"ファイルを開いても tree(root) は変わらない"
);
app.request_edit();
assert_eq!(
app.take_pending_edit().as_deref(),
Some(f.as_path()),
"ブックマークのファイルが編集対象になる"
);
app.back_to_tree();
assert_eq!(app.mode, Mode::Tree);
assert_eq!(app.root, root_before);
app.open_bookmark_list();
app.bookmark_jump_letter('z');
assert_eq!(app.root, root_before, "未登録マークではジャンプしない");
assert!(app.flash.is_some());
assert!(app.is_bookmark_list(), "未登録では一覧を閉じない");
app.close_bookmark_list();
app.open_bookmark_list();
app.bookmark_list_move(1); app.bookmark_list_edit();
assert!(!app.is_bookmark_list(), "編集で一覧を閉じる");
assert_eq!(
app.take_pending_edit().as_deref(),
Some(f.as_path()),
"一覧の e でファイルが編集対象になる"
);
std::fs::remove_dir_all(&base).ok();
std::fs::remove_dir_all(&proj).ok();
}
#[test]
fn tree_descend_sets_root_to_selected_dir() {
let dir = std::env::temp_dir().join("konoma_descend_test");
std::fs::create_dir_all(dir.join("sub")).unwrap();
std::fs::write(dir.join("sub").join("f.txt"), b"x").unwrap();
let mut app = App::new(dir.clone(), Config::default()).unwrap();
let i = app
.entries
.iter()
.position(|e| e.is_dir && e.path.ends_with("sub"))
.expect("sub が無い");
app.selected = i;
app.tree_descend().unwrap();
assert_eq!(app.root, dir.join("sub"), "root が選択ディレクトリになる");
assert_eq!(app.mode, Mode::Tree);
assert!(
app.entries.iter().any(|e| e.path.ends_with("f.txt")),
"sub の中身が表示される"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn jump_to_dir_clears_selection_on_root_change() {
let base = std::env::temp_dir().join("konoma_jump_clear_test");
let _ = std::fs::remove_dir_all(&base);
std::fs::create_dir_all(base.join("subB")).unwrap();
std::fs::write(base.join("a.txt"), b"x").unwrap();
let mut app = App::new(base.clone(), Config::default()).unwrap();
let i = app
.entries
.iter()
.position(|e| !e.is_dir && e.path.ends_with("a.txt"))
.expect("a.txt が無い");
app.selected = i;
app.toggle_select();
assert!(app.has_selection(), "前提: 選択がある");
app.jump_to_dir(base.join("subB"));
assert_eq!(app.root, base.join("subB"), "root が subB になる");
assert!(!app.has_selection(), "旧 root の選択は持ち越さない");
assert!(
!app.op_targets().iter().any(|p| p.ends_with("a.txt")),
"op_targets が旧 root の a.txt を返さない"
);
std::fs::remove_dir_all(&base).ok();
}
#[test]
fn copy_text_builds_each_kind() {
let open = PathBuf::from("/Users/me/work/konoma");
let file = open.join("src").join("main.rs");
assert_eq!(copy_text(&file, &open, CopyKind::Name), "main.rs");
assert_eq!(
copy_text(&file, &open, CopyKind::Full),
"/Users/me/work/konoma/src/main.rs"
);
assert_eq!(
copy_text(&file, &open, CopyKind::Relative),
"konoma/src/main.rs"
);
assert_eq!(
copy_text(&file, &open, CopyKind::Parent),
"/Users/me/work/konoma/src"
);
let sibling = PathBuf::from("/Users/me/work/other/x.md");
assert_eq!(
copy_text(&sibling, &open, CopyKind::Relative),
"../other/x.md",
"兄弟ディレクトリは ../ を付ける"
);
let parent = PathBuf::from("/Users/me/work");
assert_eq!(
copy_text(&parent, &open, CopyKind::Relative),
"..",
"上位は .."
);
let outside = PathBuf::from("/etc/hosts");
assert_eq!(
copy_text(&outside, &open, CopyKind::Relative),
"../../../../etc/hosts"
);
}
#[test]
fn copy_relative_matches_title_display() {
let work = std::env::temp_dir().join("konoma_copy_match_title");
let _ = std::fs::remove_dir_all(&work);
let a = work.join("A");
std::fs::create_dir_all(&a).unwrap();
std::fs::create_dir_all(work.join("B")).unwrap();
let mut app = App::new(a.clone(), Config::default()).unwrap();
app.path_style = PathStyle::Relative;
for p in [
a.join("x.rs"), work.join("B").join("aaa.md"), work.clone(), ] {
assert_eq!(
copy_text(&p, &app.open_dir, CopyKind::Relative),
app.format_path(&p),
"コピーとタイトル表示がずれている: {}",
p.display()
);
}
std::fs::remove_dir_all(&work).ok();
}
#[test]
fn is_image_preview_requires_src_and_kind() {
let app = app_with_image();
assert!(app.is_image_preview());
let mut app2 = app_with_image();
app2.image_src = None;
assert!(!app2.is_image_preview());
}
#[test]
fn help_in_image_mode_shows_only_image_section() {
let mut app = app_with_image();
app.mode = Mode::Preview;
let s: String = crate::ui::help::help_lines(&app)
.iter()
.map(|line| {
line.spans
.iter()
.map(|sp| sp.content.as_ref())
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n");
assert!(s.contains("Preview: image") && s.contains("zoom"), "画像節");
assert!(
!s.contains("Git status (row markers)") && !s.contains("horizontal scroll"),
"Tree/テキスト専用節は出さない"
);
assert!(s.contains("Tabs"), "画像中も共通タブ節");
}
#[test]
fn status_shows_image_zoom_factor() {
use ratatui::backend::TestBackend;
use ratatui::Terminal;
let mut app = app_with_image();
app.mode = Mode::Preview;
let text = |app: &App| -> String {
let mut term = Terminal::new(TestBackend::new(80, 1)).unwrap();
term.draw(|f| crate::ui::status::render_combined(f, app, f.area()))
.unwrap();
term.backend()
.buffer()
.content()
.iter()
.map(|c| c.symbol())
.collect()
};
app.image_zoom = 1.0;
let s = text(&app);
assert!(s.contains("IMAGE"), "IMAGE チップ: {s}");
assert!(s.contains("x1.00"), "フィットで x1.00: {s}");
app.image_zoom = 2.5;
assert!(text(&app).contains("x2.50"), "拡大時に倍率");
}
fn has_rgb_fg(lines: &[Line<'static>]) -> bool {
use ratatui::style::Color;
lines
.iter()
.flat_map(|l| l.spans.iter())
.any(|s| matches!(s.style.fg, Some(Color::Rgb(_, _, _))))
}
fn open_code_file(dir: &std::path::Path, name: &str, cfg: Config) -> App {
std::fs::write(dir.join(name), b"fn main() {\n let x = 1;\n}\n").unwrap();
let mut app = App::new(dir.canonicalize().unwrap(), cfg).unwrap();
app.selected = app
.entries
.iter()
.position(|e| e.path.ends_with(name))
.unwrap();
app.tree_activate().unwrap();
app.preview_viewport = 5;
app
}
#[test]
fn syntax_highlight_off_is_plain_and_not_pending() {
let dir = std::env::temp_dir().join("konoma_hl_off_test");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let mut cfg = Config::default();
cfg.ui.syntax_highlight = false;
let mut app = open_code_file(&dir, "a.rs", cfg);
assert!(
!app.is_highlight_pending(),
"off はローディング待ちにしない"
);
let lines = app.windowed_lines(5, 80);
assert!(!has_rgb_fg(&lines), "off は素テキスト(着色なし)");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn warm_grammar_opens_without_pending_and_colored() {
let _ =
crate::preview::code::highlight("fn x(){}\n", std::path::Path::new("warm.rs"), "TwoDark");
let dir = std::env::temp_dir().join("konoma_hl_warm_test");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let mut app = open_code_file(&dir, "b.rs", Config::default());
assert!(
!app.is_highlight_pending(),
"温まった言語は pending 無し(即時着色)"
);
let lines = app.windowed_lines(5, 80);
assert!(has_rgb_fg(&lines), "温まった Code は着色される");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn loading_mode_follows_config() {
let dir = std::env::temp_dir().join("konoma_loading_mode_test");
std::fs::create_dir_all(&dir).unwrap();
let mut app = App::new(dir.clone(), Config::default()).unwrap();
assert!(app.loading_is_indicator(), "既定は indicator");
app.cfg.ui.preview_loading = "progressive".into();
assert!(!app.loading_is_indicator(), "progressive 指定で素表示");
}
#[test]
fn request_edit_targets_selected_file_or_warns_on_dir() {
let dir = std::env::temp_dir().join("konoma_edit_target_test");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("sub")).unwrap();
std::fs::write(dir.join("a.rs"), b"x\n").unwrap();
let mut app = App::new(dir.canonicalize().unwrap(), Config::default()).unwrap();
app.selected = app
.entries
.iter()
.position(|e| e.path.ends_with("a.rs"))
.unwrap();
app.request_edit();
let got = app.take_pending_edit();
assert!(
got.as_deref().map(|p| p.ends_with("a.rs")).unwrap_or(false),
"選択ファイルが編集対象: {got:?}"
);
assert!(
app.take_pending_edit().is_none(),
"取り出したらクリアされる"
);
app.selected = app.entries.iter().position(|e| e.is_dir).unwrap();
app.flash = None;
app.request_edit();
assert!(
app.take_pending_edit().is_none(),
"ディレクトリは編集対象外"
);
assert!(app.flash.is_some(), "拒否メッセージを出す");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn request_edit_targets_preview_file() {
let dir = std::env::temp_dir().join("konoma_edit_preview_test");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("b.rs"), b"fn main() {}\n").unwrap();
let mut app = App::new(dir.canonicalize().unwrap(), Config::default()).unwrap();
app.selected = app
.entries
.iter()
.position(|e| e.path.ends_with("b.rs"))
.unwrap();
app.tree_activate().unwrap(); assert!(matches!(app.mode, Mode::Preview));
app.request_edit();
let got = app.take_pending_edit();
assert!(
got.as_deref().map(|p| p.ends_with("b.rs")).unwrap_or(false),
"プレビュー中ファイルが編集対象: {got:?}"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn spinner_cycles_and_has_no_emoji() {
let dir = std::env::temp_dir().join("konoma_spinner_test");
std::fs::create_dir_all(&dir).unwrap();
let mut app = App::new(dir.clone(), Config::default()).unwrap();
let g0 = app.spinner_glyph(); app.tick_spinner();
assert_ne!(g0, app.spinner_glyph(), "ティックでコマが進む(回る)");
for _ in 0..9 {
app.tick_spinner(); }
assert_eq!(app.spinner_glyph(), g0, "10 コマで一巡して先頭へ");
for _ in 0..10 {
let ch = app.spinner_glyph().chars().next().unwrap();
assert!(
('\u{2800}'..='\u{28FF}').contains(&ch),
"スピナーは点字(非絵文字): {ch:?}"
);
app.tick_spinner();
}
}
#[test]
fn progressive_pending_renders_plain_text() {
let dir = std::env::temp_dir().join("konoma_prog_plain_test");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let mut cfg = Config::default();
cfg.ui.preview_loading = "progressive".into();
let mut app = open_code_file(&dir, "c.rs", cfg);
app.hl_pending = true; let lines = app.windowed_lines(5, 80);
assert!(
!has_rgb_fg(&lines),
"progressive 待ち中は素テキスト(着色は完了後に差し替え)"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn preview_search_finds_highlights_and_navigates() {
use std::io::Write;
let dir = std::env::temp_dir().join("konoma_preview_search_test");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let p = dir.join("a.rs");
{
let mut f = std::fs::File::create(&p).unwrap();
for i in 0..40 {
let tail = if i % 7 == 0 { "needle here" } else { "x" };
writeln!(f, "line {i} {tail}").unwrap();
}
}
let mut app = App::new(dir.canonicalize().unwrap(), Config::default()).unwrap();
app.selected = app.entries.iter().position(|e| !e.is_dir).unwrap();
app.tree_activate().unwrap();
assert!(app.is_windowed(), "コードはウィンドウ読み対象");
app.start_search();
assert!(app.is_searching(), "/ で検索入力モード");
for c in "needle".chars() {
app.search_input_push(c);
}
assert_eq!(app.search_input(), Some("needle"));
app.search_commit();
assert!(!app.is_searching(), "確定で入力モード解除");
assert_eq!(app.search_matches.len(), 6, "7行ごとに6件");
assert_eq!(app.search_status(), Some((1, 6)));
assert_eq!(app.preview_top_line, 0, "最初の一致(行0)を先頭に");
assert_eq!(app.preview_search_query(), Some("needle"));
app.search_next(1);
assert_eq!(app.search_status(), Some((2, 6)));
assert_eq!(app.preview_top_line, 7);
app.search_next(1);
assert_eq!(app.preview_top_line, 14);
app.search_next(-1);
assert_eq!(app.preview_top_line, 7);
app.search_idx = 0;
app.search_next(-1);
assert_eq!(app.search_status(), Some((6, 6)));
assert_eq!(app.preview_top_line, 35);
app.search_clear();
assert!(app.preview_search_query().is_none());
assert_eq!(app.search_status(), None);
assert!(app.search_matches.is_empty());
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn highlight_query_in_line_marks_only_matches_yellow() {
use ratatui::style::Color;
let line = Line::from(vec![Span::from("foo "), Span::from("Needle bar needle")]);
let out = highlight_query_in_line(line, "needle", None);
let hit: String = out
.spans
.iter()
.filter(|s| s.style.bg == Some(Color::Yellow))
.map(|s| s.content.as_ref())
.collect();
assert_eq!(
hit, "Needleneedle",
"一致部分だけ黄色強調(原文の大小は保持)"
);
let full: String = out.spans.iter().map(|s| s.content.as_ref()).collect();
assert_eq!(full, "foo Needle bar needle");
}
#[test]
fn highlight_query_in_line_handles_multibyte_lines() {
use ratatui::style::Color;
let line = Line::from("日本語 needle テスト");
let out = highlight_query_in_line(line, "needle", None);
let hit: String = out
.spans
.iter()
.filter(|s| s.style.bg == Some(Color::Yellow))
.map(|s| s.content.as_ref())
.collect();
assert_eq!(hit, "needle", "マルチバイト行内の ASCII 一致を黄色強調");
let full: String = out.spans.iter().map(|s| s.content.as_ref()).collect();
assert_eq!(full, "日本語 needle テスト", "全文は不変");
}
#[test]
fn highlight_query_in_line_current_match_is_orange() {
use ratatui::style::Color;
let orange = Color::Rgb(0xff, 0x8c, 0x00);
let line = Line::from("let needle = 1;"); let cur = highlight_query_in_line(line.clone(), "needle", Some(0));
let other = highlight_query_in_line(line, "needle", None);
assert!(
cur.spans.iter().any(|s| s.style.bg == Some(orange)),
"現在の出現はオレンジ背景"
);
assert!(
!cur.spans.iter().any(|s| s.style.bg == Some(Color::Yellow)),
"現在の出現に黄色は使わない"
);
assert!(
other
.spans
.iter()
.any(|s| s.style.bg == Some(Color::Yellow))
&& !other.spans.iter().any(|s| s.style.bg == Some(orange)),
"非選択の出現は黄色のみ"
);
}
#[test]
fn highlight_query_only_current_occurrence_on_same_line_is_orange() {
use ratatui::style::Color;
let orange = Color::Rgb(0xff, 0x8c, 0x00);
let line = Line::from("const todos: Todo[] = [");
let out = highlight_query_in_line(line, "todo", Some(1));
let orange_txt: String = out
.spans
.iter()
.filter(|s| s.style.bg == Some(orange))
.map(|s| s.content.as_ref())
.collect();
let yellow_txt: String = out
.spans
.iter()
.filter(|s| s.style.bg == Some(Color::Yellow))
.map(|s| s.content.as_ref())
.collect();
assert_eq!(orange_txt, "Todo", "現在の出現(2つ目)だけオレンジ");
assert_eq!(yellow_txt, "todo", "同一行の他の出現(1つ目)は黄色");
}
#[test]
fn highlight_query_current_match_survives_tab_expansion() {
use ratatui::style::Color;
let orange = Color::Rgb(0xff, 0x8c, 0x00);
let expanded = crate::preview::code::expand_tabs(vec![Line::from("\tneedle ab needle")], 4);
let line = expanded.into_iter().next().unwrap();
let out = highlight_query_in_line(line, "needle", Some(1));
let orange_txt: String = out
.spans
.iter()
.filter(|s| s.style.bg == Some(orange))
.map(|s| s.content.as_ref())
.collect();
let yellow_txt: String = out
.spans
.iter()
.filter(|s| s.style.bg == Some(Color::Yellow))
.map(|s| s.content.as_ref())
.collect();
assert_eq!(orange_txt, "needle", "タブ展開後も現在一致(2つ目)だけ橙");
assert_eq!(yellow_txt, "needle", "他の出現(1つ目)は黄色");
}
#[cfg(feature = "git")]
fn init_git_repo(dir: &Path) {
let repo = git2::Repository::init(dir).unwrap();
let mut cfg = repo.config().unwrap();
cfg.set_str("user.name", "Test").unwrap();
cfg.set_str("user.email", "test@example.com").unwrap();
cfg.set_str("commit.gpgsign", "false").ok();
}
#[cfg(feature = "git")]
#[test]
fn graph_config_base_branches_drive_base_order_and_picker_reorder() {
use std::process::Command;
let dir = std::env::temp_dir().join("konoma_graph_config_base");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
init_git_repo(&dir);
let git = |args: &[&str]| {
Command::new("git")
.current_dir(&dir)
.args(args)
.output()
.unwrap();
};
std::fs::write(dir.join("a.txt"), b"a\n").unwrap();
git(&["add", "-A"]);
git(&["commit", "-q", "-m", "init"]);
git(&["branch", "-M", "trunk"]); for n in ["main", "develop", "feat"] {
git(&["checkout", "-q", "trunk"]);
git(&["checkout", "-q", "-b", n]);
std::fs::write(dir.join(format!("{n}.txt")), b"x\n").unwrap();
git(&["add", "-A"]);
git(&["commit", "-q", "-m", &format!("{n} c")]);
}
git(&["checkout", "-q", "trunk"]);
let mut cfg = Config::default();
cfg.ui.graph_base_branches = vec!["develop".into(), "main".into()];
let mut app = App::new(dir.canonicalize().unwrap(), cfg).unwrap();
app.open_git_graph();
assert_eq!(
app.git_graph_base_label(),
Some("develop"),
"config 配列の最初の存在ブランチが基準"
);
let names: Vec<String> = app
.git_graph_legend()
.iter()
.map(|e| e.name.clone())
.collect();
let pos = |n: &str| names.iter().position(|x| x == n);
assert_eq!(names.first().map(|s| s.as_str()), Some("develop"));
assert!(
pos("develop") < pos("main"),
"配列順 develop→main: {names:?}"
);
assert!(pos("main") < pos("trunk"), "配列が HEAD より先: {names:?}");
app.git_graph_open_picker();
app.git_graph_picker_sel = 1;
app.git_graph_picker_reorder(-1); app.git_graph_picker_apply();
assert_eq!(
app.git_graph_base_label(),
Some("main"),
"並び替えで先頭になった main が基準に再導出される"
);
std::fs::remove_dir_all(&dir).ok();
}
#[cfg(feature = "git")]
#[test]
fn descend_into_same_repo_subdir_reuses_ignored_set() {
let dir = std::env::temp_dir().join("konoma_ignored_reuse_same");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("sub")).unwrap();
init_git_repo(&dir);
std::fs::write(dir.join(".gitignore"), b"ignored.txt\n").unwrap();
std::fs::write(dir.join("ignored.txt"), b"x").unwrap();
let mut app = App::new(dir.canonicalize().unwrap(), Config::default()).unwrap();
app.refresh_git_if_needed();
let wd = app.git_ignored_for.clone();
assert!(
wd.is_some(),
"ignored は repo workdir をキーに計算済みのはず"
);
let sentinel = dir.join("__sentinel_marker__");
app.git_ignored.insert(sentinel.clone());
let i = app
.entries
.iter()
.position(|e| e.is_dir && e.path.ends_with("sub"))
.expect("sub エントリが無い");
app.selected = i;
app.tree_descend().unwrap();
assert!(app.root.ends_with("sub"), "root が sub になった");
app.refresh_git_if_needed();
assert_eq!(
app.git_ignored_for, wd,
"同一 repo: ignored のキャッシュキー(workdir)は不変"
);
assert!(
app.git_ignored.contains(&sentinel),
"同一 repo 内では ignored を再計算せず流用する(番兵が生存)"
);
assert_eq!(
app.git_status_for.as_deref(),
Some(app.root.as_path()),
"statuses は root 変更のたびに取り直す"
);
std::fs::remove_dir_all(&dir).ok();
}
#[cfg(feature = "git")]
#[test]
fn descend_into_nested_different_repo_recomputes_ignored_set() {
let dir = std::env::temp_dir().join("konoma_ignored_reuse_diff");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("inner")).unwrap();
init_git_repo(&dir);
init_git_repo(&dir.join("inner"));
std::fs::write(dir.join(".gitignore"), b"ignored.txt\n").unwrap();
let mut app = App::new(dir.canonicalize().unwrap(), Config::default()).unwrap();
app.refresh_git_if_needed();
let wd_a = app.git_ignored_for.clone();
assert!(wd_a.is_some());
let sentinel = dir.join("__sentinel_marker__");
app.git_ignored.insert(sentinel.clone());
let i = app
.entries
.iter()
.position(|e| e.is_dir && e.path.ends_with("inner"))
.expect("inner エントリが無い");
app.selected = i;
app.tree_descend().unwrap();
app.refresh_git_if_needed();
assert_ne!(
app.git_ignored_for, wd_a,
"別 repo: workdir が変わったのでキャッシュキーも変わる"
);
assert!(
!app.git_ignored.contains(&sentinel),
"別 repo へ移ると ignored を作り直す(番兵が消える)"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn busy_indicator_reflects_background_jobs() {
let dir = std::env::temp_dir().join("konoma_busy_indicator_test");
std::fs::create_dir_all(&dir).unwrap();
let mut app = App::new(dir.clone(), Config::default()).unwrap();
app.lang = crate::i18n::Lang::En;
assert!(app.busy_jobs().is_empty(), "アイドルではジョブ無し");
assert!(!app.busy_indicator_active());
app.media_loading = true;
assert!(app.busy_jobs().contains(&crate::i18n::Msg::BusyMedia));
assert!(app.busy_indicator_active());
let joined: String = crate::ui::status::context_spans(&app)
.iter()
.map(|s| s.content.as_ref())
.collect();
assert!(joined.contains("loading media"), "{joined}");
app.md_remote_inflight
.insert("https://example.com/a.png".into());
let joined: String = crate::ui::status::context_spans(&app)
.iter()
.map(|s| s.content.as_ref())
.collect();
assert!(joined.contains("+1"), "複数ジョブは +n: {joined}");
app.cfg.ui.busy_indicator = false;
assert!(!app.busy_indicator_active());
let joined: String = crate::ui::status::context_spans(&app)
.iter()
.map(|s| s.content.as_ref())
.collect();
assert!(!joined.contains("loading media"), "{joined}");
app.cfg.ui.busy_indicator = true;
app.media_loading = false;
app.md_remote_inflight.clear();
assert!(app.busy_jobs().is_empty());
assert!(!app.busy_indicator_active());
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn busy_indicator_tracks_ignored_scan() {
let dir = std::env::temp_dir().join("konoma_busy_gitscan_test");
std::fs::create_dir_all(&dir).unwrap();
let mut app = App::new(dir.clone(), Config::default()).unwrap();
app.git_ignored_gen = 7;
app.git_ignored_pending = Some(dir.clone());
assert!(app.busy_jobs().contains(&crate::i18n::Msg::BusyGitScan));
let cur = IgnoredResult {
gen: 7,
workdir: dir.clone(),
set: Default::default(),
};
assert!(app.apply_ignored(cur));
assert!(
!app.busy_jobs().contains(&crate::i18n::Msg::BusyGitScan),
"適用でスキャン中表示が消える"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn apply_ignored_reflects_current_gen_and_discards_stale() {
let dir = std::env::temp_dir().join("konoma_apply_ignored_gen");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let mut app = App::new(dir.clone(), Config::default()).unwrap();
app.git_ignored_gen = 5;
app.git_ignored_pending = Some(dir.clone());
let stale = IgnoredResult {
gen: 4,
workdir: dir.clone(),
set: [dir.join("x")].into_iter().collect(),
};
assert!(!app.apply_ignored(stale), "stale 世代は反映しない");
assert!(app.git_ignored.is_empty(), "stale は無視セットを変えない");
assert!(
app.git_ignored_pending.is_some(),
"stale では pending を残す(現行計算待ちのまま)"
);
let cur = IgnoredResult {
gen: 5,
workdir: dir.clone(),
set: [dir.join("y")].into_iter().collect(),
};
assert!(app.apply_ignored(cur), "現世代は反映する");
assert!(app.git_ignored.contains(&dir.join("y")));
assert_eq!(
app.git_ignored_for.as_deref(),
Some(dir.as_path()),
"反映で workdir をキャッシュキーに記録"
);
assert!(
app.git_ignored_pending.is_none(),
"現世代の反映で pending を消す"
);
std::fs::remove_dir_all(&dir).ok();
}
#[cfg(feature = "git")]
#[test]
fn graph_legend_caps_branches_head_first_and_picker_toggles() {
use std::process::Command;
let dir = std::env::temp_dir().join("konoma_graph_legend_cap_picker");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
init_git_repo(&dir);
let git = |args: &[&str]| {
Command::new("git")
.current_dir(&dir)
.args(args)
.output()
.unwrap();
};
std::fs::write(dir.join("a.txt"), b"a\n").unwrap();
git(&["add", "-A"]);
git(&["commit", "-q", "-m", "init"]);
git(&["branch", "-M", "trunk"]); for n in ["f1", "f2", "f3", "f4", "f5"] {
git(&["checkout", "-q", "trunk"]);
git(&["checkout", "-q", "-b", n]);
std::fs::write(dir.join(format!("{n}.txt")), b"x\n").unwrap();
git(&["add", "-A"]);
git(&["commit", "-q", "-m", &format!("{n} c")]);
}
git(&["checkout", "-q", "trunk"]);
let mut cfg = Config::default();
cfg.ui.graph_max_branches = 3; let mut app = App::new(dir.canonicalize().unwrap(), cfg).unwrap();
app.open_git_graph();
assert!(app.is_git_graph(), "グラフが開く");
let legend = app.git_graph_legend();
assert!(!legend.is_empty(), "凡例が空でない");
assert!(
legend[0].is_head && legend[0].name == "trunk",
"凡例の先頭は HEAD(trunk): {:?}",
legend
.iter()
.map(|e| (&e.name, e.is_head))
.collect::<Vec<_>>()
);
assert!(
legend.len() <= 3,
"表示ブランチは上限3まで: {}",
legend.len()
);
assert!(app.git_graph_hidden_count() >= 1, "一部は非表示");
app.git_graph_open_picker();
assert!(app.is_git_graph_picker(), "パネルが開く");
let items = app.git_graph_picker_items();
assert!(items[0].1, "パネル先頭は HEAD(is_current)");
app.git_graph_picker_sel = 0;
app.git_graph_picker_toggle(); assert!(
app.git_graph_picker_items()[0].2,
"HEAD はトグルしても表示のまま"
);
app.git_graph_picker_all();
app.git_graph_picker_apply();
assert!(!app.is_git_graph_picker(), "適用でパネルが閉じる");
assert_eq!(app.git_graph_hidden_count(), 0, "全表示で非表示0");
assert_eq!(app.git_graph_legend().len(), 6, "全6ブランチが凡例に出る");
std::fs::remove_dir_all(&dir).ok();
}
#[cfg(feature = "git")]
#[test]
fn open_git_view_lists_changes_and_stage_reloads() {
let dir = std::env::temp_dir().join("konoma_app_git_view");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
init_git_repo(&dir);
std::fs::write(dir.join("tracked.txt"), b"v1\n").unwrap();
let out = std::process::Command::new("git")
.current_dir(&dir)
.args(["add", "-A"])
.output()
.unwrap();
assert!(out.status.success());
let out = std::process::Command::new("git")
.current_dir(&dir)
.args(["commit", "-m", "init"])
.output()
.unwrap();
assert!(out.status.success());
std::fs::write(dir.join("tracked.txt"), b"v2\n").unwrap(); std::fs::write(dir.join("untracked.txt"), b"new\n").unwrap();
let mut app = App::new(dir.canonicalize().unwrap(), Config::default()).unwrap();
app.open_git_view();
assert!(app.is_git_view(), "Git ビューが開く");
assert_eq!(app.git_view_entries().len(), 2, "変更2件");
assert!(
app.git_view_entries().iter().all(|e| !e.staged),
"初期は全て未ステージ"
);
app.git_view_sel = 0;
let target = app.git_view_selected().unwrap();
app.git_view_stage();
let staged_now = app
.git_view_entries()
.iter()
.find(|e| e.path == target)
.map(|e| e.staged)
.unwrap_or(false);
assert!(staged_now, "ステージ後は staged=true (reload 反映)");
app.close_git_view();
assert!(!app.is_git_view(), "閉じる");
std::fs::remove_dir_all(&dir).ok();
}
#[cfg(feature = "git")]
#[test]
fn refresh_in_git_view_refetches_entries() {
let dir = std::env::temp_dir().join("konoma_refresh_git_view_refetch");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
init_git_repo(&dir);
std::fs::write(dir.join("tracked.txt"), b"v1\n").unwrap();
let git = |args: &[&str]| {
assert!(std::process::Command::new("git")
.current_dir(&dir)
.args(args)
.output()
.unwrap()
.status
.success());
};
git(&["add", "-A"]);
git(&["commit", "-m", "init"]);
std::fs::write(dir.join("tracked.txt"), b"v2\n").unwrap();
let mut app = App::new(dir.canonicalize().unwrap(), Config::default()).unwrap();
app.open_git_view();
assert!(app.is_git_view());
assert_eq!(app.git_view_entries().len(), 1, "変更1件(modified)");
std::fs::write(dir.join("untracked.txt"), b"new\n").unwrap();
app.refresh().unwrap();
assert_eq!(
app.git_view_entries().len(),
2,
"refresh で git_view_entries が再取得され untracked が増える"
);
std::fs::remove_dir_all(&dir).ok();
}
#[cfg(feature = "git")]
#[test]
fn git_view_stage_all_then_unstage_all() {
let dir = std::env::temp_dir().join("konoma_app_git_stage_all");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
init_git_repo(&dir);
let git = |args: &[&str]| {
std::process::Command::new("git")
.current_dir(&dir)
.args(args)
.output()
.unwrap();
};
std::fs::write(dir.join("a.txt"), b"v1\n").unwrap();
git(&["add", "-A"]);
git(&["commit", "-m", "init"]);
std::fs::write(dir.join("a.txt"), b"v2\n").unwrap(); std::fs::write(dir.join("b.txt"), b"new\n").unwrap();
let mut app = App::new(dir.canonicalize().unwrap(), Config::default()).unwrap();
app.open_git_view();
assert_eq!(app.git_view_entries().len(), 2, "変更2件");
assert!(
app.git_view_entries().iter().all(|e| !e.staged),
"初期は全未ステージ"
);
app.git_view_stage_all();
assert!(
app.git_view_entries().iter().all(|e| e.staged),
"全ステージ後は全 staged"
);
app.git_view_unstage_all();
assert!(
app.git_view_entries().iter().all(|e| !e.staged),
"全アンステージ後は全未ステージ"
);
std::fs::remove_dir_all(&dir).ok();
}
#[cfg(feature = "git")]
#[test]
fn git_diff_opens_from_view_and_esc_returns_to_view() {
let dir = std::env::temp_dir().join("konoma_app_gitdiff_roundtrip");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
init_git_repo(&dir);
std::fs::write(dir.join("a.txt"), b"v1\n").unwrap();
let sh = |args: &[&str]| {
let out = std::process::Command::new("git")
.current_dir(&dir)
.args(args)
.output()
.unwrap();
assert!(out.status.success());
};
sh(&["add", "-A"]);
sh(&["commit", "-m", "init"]);
std::fs::write(dir.join("a.txt"), b"v2\n").unwrap();
let canon = dir.canonicalize().unwrap();
let mut app = App::new(canon.clone(), Config::default()).unwrap();
app.open_git_view();
assert!(app.is_git_view());
let target = app.git_view_selected().unwrap();
app.open_git_diff(&target);
assert!(app.is_git_diff_preview(), "GitDiff プレビューになる");
assert_eq!(app.mode, Mode::Preview);
assert!(!app.is_git_view(), "Git ビューは閉じる");
assert!(!app.git_diff_lines().is_empty(), "diff 行が空");
assert!(app.diff_cache.is_some(), "diff キャッシュが張られる");
app.refresh().unwrap();
assert!(
app.diff_cache.is_none(),
"refresh で diff キャッシュが無効化される"
);
app.close_git_diff();
assert!(app.is_git_view(), "Esc で Git ビューへ戻る");
assert!(!app.is_git_diff_preview());
std::fs::remove_dir_all(&dir).ok();
}
#[cfg(feature = "git")]
#[test]
fn git_diff_discard_confirm_reverts_and_returns_to_view() {
let dir = std::env::temp_dir().join("konoma_app_gitdiff_discard");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
init_git_repo(&dir);
let f = dir.join("a.txt");
std::fs::write(&f, b"v1\n").unwrap();
let sh = |args: &[&str]| {
let out = std::process::Command::new("git")
.current_dir(&dir)
.args(args)
.output()
.unwrap();
assert!(out.status.success());
};
sh(&["add", "-A"]);
sh(&["commit", "-m", "init"]);
std::fs::write(&f, b"v2\n").unwrap();
let canon = dir.canonicalize().unwrap();
let mut app = App::new(canon.clone(), Config::default()).unwrap();
app.open_git_view();
let target = app.git_view_selected().unwrap();
app.open_git_diff(&target);
app.git_diff_start_discard();
assert!(app.is_dialog() && app.dialog_is_confirm(), "破棄確認が出る");
app.dialog_confirm(true).unwrap();
assert_eq!(std::fs::read_to_string(&f).unwrap(), "v1\n", "破棄で復元");
assert!(app.is_git_view(), "破棄後は Git ビューへ戻る");
assert!(!app.is_git_diff_preview());
assert!(
app.git_view_entries().is_empty(),
"破棄後はクリーン(変更0件)"
);
std::fs::remove_dir_all(&dir).ok();
}
#[cfg(feature = "git")]
#[test]
fn open_git_view_noop_when_not_a_repo() {
let dir = std::env::temp_dir().join("konoma_app_git_view_norepo");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let mut app = App::new(dir.canonicalize().unwrap(), Config::default()).unwrap();
app.open_git_view();
assert!(!app.is_git_view(), "repo でなければ開かない");
assert!(app.flash.is_some(), "flash で知らせる");
std::fs::remove_dir_all(&dir).ok();
}
#[cfg(feature = "git")]
#[test]
fn git_branches_list_checkout_and_create() {
use std::process::Command;
let dir = std::env::temp_dir().join("konoma_app_git_branch");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let git = |args: &[&str]| {
Command::new("git")
.current_dir(&dir)
.args(args)
.output()
.unwrap();
};
git(&["init", "-q"]);
git(&["config", "user.email", "t@t.t"]);
git(&["config", "user.name", "t"]);
std::fs::write(dir.join("a.txt"), b"x").unwrap();
git(&["add", "."]);
git(&["commit", "-q", "-m", "init"]);
git(&["branch", "feature"]); let mut app = App::new(dir.canonicalize().unwrap(), Config::default()).unwrap();
app.open_git_branches();
assert!(app.is_git_branches());
let view = app.git_branch_view();
let names: Vec<String> = view.iter().map(|b| b.name.clone()).collect();
assert!(
names.iter().any(|n| n == "feature"),
"feature が一覧に: {names:?}"
);
assert!(
view[app.git_branch_sel()].is_current,
"カーソルは現在ブランチ"
);
app.git_branch_start_filter();
for c in "feat".chars() {
app.git_branch_filter_push(c);
}
let filtered = app.git_branch_view();
assert_eq!(filtered.len(), 1, "絞り込みで1件");
assert_eq!(filtered[0].name, "feature");
app.git_branch_filter_clear();
assert_eq!(app.git_branch_view().len(), names.len(), "解除で全件へ戻る");
let fi = app
.git_branch_view()
.iter()
.position(|b| b.name == "feature")
.unwrap();
app.git_branch_move(fi as i32 - app.git_branch_sel() as i32);
app.checkout_selected_branch().unwrap();
assert!(!app.is_git_branches(), "checkout で一覧は閉じる");
assert_eq!(app.git_branch(), Some("feature"), "現在ブランチ=feature");
app.open_git_branches();
app.start_create_branch();
for c in "newbr".chars() {
app.dialog_input_push(c);
}
app.dialog_submit().unwrap();
assert_eq!(app.git_branch(), Some("newbr"), "作成して切替");
app.open_git_branches();
app.start_delete_branch();
assert!(!app.is_dialog(), "現在ブランチは削除確認を開かない");
let fi = app
.git_branch_view()
.iter()
.position(|b| b.name == "feature")
.unwrap();
app.git_branch_move(fi as i32 - app.git_branch_sel() as i32);
app.start_delete_branch();
assert!(app.is_dialog() && app.dialog_is_confirm(), "削除確認が開く");
app.dialog_confirm(true).unwrap(); assert!(
!app.git_branch_view().iter().any(|b| b.name == "feature"),
"feature が削除された"
);
std::fs::remove_dir_all(&dir).ok();
}
#[cfg(feature = "git")]
#[test]
fn git_commit_flow_creates_commit_with_message() {
let dir = std::env::temp_dir().join("konoma_app_git_commit_flow");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
init_git_repo(&dir);
let f = dir.join("a.txt");
std::fs::write(&f, b"hello\n").unwrap();
crate::git::stage(&dir, &f).unwrap();
let canon = dir.canonicalize().unwrap();
let mut app = App::new(canon.clone(), Config::default()).unwrap();
app.open_git_view();
assert!(app.is_git_view());
app.start_git_commit();
assert!(app.is_dialog(), "コミット入力ダイアログが出る");
assert_eq!(app.internal_mode(), Some(InternalMode::Commit));
for c in "feat: add a".chars() {
app.dialog_input_push(c);
}
app.dialog_submit().unwrap();
assert!(!app.is_dialog(), "成功するとダイアログは閉じる");
let entries = crate::git::log(&canon, 10);
assert_eq!(entries.len(), 1, "コミット1件のはず");
assert_eq!(entries[0].summary, "feat: add a");
std::fs::remove_dir_all(&dir).ok();
}
#[cfg(feature = "git")]
#[test]
fn git_commit_empty_message_is_rejected() {
let dir = std::env::temp_dir().join("konoma_app_git_commit_empty");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
init_git_repo(&dir);
let f = dir.join("a.txt");
std::fs::write(&f, b"hello\n").unwrap();
crate::git::stage(&dir, &f).unwrap();
let canon = dir.canonicalize().unwrap();
let mut app = App::new(canon.clone(), Config::default()).unwrap();
app.start_git_commit();
app.dialog_input_push(' ');
app.dialog_submit().unwrap();
assert!(
app.flash
.as_deref()
.is_some_and(|s| s.contains("empty") || s.contains("空")),
"空メッセージは flash で拒否: {:?}",
app.flash
);
assert!(
crate::git::log(&canon, 10).is_empty(),
"空メッセージではコミットされない"
);
std::fs::remove_dir_all(&dir).ok();
}
#[cfg(feature = "git")]
#[test]
fn git_log_lists_commits_and_detail_has_diff_lines() {
let dir = std::env::temp_dir().join("konoma_app_git_log_detail");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
init_git_repo(&dir);
let sh = |args: &[&str]| {
let out = std::process::Command::new("git")
.current_dir(&dir)
.args(args)
.output()
.unwrap();
assert!(out.status.success(), "git {args:?} 失敗");
};
std::fs::write(dir.join("a.txt"), b"one\n").unwrap();
sh(&["add", "-A"]);
sh(&["commit", "-m", "first commit"]);
std::fs::write(dir.join("a.txt"), b"one\ntwo\n").unwrap();
sh(&["add", "-A"]);
sh(&["commit", "-m", "second commit"]);
let canon = dir.canonicalize().unwrap();
let mut app = App::new(canon.clone(), Config::default()).unwrap();
app.open_git_view();
app.open_git_log();
assert!(app.is_git_log(), "git log が開く");
assert!(!app.is_git_view(), "log を開くと Git ビューは閉じる");
assert_eq!(app.git_log_entries().len(), 2, "コミット2件");
assert_eq!(app.git_log_entries()[0].summary, "second commit");
assert_eq!(app.internal_mode(), Some(InternalMode::GitLog));
app.git_log_move(1);
assert_eq!(app.git_log_sel(), 1);
app.open_git_commit_detail();
assert!(app.is_git_detail(), "コミット詳細が開く");
assert_eq!(app.internal_mode(), Some(InternalMode::GitDetail));
let lines = app.git_detail_lines();
assert!(!lines.is_empty(), "コミット詳細の diff 行が空");
assert!(
lines
.iter()
.any(|l| l.kind == crate::git::DiffLineKind::Added),
"Added 行が無い: {lines:?}"
);
app.close_git_detail();
assert!(!app.is_git_detail());
assert!(app.is_git_log(), "詳細を閉じると log へ戻る");
app.close_git_log();
assert!(!app.is_git_log());
assert!(app.is_git_view(), "log を閉じると Git ビューへ戻る");
std::fs::remove_dir_all(&dir).ok();
}
#[cfg(feature = "git")]
#[test]
fn open_git_log_noop_when_no_commits() {
let dir = std::env::temp_dir().join("konoma_app_git_log_unborn");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
init_git_repo(&dir); let canon = dir.canonicalize().unwrap();
let mut app = App::new(canon, Config::default()).unwrap();
app.open_git_log();
assert!(!app.is_git_log(), "コミットが無ければ log は開かない");
std::fs::remove_dir_all(&dir).ok();
}
fn write_png(path: &Path, w: u32, h: u32) {
let img = image::RgbImage::from_pixel(w, h, image::Rgb([10, 20, 30]));
image::DynamicImage::ImageRgb8(img).save(path).unwrap();
}
#[test]
fn path_style_next_cycles_and_cycle_path_style_advances() {
assert_eq!(PathStyle::Relative.next(), PathStyle::Home);
assert_eq!(PathStyle::Home.next(), PathStyle::Full);
assert_eq!(PathStyle::Full.next(), PathStyle::Relative);
let dir = std::env::temp_dir().join("konoma_cycle_pathstyle_test");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let mut app = App::new(dir.clone(), Config::default()).unwrap();
let s0 = app.path_style;
app.cycle_path_style();
assert_eq!(app.path_style, s0.next(), "cycle は next() を適用する");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn toggle_hidden_reveals_and_hides_dotfiles() {
let dir = std::env::temp_dir().join("konoma_toggle_hidden_app_test");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("visible.txt"), b"x").unwrap();
std::fs::write(dir.join(".secret"), b"x").unwrap();
let mut app = App::new(dir.clone(), Config::default()).unwrap();
app.rebuild_tree().unwrap();
let has_dot = |a: &App| a.entries.iter().any(|e| e.path.ends_with(".secret"));
assert!(!app.show_hidden && !has_dot(&app), "既定では隠しは出ない");
app.toggle_hidden().unwrap();
assert!(
app.show_hidden && has_dot(&app),
"トグルで .secret が現れる"
);
app.toggle_hidden().unwrap();
assert!(!app.show_hidden && !has_dot(&app), "もう一度で隠れる");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn sort_menu_open_and_close_toggles_flag() {
let dir = std::env::temp_dir().join("konoma_sortmenu_close_test");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let mut app = App::new(dir.clone(), Config::default()).unwrap();
assert!(!app.is_sort_menu());
app.open_sort_menu();
assert!(app.is_sort_menu());
app.close_sort_menu();
assert!(!app.is_sort_menu(), "close_sort_menu でフラグが下りる");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn looks_like_gif_uses_magic_not_extension() {
let dir = std::env::temp_dir().join("konoma_looks_like_gif_test");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("a.gif"), b"GIF89a\x00\x00").unwrap();
std::fs::write(dir.join("old.gif"), b"GIF87a\x00\x00").unwrap();
std::fs::write(dir.join("fake.gif"), b"not a gif at all").unwrap();
std::fs::write(dir.join("real.bin"), b"GIF89a binary").unwrap();
assert!(App::looks_like_gif(&dir.join("a.gif")));
assert!(App::looks_like_gif(&dir.join("old.gif")));
assert!(
!App::looks_like_gif(&dir.join("fake.gif")),
"中身が GIF でなければ false"
);
assert!(
App::looks_like_gif(&dir.join("real.bin")),
".gif でなくてもマジックがあれば true"
);
assert!(
!App::looks_like_gif(&dir.join("nope")),
"存在しないファイルは false"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn keymap_report_none_default_and_formats_conflicts_and_warnings() {
use crate::keymap::{ConflictKind, KeyConflict, KeyPress, Surface};
let dir = std::env::temp_dir().join("konoma_keymap_report_test");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let mut app = App::new(dir.clone(), Config::default()).unwrap();
assert!(app.keymap_report().is_none(), "既定では報告なし");
app.keymaps.conflicts.push(KeyConflict {
surface: Surface::Tree,
key: KeyPress::ch('x'),
kept: "kept".into(),
dropped: "dropped".into(),
reason: ConflictKind::GlobalShadow,
});
app.keymaps.warnings.push("bad setting".into());
app.lang = crate::i18n::Lang::En;
let en = app.keymap_report().expect("衝突/警告があれば Some");
assert!(en.contains("1 key conflict"), "英語の衝突件数: {en}");
assert!(
en.contains("1 invalid key setting"),
"英語の無効設定件数: {en}"
);
app.lang = crate::i18n::Lang::Jp;
let jp = app.keymap_report().expect("Some");
assert!(jp.contains("キー衝突1件"), "日本語の衝突件数: {jp}");
assert!(jp.contains("無効な設定1件"), "日本語の無効設定件数: {jp}");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn take_warm_job_returns_once_then_none() {
let dir = std::env::temp_dir().join("konoma_take_warm_job_test");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let mut app = App::new(dir.clone(), Config::default()).unwrap();
app.preview_path = Some(dir.join("main.rs"));
app.hl_pending = true;
app.hl_warming = false;
let job = app.take_warm_job().expect("起動対象を1度だけ返す");
assert_eq!(job.0, "rs", "拡張子");
assert_eq!(job.1, dir.join("main.rs"));
assert!(app.take_warm_job().is_none(), "二重起動しない");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn launch_git_tool_sets_pending_flag_and_take_clears_it() {
let dir = std::env::temp_dir().join("konoma_launch_git_tool_test");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let mut app = App::new(dir.clone(), Config::default()).unwrap();
assert!(!app.take_launch_git_tool(), "初期は要求なし");
app.launch_git_tool(); assert!(app.take_launch_git_tool(), "要求が立つ");
assert!(!app.take_launch_git_tool(), "take で消費される");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn preview_scroll_paging_and_to_top_non_windowed() {
let dir = std::env::temp_dir().join("konoma_preview_scroll_test");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let mut app = App::new(dir.clone(), Config::default()).unwrap();
app.preview_viewport = 20;
app.preview_scroll(5);
assert_eq!(app.preview_scroll, 5);
app.preview_scroll(-100);
assert_eq!(app.preview_scroll, 0, "下限 0 でクランプ");
app.preview_page(1); assert_eq!(app.preview_scroll, 19);
app.preview_half_page(1); assert_eq!(app.preview_scroll, 29);
app.preview_page(-1); assert_eq!(app.preview_scroll, 10);
app.preview_to_top();
assert_eq!(app.preview_scroll, 0, "to_top で先頭へ");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn preview_hscroll_moves_and_home_end() {
let dir = std::env::temp_dir().join("konoma_preview_hscroll_test");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let mut app = App::new(dir.clone(), Config::default()).unwrap();
app.preview_hscroll(8);
assert_eq!(app.preview_hscroll, 8);
app.preview_hscroll(-100);
assert_eq!(app.preview_hscroll, 0, "下限 0");
app.preview_hscroll_end();
assert_eq!(app.preview_hscroll, u16::MAX, "$ は上限(描画側でクランプ)");
app.preview_hscroll_home();
assert_eq!(app.preview_hscroll, 0, "0 で行頭");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn windowed_text_preview_reads_window_and_scrolls_lines() {
let dir = std::env::temp_dir().join("konoma_windowed_lines_test");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let mut content = String::new();
for i in 0..200 {
content.push_str(&format!("line {i}\n"));
}
std::fs::write(dir.join("big.txt"), content.as_bytes()).unwrap();
let mut app = App::new(dir.clone(), Config::default()).unwrap();
app.rebuild_tree().unwrap();
let i = app
.entries
.iter()
.position(|e| e.path.ends_with("big.txt"))
.unwrap();
app.selected = i;
app.tree_activate().unwrap(); assert!(app.is_windowed(), "テキストは窓読みモード");
app.preview_viewport = 10;
let first_line = |a: &mut App| -> String {
a.windowed_lines(10, 80)[0]
.spans
.iter()
.map(|s| s.content.as_ref())
.collect()
};
assert!(first_line(&mut app).contains("line 0"), "先頭は line 0");
assert_eq!(app.preview_byte_top, 0);
app.preview_scroll(5); assert_eq!(app.preview_cursor_line, 5, "カーソルは 5 行目");
assert_eq!(app.preview_byte_top, 0, "窓内移動では窓は動かない");
assert!(
first_line(&mut app).contains("line 0"),
"先頭は line 0 のまま"
);
app.preview_scroll(5); assert_eq!(app.preview_cursor_line, 10, "カーソルは 10 行目");
assert!(
app.preview_byte_top > 0,
"下端超えで窓が追従(byte_top 前進)"
);
assert!(
first_line(&mut app).contains("line 1"),
"line 10 が見えるよう先頭は line 1"
);
app.preview_to_top();
assert_eq!(app.preview_cursor_line, 0);
assert_eq!(app.preview_byte_top, 0);
assert_eq!(app.preview_top_line, 0);
assert!(
first_line(&mut app).contains("line 0"),
"to_top で先頭へ戻る"
);
app.preview_to_bottom();
assert_eq!(app.preview_cursor_line, 199, "to_bottom で最終行へ");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn preview_visual_selection_copies_logical_lines() {
let dir = std::env::temp_dir().join("konoma_preview_visual_test");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let content = (0..20)
.map(|i| format!("line {i}"))
.collect::<Vec<_>>()
.join("\n");
std::fs::write(dir.join("code.txt"), content.as_bytes()).unwrap();
let mut app = App::new(dir.clone(), Config::default()).unwrap();
app.rebuild_tree().unwrap();
let i = app
.entries
.iter()
.position(|e| e.path.ends_with("code.txt"))
.unwrap();
app.selected = i;
app.tree_activate().unwrap();
assert!(app.is_windowed(), "テキストは窓読み(行カーソル有効)");
app.preview_viewport = 10;
app.preview_scroll(2);
assert_eq!(app.preview_cursor_line, 2);
assert!(!app.is_preview_visual(), "まだ選択していない");
assert_eq!(app.surface(), crate::keymap::Surface::PreviewText);
app.preview_enter_visual(true);
assert!(app.is_preview_visual(), "選択モードに入る");
assert!(app.preview_visual_linewise(), "V は行選択");
assert_eq!(app.surface(), crate::keymap::Surface::PreviewTextVisual);
assert_eq!(
app.internal_mode(),
Some(crate::app::InternalMode::PreviewVisual)
);
app.preview_scroll(2); assert_eq!(
app.preview_selection(),
crate::app::PreviewSelection::Line { lo: 2, hi: 4 }
);
assert_eq!(app.preview_selection_text(), "line 2\nline 3\nline 4");
app.preview_copy_selection();
assert!(!app.is_preview_visual(), "コピー後は選択解除");
app.preview_to_top(); app.preview_scroll(5); app.preview_enter_visual(true);
app.preview_scroll(-3); assert_eq!(
app.preview_selection(),
crate::app::PreviewSelection::Line { lo: 2, hi: 5 }
);
assert_eq!(
app.preview_selection_text(),
"line 2\nline 3\nline 4\nline 5"
);
app.preview_exit_visual();
assert!(!app.is_preview_visual());
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn preview_charwise_selection_copies_character_range() {
let dir = std::env::temp_dir().join("konoma_preview_charwise_test");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let content = (0..6)
.map(|_| "abcdefghij".to_string())
.collect::<Vec<_>>()
.join("\n");
std::fs::write(dir.join("code.txt"), content.as_bytes()).unwrap();
let mut app = App::new(dir.clone(), Config::default()).unwrap();
app.rebuild_tree().unwrap();
let i = app
.entries
.iter()
.position(|e| e.path.ends_with("code.txt"))
.unwrap();
app.selected = i;
app.tree_activate().unwrap();
assert!(app.is_windowed());
app.preview_viewport = 10;
app.preview_col_move(1); app.preview_col_move(1); app.preview_enter_visual(false); assert!(app.is_preview_visual());
assert!(!app.preview_visual_linewise(), "v は文字選択");
app.preview_col_move(1); app.preview_col_move(1); app.preview_col_move(1); assert_eq!(
app.preview_selection(),
crate::app::PreviewSelection::Char {
start: (0, 2),
end: (0, 5)
}
);
assert_eq!(app.preview_selection_text(), "cdef");
app.preview_scroll(2); app.preview_col_home(); app.preview_col_move(1); app.preview_col_move(1); app.preview_col_move(1); assert_eq!(
app.preview_selection(),
crate::app::PreviewSelection::Char {
start: (0, 2),
end: (2, 3)
}
);
assert_eq!(app.preview_selection_text(), "cdefghij\nabcdefghij\nabcd");
app.preview_exit_visual();
app.preview_to_top();
app.preview_scroll(3); app.preview_col_home();
app.preview_col_move(1); app.preview_enter_visual(false); app.preview_scroll(-1); app.preview_col_move(1); assert_eq!(
app.preview_selection(),
crate::app::PreviewSelection::Char {
start: (2, 2),
end: (3, 1)
}
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn markdown_raw_toggle_enables_windowed_selection() {
let dir = std::env::temp_dir().join("konoma_md_raw_test");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(
dir.join("doc.md"),
b"# Title\n\nSome **bold** text.\n\n- item one\n- item two\n",
)
.unwrap();
let mut app = App::new(dir.clone(), Config::default()).unwrap();
app.rebuild_tree().unwrap();
let i = app
.entries
.iter()
.position(|e| e.path.ends_with("doc.md"))
.unwrap();
app.selected = i;
app.tree_activate().unwrap();
assert!(app.is_decorated_kind(), "Markdown は装飾種別");
assert!(!app.is_windowed(), "装飾表示は windowed でない");
assert!(!app.is_raw_source());
assert_eq!(app.surface(), crate::keymap::Surface::PreviewText);
app.toggle_md_raw();
assert!(app.is_raw_source(), "raw ソース表示に切替");
assert!(app.is_windowed(), "raw は windowed(選択可)");
app.preview_viewport = 10;
app.preview_enter_visual(false); for _ in 0..6 {
app.preview_col_move(1); }
assert_eq!(app.preview_selection_text(), "# Title");
app.preview_exit_visual();
app.toggle_md_raw();
assert!(!app.is_raw_source(), "装飾表示へ戻る");
assert!(!app.is_windowed());
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn dialog_cursor_right_and_end_clamp() {
let dir = std::env::temp_dir().join("konoma_dialog_cursor_re_test");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("a.txt"), b"x").unwrap();
let mut app = App::new(dir.clone(), Config::default()).unwrap();
app.rebuild_tree().unwrap();
app.start_create();
for c in "abc".chars() {
app.dialog_input_push(c);
}
app.dialog_cursor_home();
app.dialog_cursor_right(); app.dialog_input_push('X'); assert_eq!(
app.dialog_view().map(|v| v.2.to_string()),
Some("aXbc".into())
);
app.dialog_cursor_right(); for _ in 0..10 {
app.dialog_cursor_right();
}
app.dialog_input_push('Z'); assert_eq!(
app.dialog_view().map(|v| v.2.to_string()),
Some("aXbcZ".into())
);
app.dialog_cursor_home();
app.dialog_cursor_end();
app.dialog_input_push('!'); assert_eq!(
app.dialog_view().map(|v| v.2.to_string()),
Some("aXbcZ!".into())
);
app.dialog_cancel();
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn set_gif_frames_and_advance_when_due() {
let mut app = app_with_image();
let frame = |c: [u8; 3]| {
image::DynamicImage::ImageRgb8(image::RgbImage::from_pixel(8, 8, image::Rgb(c)))
};
app.set_gif_frames(vec![(
frame([1, 2, 3]),
std::time::Duration::from_millis(50),
)]);
assert!(!app.is_gif_active());
assert!(!app.advance_gif_if_due(), "単一フレームは進めない");
app.set_gif_frames(vec![
(frame([255, 0, 0]), std::time::Duration::from_millis(50)),
(frame([0, 255, 0]), std::time::Duration::from_millis(50)),
]);
assert!(app.is_gif_active());
assert!(!app.advance_gif_if_due(), "最初の tick は計時開始だけ");
assert_eq!(app.gif_idx, 0);
app.gif_shown_at = Some(std::time::Instant::now() - std::time::Duration::from_secs(1));
assert!(app.advance_gif_if_due(), "期限超過で次フレームへ");
assert_eq!(app.gif_idx, 1);
}
#[test]
fn gif_poll_timeout_none_without_anim_some_when_playing() {
use std::time::Duration;
let mut app = app_with_image();
assert!(app.gif_poll_timeout().is_none(), "アニメでなければ None");
let frame =
image::DynamicImage::ImageRgb8(image::RgbImage::from_pixel(4, 4, image::Rgb([0; 3])));
app.gif_frames = vec![
(frame.clone(), Duration::from_millis(50)),
(frame, Duration::from_millis(50)),
];
let t = app.gif_poll_timeout().expect("再生中は Some");
assert!(
t >= Duration::from_millis(10) && t <= Duration::from_millis(100),
"10〜100ms にクランプ: {t:?}"
);
}
#[test]
fn apply_image_resize_err_is_ignored() {
let mut app = app_with_image();
let r = app.apply_image_resize(Err(ratatui_image::errors::Errors::NoCap));
assert!(!r, "Err は反映しない");
}
#[test]
fn attach_and_detach_image_backend_set_and_clear_state() {
let dir = std::env::temp_dir().join("konoma_attach_detach_img_test");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let mut app = App::new(dir.clone(), Config::default()).unwrap();
assert!(app.picker.is_none() && app.img_tx.is_none());
let picker = ratatui_image::picker::Picker::halfblocks();
let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<ratatui_image::thread::ResizeRequest>();
Box::leak(Box::new(rx));
app.attach_image_backend(picker, tx);
assert!(
app.picker.is_some() && app.img_tx.is_some(),
"attach で両方載る"
);
app.image_src = Some(image::DynamicImage::new_rgb8(4, 4));
app.detach_image_backend();
assert!(app.img_tx.is_none(), "detach で tx を落とす(ワーカー終了)");
assert!(app.image_src.is_none(), "detach で画像状態も解放");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn load_image_decodes_with_backend_and_noops_without() {
let dir = std::env::temp_dir().join("konoma_load_image_test");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let png = dir.join("tiny.png");
write_png(&png, 20, 10);
let mut bare = App::new(dir.clone(), Config::default()).unwrap();
bare.load_image(&png);
assert!(bare.image_src.is_none(), "picker/tx 無しでは load しない");
let mut app = app_with_image();
app.image_src = None; app.load_image(&png);
let img = app.image_src.as_ref().expect("PNG をデコードして載せる");
assert_eq!((img.width(), img.height()), (20, 10));
app.image_src = None;
std::fs::write(dir.join("notimg.png"), b"this is not a png").unwrap();
app.load_image(&dir.join("notimg.png"));
assert!(app.image_src.is_none(), "デコード失敗時は載せない");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn open_link_target_handles_anchor_missing_file_and_dir() {
let base = std::env::temp_dir().join("konoma_open_link_target_test");
let _ = std::fs::remove_dir_all(&base);
std::fs::create_dir_all(base.join("sub")).unwrap();
std::fs::write(base.join("a.md"), b"# doc").unwrap();
std::fs::write(base.join("b.txt"), b"hello").unwrap();
let base = base.canonicalize().unwrap();
let mut app = App::new(base.clone(), Config::default()).unwrap();
app.rebuild_tree().unwrap();
app.preview_path = Some(base.join("a.md"));
app.preview_kind = Some(PreviewKind::Markdown(base.join("a.md")));
app.mode = Mode::Preview;
app.open_link_target("#section").unwrap();
assert_eq!(
app.flash.as_deref(),
Some(tr(app.lang, crate::i18n::Msg::AnchorsUnsupported)),
"アンカーは未対応通知"
);
app.preview_path = Some(base.join("a.md"));
app.open_link_target("does_not_exist.zz").unwrap();
assert!(
app.flash
.as_deref()
.unwrap()
.contains(tr(app.lang, crate::i18n::Msg::NotFound)),
"見つからない: {:?}",
app.flash
);
app.preview_path = Some(base.join("a.md"));
app.open_link_target("b.txt").unwrap();
assert_eq!(
app.preview_path
.as_ref()
.map(|p| p.file_name().unwrap().to_owned()),
Some(std::ffi::OsString::from("b.txt")),
"ファイルリンクで b.txt をプレビュー"
);
app.preview_path = Some(base.join("a.md"));
app.mode = Mode::Preview;
app.open_link_target("sub").unwrap();
assert_eq!(app.root, base.join("sub"), "ディレクトリリンクで root=sub");
assert_eq!(app.mode, Mode::Tree, "ディレクトリは Tree へ戻る");
std::fs::remove_dir_all(&base).ok();
}
#[test]
fn copy_target_follows_mode() {
let dir = std::env::temp_dir().join("konoma_copy_target_test");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("a.txt"), b"x").unwrap();
let mut app = App::new(dir.clone(), Config::default()).unwrap();
app.rebuild_tree().unwrap();
let i = app
.entries
.iter()
.position(|e| e.path.ends_with("a.txt"))
.unwrap();
app.selected = i;
assert_eq!(app.copy_target(), Some(dir.join("a.txt")));
app.mode = Mode::Preview;
app.preview_path = Some(dir.join("p.txt"));
assert_eq!(app.copy_target(), Some(dir.join("p.txt")));
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn tab_label_shows_root_dir_or_preview_file_name() {
let dir = std::env::temp_dir()
.join("konoma_tab_label_name_test")
.join("proj");
let _ = std::fs::remove_dir_all(dir.parent().unwrap());
std::fs::create_dir_all(&dir).unwrap();
let mut app = App::new(dir.clone(), Config::default()).unwrap();
let active = app.active_tab_index();
assert_eq!(app.tab_label(active), "proj");
assert_eq!(app.tab_label(999), "");
app.mode = Mode::Preview;
app.preview_path = Some(dir.join("file.md"));
assert_eq!(app.tab_label(active), "file.md");
std::fs::remove_dir_all(dir.parent().unwrap()).ok();
}
#[cfg(feature = "git")]
fn git_repo_with_commits(dir: &Path) {
init_git_repo(dir);
let sh = |args: &[&str]| {
let out = std::process::Command::new("git")
.current_dir(dir)
.args(args)
.output()
.unwrap();
assert!(out.status.success(), "git {args:?} 失敗");
};
std::fs::write(dir.join("a.txt"), b"one\n").unwrap();
sh(&["add", "-A"]);
sh(&["commit", "-m", "first commit\n\nbody line"]);
std::fs::write(dir.join("a.txt"), b"one\ntwo\n").unwrap();
sh(&["add", "-A"]);
sh(&["commit", "-m", "second commit"]);
}
#[cfg(feature = "git")]
#[test]
fn current_commit_meta_resolves_per_surface_and_none_in_tree() {
let dir = std::env::temp_dir().join("konoma_current_commit_meta_test");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
git_repo_with_commits(&dir);
let canon = dir.canonicalize().unwrap();
let mut app = App::new(canon.clone(), Config::default()).unwrap();
assert!(
app.current_commit_meta().is_none(),
"Tree ではコミットメタ無し"
);
app.open_git_view();
app.open_git_log();
let meta = app.current_commit_meta().expect("log の選択コミットメタ");
assert!(
meta.message.starts_with("second commit"),
"件名: {:?}",
meta.message
);
assert_eq!(
meta.short.len(),
meta.id.chars().take(meta.short.len()).count()
);
assert!(
!meta.id.is_empty() && meta.id.starts_with(&meta.short),
"完全ハッシュは短縮から始まる"
);
app.git_log_move(1);
app.open_git_commit_detail();
let dmeta = app.current_commit_meta().expect("detail のメタ");
assert!(
dmeta.message.starts_with("first commit"),
"詳細は first: {:?}",
dmeta.message
);
assert!(
dmeta.message.contains("body line"),
"完全メッセージ(本文)も含む"
);
std::fs::remove_dir_all(&dir).ok();
}
#[cfg(feature = "git")]
#[test]
fn git_copy_message_and_branch_name_set_flash() {
let dir = std::env::temp_dir().join("konoma_git_copy_flash_test");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
git_repo_with_commits(&dir);
let canon = dir.canonicalize().unwrap();
let mut app = App::new(canon, Config::default()).unwrap();
let copied = tr(app.lang, crate::i18n::Msg::CopiedPrefix);
let failed = tr(app.lang, crate::i18n::Msg::CopyFailed);
let no_target = tr(app.lang, crate::i18n::Msg::NoCopyTarget);
app.open_git_view();
app.open_git_log();
app.git_copy(GitCopyKind::Message);
let f = app.flash.clone().expect("コピーで flash");
assert!(
f.starts_with(copied) || f.starts_with(failed),
"copied/failed のどちらかを通知: {f:?}"
);
assert_ne!(f, no_target, "コミットがあるので no-target ではない");
app.close_git_log();
app.close_git_view();
app.git_branches = None;
app.git_copy_branch_name();
assert_eq!(
app.flash.as_deref(),
Some(no_target),
"ブランチ未選択は no-target"
);
std::fs::remove_dir_all(&dir).ok();
}
#[cfg(feature = "git")]
#[test]
fn git_copy_branch_name_copies_current_branch() {
let dir = std::env::temp_dir().join("konoma_git_copy_branch_test");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
git_repo_with_commits(&dir);
let canon = dir.canonicalize().unwrap();
let mut app = App::new(canon, Config::default()).unwrap();
app.open_git_branches();
let branch = app.git_branch_view()[app.git_branch_sel()].name.clone();
let no_target = tr(app.lang, crate::i18n::Msg::NoCopyTarget);
app.git_copy_branch_name();
let f = app.flash.clone().expect("flash");
assert_ne!(
f, no_target,
"選択ブランチがあるので no-target ではない: {f:?}"
);
assert!(!branch.is_empty(), "現在ブランチ名が取れる");
std::fs::remove_dir_all(&dir).ok();
}
#[cfg(feature = "git")]
#[test]
fn git_view_unstage_single_file_reloads() {
let dir = std::env::temp_dir().join("konoma_git_view_unstage_test");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
init_git_repo(&dir);
let sh = |args: &[&str]| {
std::process::Command::new("git")
.current_dir(&dir)
.args(args)
.output()
.unwrap();
};
std::fs::write(dir.join("a.txt"), b"v1\n").unwrap();
sh(&["add", "-A"]);
sh(&["commit", "-m", "init"]);
std::fs::write(dir.join("a.txt"), b"v2\n").unwrap();
let canon = dir.canonicalize().unwrap();
let mut app = App::new(canon, Config::default()).unwrap();
app.open_git_view();
app.git_view_sel = 0;
let target = app.git_view_selected().unwrap();
app.git_view_stage();
assert!(
app.git_view_entries()
.iter()
.find(|e| e.path == target)
.unwrap()
.staged
);
app.git_view_unstage();
assert!(
!app.git_view_entries()
.iter()
.find(|e| e.path == target)
.unwrap()
.staged,
"unstage で staged=false に戻る"
);
std::fs::remove_dir_all(&dir).ok();
}
#[cfg(feature = "git")]
#[test]
fn git_view_start_discard_opens_confirm_without_destroying() {
let dir = std::env::temp_dir().join("konoma_git_view_start_discard_test");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
init_git_repo(&dir);
let sh = |args: &[&str]| {
std::process::Command::new("git")
.current_dir(&dir)
.args(args)
.output()
.unwrap();
};
std::fs::write(dir.join("a.txt"), b"v1\n").unwrap();
sh(&["add", "-A"]);
sh(&["commit", "-m", "init"]);
std::fs::write(dir.join("a.txt"), b"v2\n").unwrap();
let canon = dir.canonicalize().unwrap();
let mut app = App::new(canon, Config::default()).unwrap();
app.open_git_view();
app.git_view_start_discard();
assert!(
app.is_dialog() && app.dialog_is_confirm(),
"破棄は確認ダイアログ"
);
assert_eq!(
std::fs::read_to_string(dir.join("a.txt")).unwrap(),
"v2\n",
"確認段階では破棄しない"
);
app.dialog_cancel();
std::fs::remove_dir_all(&dir).ok();
}
#[cfg(feature = "git")]
#[test]
fn git_branch_filter_commit_and_backspace() {
use std::process::Command;
let dir = std::env::temp_dir().join("konoma_branch_filter_commit_test");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let git = |args: &[&str]| {
Command::new("git")
.current_dir(&dir)
.args(args)
.output()
.unwrap();
};
git(&["init", "-q"]);
git(&["config", "user.email", "t@t.t"]);
git(&["config", "user.name", "t"]);
std::fs::write(dir.join("a.txt"), b"x").unwrap();
git(&["add", "."]);
git(&["commit", "-q", "-m", "init"]);
git(&["branch", "feature"]);
git(&["branch", "fixup"]);
let mut app = App::new(dir.canonicalize().unwrap(), Config::default()).unwrap();
app.open_git_branches();
app.git_branch_start_filter();
app.git_branch_filter_push('f');
assert_eq!(app.git_branch_view().len(), 2, "f で feature/fixup の2件");
app.git_branch_filter_push('i');
assert_eq!(app.git_branch_view().len(), 1, "fi で fixup の1件に絞る");
app.git_branch_filter_backspace();
assert_eq!(app.git_branch_view().len(), 2, "backspace で2件へ広がる");
assert!(app.git_branch_filtering());
app.git_branch_filter_commit();
assert!(!app.git_branch_filtering(), "Enter で入力モードを抜ける");
assert_eq!(app.git_branch_query(), "f", "クエリは保持される");
std::fs::remove_dir_all(&dir).ok();
}
#[cfg(feature = "git")]
#[test]
fn graph_base_set_clear_and_detail_scroll_hscroll() {
let dir = std::env::temp_dir().join("konoma_graph_base_detail_scroll_test");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
git_repo_with_commits(&dir);
let canon = dir.canonicalize().unwrap();
let mut app = App::new(canon, Config::default()).unwrap();
app.open_git_graph();
assert!(app.is_git_graph());
assert!(app.git_graph_base_label().is_none(), "初期は基準なし");
app.git_graph_set_base();
assert!(
app.git_graph_base_label().is_some(),
"set_base で基準ラベルが付く"
);
app.git_graph_clear_base();
assert!(app.git_graph_base_label().is_none(), "clear_base で消える");
app.open_git_graph_detail();
assert!(app.is_git_detail());
app.set_git_detail_viewport(5);
app.set_git_detail_total(100);
assert_eq!(app.git_detail_scroll(), 0);
app.git_detail_scroll_by(10);
assert_eq!(app.git_detail_scroll(), 10, "下へ10");
app.git_detail_scroll_by(-3);
assert_eq!(app.git_detail_scroll(), 7, "上へ3");
app.git_detail_scroll_to(true); assert_eq!(app.git_detail_scroll(), 95, "G で末尾(クランプ)");
app.git_detail_scroll_to(false); assert_eq!(app.git_detail_scroll(), 0);
app.git_detail_hscroll_by(4);
assert_eq!(app.git_detail_hscroll(), 4);
app.git_detail_hscroll_home();
assert_eq!(app.git_detail_hscroll(), 0);
app.git_detail_hscroll_end();
assert_eq!(app.git_detail_hscroll(), u16::MAX);
app.clamp_git_detail_hscroll(12);
assert_eq!(app.git_detail_hscroll(), 12, "描画側の最大幅でクランプ");
std::fs::remove_dir_all(&dir).ok();
}
#[cfg(feature = "git")]
#[test]
fn graph_picker_move_jump_toggle_current_only_and_cancel() {
use std::process::Command;
let dir = std::env::temp_dir().join("konoma_graph_picker_ops_test");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let git = |args: &[&str]| {
Command::new("git")
.current_dir(&dir)
.args(args)
.output()
.unwrap();
};
init_git_repo(&dir);
std::fs::write(dir.join("a.txt"), b"a\n").unwrap();
git(&["add", "-A"]);
git(&["commit", "-q", "-m", "init"]);
git(&["branch", "-M", "trunk"]);
for n in ["f1", "f2", "f3"] {
git(&["checkout", "-q", "trunk"]);
git(&["checkout", "-q", "-b", n]);
std::fs::write(dir.join(format!("{n}.txt")), b"x\n").unwrap();
git(&["add", "-A"]);
git(&["commit", "-q", "-m", &format!("{n} c")]);
}
git(&["checkout", "-q", "trunk"]);
let mut cfg = Config::default();
cfg.ui.graph_max_branches = 10;
let mut app = App::new(dir.canonicalize().unwrap(), cfg).unwrap();
app.open_git_graph();
app.git_graph_open_picker();
assert!(app.is_git_graph_picker());
let n = app.git_graph_picker_items().len();
assert!(n >= 4, "4ブランチ以上");
app.git_graph_picker_move(2);
assert_eq!(app.git_graph_picker_sel(), 2);
app.git_graph_picker_jump(false);
assert_eq!(app.git_graph_picker_sel(), n - 1, "末尾へジャンプ");
app.git_graph_picker_jump(true);
assert_eq!(app.git_graph_picker_sel(), 0, "先頭へジャンプ");
app.git_graph_picker_current_only();
let on: Vec<String> = app
.git_graph_picker_items()
.into_iter()
.filter(|(_, _, on)| *on)
.map(|(n, _, _)| n)
.collect();
assert_eq!(
on,
vec!["trunk".to_string()],
"current only は HEAD だけ: {on:?}"
);
let non_head = app
.git_graph_picker_items()
.iter()
.position(|(_, is_cur, _)| !*is_cur)
.unwrap();
app.git_graph_picker_move(non_head as i32 - app.git_graph_picker_sel() as i32);
app.git_graph_picker_toggle();
assert!(
app.git_graph_picker_items()[non_head].2,
"非 HEAD はトグルで ON"
);
app.git_graph_picker_cancel();
assert!(!app.is_git_graph_picker(), "cancel でパネルが閉じる");
std::fs::remove_dir_all(&dir).ok();
}
#[cfg(feature = "git")]
#[test]
fn close_git_graph_returns_to_git_view() {
let dir = std::env::temp_dir().join("konoma_close_git_graph_test");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
git_repo_with_commits(&dir);
let canon = dir.canonicalize().unwrap();
let mut app = App::new(canon, Config::default()).unwrap();
app.open_git_graph();
assert!(app.is_git_graph());
app.close_git_graph();
assert!(!app.is_git_graph(), "グラフは閉じる");
assert!(app.is_git_view(), "閉じると変更ハブへ戻る");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn mark_set_state_and_cancel() {
let dir = std::env::temp_dir().join("konoma_mark_state_test");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let mut app = App::new(dir.clone(), Config::default()).unwrap();
assert!(!app.is_marking());
app.start_mark_set();
assert!(app.is_marking(), "m=登録待ち");
app.cancel_mark();
assert!(!app.is_marking(), "cancel_mark で待機解除");
app.flash = None;
app.mark_input('a');
assert!(app.flash.is_none());
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn bookmark_from_preview_registers_previewed_file() {
let root = std::env::temp_dir().join("konoma_bm_preview_test");
let _ = std::fs::remove_dir_all(&root);
let proj = root.join("proj");
std::fs::create_dir_all(&proj).unwrap();
std::fs::write(proj.join("a.txt"), b"a").unwrap();
std::fs::write(proj.join("b.txt"), b"b").unwrap();
let proj = proj.canonicalize().unwrap();
let mut app = App::new(proj.clone(), Config::default()).unwrap();
app.bookmarks = crate::bookmarks::Bookmarks::with_base(root.join("cfgbase"), &proj);
app.enter_preview(&proj.join("b.txt"));
app.selected = app
.entries
.iter()
.position(|e| e.path.ends_with("a.txt"))
.unwrap();
assert_eq!(app.mode, Mode::Preview);
app.start_mark_set();
app.mark_input('p');
assert_eq!(
app.bookmarks.get('p'),
Some(proj.join("b.txt")),
"カーソルの a.txt でなく表示中の b.txt が登録される"
);
assert_eq!(app.mode, Mode::Preview, "登録後もプレビューのまま");
app.back_to_tree();
app.start_mark_set();
app.mark_input('q');
assert_eq!(app.bookmarks.get('q'), Some(proj.join("a.txt")));
std::fs::remove_dir_all(&root).ok();
}
#[test]
fn global_bookmark_display_is_absolute() {
let dir = std::env::temp_dir().join("konoma_bm_display_test");
std::fs::create_dir_all(&dir).unwrap();
let mut app = App::new(dir.canonicalize().unwrap(), Config::default()).unwrap();
app.path_style = PathStyle::Relative;
if let Some(home) = std::env::var_os("HOME") {
let vimrc = PathBuf::from(home).join(".vimrc");
assert_eq!(
app.bookmark_display_path(false, &vimrc),
"~/.vimrc",
"グローバル=HOME 配下は ~ 短縮の絶対表示(相対 ../../.. にしない)"
);
assert!(
app.bookmark_display_path(true, &vimrc).contains(".vimrc"),
"ローカルは format_path(文脈相対)"
);
}
let outside = PathBuf::from("/opt/other/place");
assert_eq!(
app.bookmark_display_path(false, &outside),
"/opt/other/place",
"HOME 外はフル絶対のまま"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn tab_list_switch_close_and_guards() {
let dir = std::env::temp_dir().join("konoma_tab_list_test");
std::fs::create_dir_all(&dir).unwrap();
let mut app = App::new(dir.canonicalize().unwrap(), Config::default()).unwrap();
app.tab_new().unwrap();
app.tab_new().unwrap(); assert_eq!(app.tab_count(), 3);
assert_eq!(app.active_tab_index(), 2);
app.toggle_tab_list();
assert!(app.is_tab_list());
assert_eq!(app.tab_list_sel(), 2, "開いた時の選択はアクティブタブ");
app.tab_list_move(1);
assert_eq!(app.tab_list_sel(), 0, "末尾から wrap");
app.tab_list_move(-1);
assert_eq!(app.tab_list_sel(), 2);
app.tab_list_move(1); app.tab_list_activate();
assert!(!app.is_tab_list(), "切替で一覧が閉じる");
assert_eq!(app.active_tab_index(), 0);
app.toggle_tab_list();
app.tab_list_move(-1); app.tab_list_close_selected();
assert!(app.is_tab_list(), "w では一覧は開いたまま");
assert_eq!(app.tab_count(), 2);
assert_eq!(
app.active_tab_index(),
0,
"非アクティブを閉じてもアクティブ不変"
);
assert!(app.tab_list_sel() < app.tab_count(), "選択はクランプ");
app.tab_goto(1);
app.toggle_tab_list();
app.tab_list_move(-1); app.tab_list_close_selected();
assert_eq!(app.tab_count(), 1);
assert_eq!(app.active_tab_index(), 0, "前方を閉じたら index が詰まる");
app.flash = None;
app.tab_list_close_selected();
assert_eq!(app.tab_count(), 1);
assert!(app.flash.is_some(), "最後の1枚は拒否して flash");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn bookmark_list_jump_delete_and_close() {
let root = std::env::temp_dir().join("konoma_bm_list_ops_test");
let _ = std::fs::remove_dir_all(&root);
let proj = root.join("proj");
std::fs::create_dir_all(proj.join("sub")).unwrap();
std::fs::write(proj.join("f.txt"), b"x").unwrap();
let base = root.join("cfgbase");
let proj = proj.canonicalize().unwrap();
let mut app = App::new(proj.clone(), Config::default()).unwrap();
app.bookmarks = crate::bookmarks::Bookmarks::with_base(base.clone(), &proj);
app.bookmarks.set('a', proj.join("sub")).unwrap();
app.bookmarks.set('b', proj.join("f.txt")).unwrap();
app.open_bookmark_list();
assert!(app.is_bookmark_list());
assert_eq!(app.bookmark_list_items().len(), 2);
app.bookmark_list_sel = 0;
app.bookmark_list_jump();
assert!(!app.is_bookmark_list(), "ジャンプで一覧が閉じる");
assert_eq!(
app.root,
proj.join("sub"),
"ディレクトリへジャンプで root 変更"
);
app.open_bookmark_list();
let before = app.bookmark_list_items().len();
app.bookmark_list_sel = before - 1;
app.bookmark_list_delete();
assert_eq!(app.bookmark_list_items().len(), before - 1, "削除で1件減る");
assert!(app.is_bookmark_list(), "削除後も一覧は開いたまま");
assert!(app.bookmark_list_sel < app.bookmark_list_items().len().max(1));
app.close_bookmark_list();
assert!(!app.is_bookmark_list(), "close で閉じる");
std::fs::remove_dir_all(&root).ok();
}
#[test]
fn attach_git_loader_stores_channel() {
let dir = std::env::temp_dir().join("konoma_attach_git_loader_test");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let mut app = App::new(dir.clone(), Config::default()).unwrap();
assert!(app.ignored_tx.is_none(), "初期はチャネル未装着");
let (tx, rx) = std::sync::mpsc::channel::<IgnoredResult>();
Box::leak(Box::new(rx));
app.attach_git_loader(tx);
assert!(
app.ignored_tx.is_some(),
"attach_git_loader で Sender を保持"
);
std::fs::remove_dir_all(&dir).ok();
}
#[cfg(feature = "git")]
#[test]
fn refresh_git_status_only_updates_statuses_without_recompute() {
let dir = std::env::temp_dir().join("konoma_refresh_status_only_test");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
init_git_repo(&dir);
let sh = |args: &[&str]| {
std::process::Command::new("git")
.current_dir(&dir)
.args(args)
.output()
.unwrap();
};
std::fs::write(dir.join("a.txt"), b"v1\n").unwrap();
sh(&["add", "-A"]);
sh(&["commit", "-m", "init"]);
let canon = dir.canonicalize().unwrap();
let mut app = App::new(canon.clone(), Config::default()).unwrap();
app.refresh_git_if_needed(); assert!(!app.git_has_changes(), "コミット直後はクリーン");
std::fs::write(dir.join("a.txt"), b"v2\n").unwrap();
app.refresh_fs(false).unwrap();
assert!(app.git_has_changes(), "statuses のみ更新で変更を検知");
assert!(
app.git_status_of(&canon.join("a.txt")).is_some(),
"a.txt に status が付く"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn op_base_dir_for_file_dir_and_empty() {
let dir = std::env::temp_dir().join("konoma_op_base_dir_test");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("sub")).unwrap();
std::fs::write(dir.join("a.txt"), b"x").unwrap();
let mut app = App::new(dir.clone(), Config::default()).unwrap();
app.rebuild_tree().unwrap();
let si = app
.entries
.iter()
.position(|e| e.path.ends_with("sub"))
.unwrap();
app.selected = si;
assert_eq!(app.op_base_dir(), dir.join("sub"), "dir 選択はその中");
let ai = app
.entries
.iter()
.position(|e| e.path.ends_with("a.txt"))
.unwrap();
app.selected = ai;
assert_eq!(app.op_base_dir(), dir, "file 選択は親ディレクトリ");
app.entries.clear();
assert_eq!(app.op_base_dir(), app.root, "エントリ無しは root");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn dialog_preview_scroll_clamps_within_lines() {
let dir = std::env::temp_dir().join("konoma_dialog_preview_scroll_test");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
for n in ["a.txt", "b.txt", "c.txt"] {
std::fs::write(dir.join(n), b"x").unwrap();
}
let mut app = App::new(dir.clone(), Config::default()).unwrap();
app.rebuild_tree().unwrap();
app.visual_select_scope(true); app.start_batch_rename();
for c in "img_{n}".chars() {
app.dialog_input_push(c);
}
app.dialog_submit().unwrap();
assert!(app.dialog_is_preview(), "プレビューに遷移");
let n_lines = app.dialog_preview_view().unwrap().1.len();
assert!(n_lines >= 3);
app.dialog_preview_scroll(1000);
assert_eq!(
app.dialog_preview_view().unwrap().2,
n_lines - 1,
"末尾でクランプ"
);
app.dialog_preview_scroll(-1000);
assert_eq!(app.dialog_preview_view().unwrap().2, 0, "先頭でクランプ");
app.dialog_cancel();
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn ui_preview_renders_image_via_kitty_path() {
use ratatui::backend::TestBackend;
use ratatui::Terminal;
let mut app = app_with_kitty();
app.preview_kind = Some(PreviewKind::Image(PathBuf::from("x.gif")));
let frame = |c: [u8; 3]| {
image::DynamicImage::ImageRgb8(image::RgbImage::from_pixel(40, 30, image::Rgb(c)))
};
app.gif_frames = vec![
(frame([255, 0, 0]), std::time::Duration::from_millis(50)),
(frame([0, 255, 0]), std::time::Duration::from_millis(50)),
];
app.gif_idx = 0;
app.mode = Mode::Preview;
assert!(app.is_gif_active() && app.is_image_preview());
let mut term = Terminal::new(TestBackend::new(48, 20)).unwrap();
term.draw(|f| crate::ui::preview::render(f, &mut app, f.area()))
.unwrap();
let s: String = term
.backend()
.buffer()
.content()
.iter()
.map(|c| c.symbol())
.collect();
assert!(s.contains("_G"), "render_image が kitty 転送列を吐かない");
}
#[test]
fn ui_preview_image_falls_back_when_not_yet_encoded() {
use ratatui::backend::TestBackend;
use ratatui::Terminal;
let mut app = app_with_kitty();
app.preview_kind = Some(PreviewKind::Image(PathBuf::from("x.png")));
app.image_src = Some(image::DynamicImage::new_rgb8(20, 10));
app.mode = Mode::Preview;
let mut term = Terminal::new(TestBackend::new(60, 10)).unwrap();
term.draw(|f| crate::ui::preview::render(f, &mut app, f.area()))
.unwrap();
let s: String = term
.backend()
.buffer()
.content()
.iter()
.map(|c| c.symbol())
.collect();
assert!(s.contains("image"), "静止画フォールバック文言が出ない: {s}");
}
#[test]
fn ui_preview_renders_text_fallbacks_for_unsupported_kinds() {
use ratatui::backend::TestBackend;
use ratatui::Terminal;
let dir = std::env::temp_dir().join("konoma_ui_preview_fallback_test");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let mut app = App::new(dir.clone(), Config::default()).unwrap();
app.mode = Mode::Preview;
app.preview_path = Some(dir.join("thing.bin"));
let dump = |app: &mut App| -> String {
let mut term = Terminal::new(TestBackend::new(70, 10)).unwrap();
term.draw(|f| crate::ui::preview::render(f, app, f.area()))
.unwrap();
term.backend()
.buffer()
.content()
.iter()
.map(|c| c.symbol())
.collect()
};
app.preview_kind = Some(PreviewKind::CanNotPreview { ext: "bin".into() });
assert!(
dump(&mut app).contains("can not preview"),
"未対応フォールバックが出ない"
);
app.preview_kind = Some(PreviewKind::Video(dir.join("v.mp4")));
let video = dump(&mut app);
assert!(
video.contains("video") || video.contains("動画"),
"動画フォールバックが出ない: {video}"
);
app.preview_kind = Some(PreviewKind::Command {
path: dir.join("c.xyz"),
template: "mpv {path}".into(),
render_as: None,
detached: true,
});
assert!(
dump(&mut app).contains("command"),
"コマンド委譲表示が出ない"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn resolve_md_image_path_remote_uncached_and_data_are_none() {
assert!(
resolve_md_image_path("https://konoma.example/never-fetched-\u{2603}.png", None).is_none(),
"未取得のリモートは None"
);
assert!(resolve_md_image_path("data:image/png;base64,AAAA", None).is_none());
}
#[test]
fn md_remote_cache_path_is_stable_and_url_specific() {
let a1 = md_remote_cache_path("https://example.com/a.png");
let a2 = md_remote_cache_path("https://example.com/a.png");
let b = md_remote_cache_path("https://example.com/b.png");
if let (Some(a1), Some(a2), Some(b)) = (a1, a2, b) {
assert_eq!(a1, a2, "同一 URL は同一パス");
assert_ne!(a1, b, "異なる URL は異なるパス");
assert!(
a1.to_string_lossy().contains("remote-images"),
"remote-images 配下: {a1:?}"
);
}
}
#[test]
fn md_image_dims_reads_raster_and_svg() {
let dir = std::env::temp_dir().join("konoma_md_dims_test");
let _ = std::fs::create_dir_all(&dir);
let png = dir.join("p.png");
image::RgbaImage::from_pixel(4, 2, image::Rgba([1, 2, 3, 255]))
.save(&png)
.unwrap();
assert_eq!(md_image_dims(&png), Some((4, 2)), "PNG の寸法");
let svg = dir.join("noext");
std::fs::write(
&svg,
br#"<svg xmlns="http://www.w3.org/2000/svg" width="20" height="10"></svg>"#,
)
.unwrap();
assert_eq!(md_image_dims(&svg), Some((20, 10)), "拡張子なし SVG の寸法");
let bad = dir.join("bad");
std::fs::write(&bad, b"not an image").unwrap();
assert!(md_image_dims(&bad).is_none(), "非画像は None");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn md_decode_image_decodes_raster_and_rasterizes_svg() {
let dir = std::env::temp_dir().join("konoma_md_decode_test");
let _ = std::fs::create_dir_all(&dir);
let png = dir.join("p.png");
image::RgbaImage::from_pixel(3, 3, image::Rgba([9, 9, 9, 255]))
.save(&png)
.unwrap();
assert!(md_decode_image(&png, 800).is_some(), "PNG をデコード");
let svg = dir.join("noext");
std::fs::write(
&svg,
br##"<svg xmlns="http://www.w3.org/2000/svg" width="20" height="10"><rect width="20" height="10" fill="#0f0"/></svg>"##,
)
.unwrap();
let img = md_decode_image(&svg, 800).expect("SVG をラスタライズ");
assert_eq!((img.width(), img.height()), (800, 400), "最大辺 800 に拡大");
assert!(md_decode_image(&dir.join("missing"), 800).is_none());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn apply_remote_fetch_marks_failed_and_invalidates_cache() {
let dir = std::env::temp_dir().join("konoma_apply_remote_test");
std::fs::create_dir_all(&dir).unwrap();
let mut app = App::new(dir.clone(), Config::default()).unwrap();
let url = "https://example.com/x.png".to_string();
app.md_remote_inflight.insert(url.clone());
assert!(app.apply_remote_fetch(RemoteFetch {
url: url.clone(),
ok: false,
}));
assert!(!app.md_remote_inflight.contains(&url), "in-flight から除去");
assert!(app.md_remote_failed.contains(&url), "失敗を記録");
assert!(app.md_cache.is_none(), "装飾キャッシュを無効化");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn preview_survives_target_file_overwrite_and_delete() {
use image::RgbImage;
let dir = std::env::temp_dir().join("konoma_preview_file_vanish_test");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let pic = dir.join("plot.png");
RgbImage::new(4, 3).save(&pic).unwrap();
let mut app = App::new(dir.clone(), Config::default()).unwrap();
let idx = app
.entries
.iter()
.position(|e| e.path.ends_with("plot.png"))
.unwrap();
app.selected = idx;
app.tree_activate().unwrap();
assert!(matches!(
app.preview_kind,
Some(crate::preview::PreviewKind::Image(_))
));
RgbImage::new(8, 6).save(&pic).unwrap();
app.preview_media_mtime = None;
let _ = app.refresh();
std::fs::remove_file(&pic).unwrap();
app.preview_media_mtime = None;
let _ = app.refresh();
let doc = dir.join("doc.md");
std::fs::write(&doc, b"# hello\n\nbody\n").unwrap();
let mut app = App::new(dir.clone(), Config::default()).unwrap();
let idx = app
.entries
.iter()
.position(|e| e.path.ends_with("doc.md"))
.unwrap();
app.selected = idx;
app.tree_activate().unwrap();
let _ = app.decorated_lines(80);
std::fs::remove_file(&doc).unwrap();
let _ = app.refresh();
let _ = app.decorated_lines(80);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn md_band_pixels_stays_within_image_bounds() {
for &dh in &[1u32, 3, 100, 1000] {
for full_rows in [1u16, 2, 24, 60] {
for row_off in 0..full_rows {
for vis_rows in 1..=full_rows {
let (y0, h) = md_band_pixels(full_rows, row_off, vis_rows, dh);
assert!(h >= 1, "帯の高さは 1 以上");
assert!(
y0 + h <= dh,
"帯が画像外: dh={dh} full={full_rows} off={row_off} vis={vis_rows} → y0={y0} h={h}"
);
}
}
}
}
}
#[test]
fn gutter_marks_classifies_add_modify_delete() {
use crate::git::{DiffLine, DiffLineKind};
let ctx = |old, new| DiffLine {
kind: DiffLineKind::Context,
old_no: Some(old),
new_no: Some(new),
text: String::new(),
};
let add = |new| DiffLine {
kind: DiffLineKind::Added,
old_no: None,
new_no: Some(new),
text: String::new(),
};
let rem = |old| DiffLine {
kind: DiffLineKind::Removed,
old_no: Some(old),
new_no: None,
text: String::new(),
};
let m = gutter_marks(&[ctx(1, 1), add(2)]);
assert_eq!(m.get(&2), Some(&GutterMark::Added));
let m = gutter_marks(&[ctx(1, 1), rem(2), add(2), ctx(3, 3)]);
assert_eq!(m.get(&2), Some(&GutterMark::Modified));
let m = gutter_marks(&[ctx(3, 3), rem(4), ctx(5, 4)]);
assert_eq!(m.get(&4), Some(&GutterMark::Deleted));
let m = gutter_marks(&[ctx(3, 3), rem(4), rem(5)]);
assert_eq!(m.get(&3), Some(&GutterMark::Deleted));
assert!(gutter_marks(&[ctx(1, 1), ctx(2, 2)]).is_empty());
}
#[test]
fn git_gutter_prepends_marker_only_when_changed() {
let base = vec![Line::from("aaa"), Line::from("bbb")];
let out = with_git_gutter(base.clone(), 0, &std::collections::HashMap::new());
assert_eq!(out[0].to_string(), "aaa");
let mut marks = std::collections::HashMap::new();
marks.insert(2u32, GutterMark::Added);
let out = with_git_gutter(base, 0, &marks);
assert!(out[0].to_string().starts_with(' '), "変更なし行は空セル");
assert!(out[1].to_string().starts_with('▌'), "追加行は変更バー ▌");
let base = vec![Line::from("aaa"), Line::from("bbb")];
let mut marks = std::collections::HashMap::new();
marks.insert(2u32, GutterMark::Deleted);
let out = with_git_gutter(base, 0, &marks);
assert!(
out[1].to_string().starts_with('▔'),
"削除位置は上端バー ▔(行間の目印)"
);
}
fn app_with_table() -> (App, std::path::PathBuf) {
let dir = std::env::temp_dir().join("konoma_table_app_test");
std::fs::create_dir_all(&dir).unwrap();
let csv = dir.join("t.csv");
std::fs::write(&csv, "h1,h2,h3\na,b,c\nd,e,f\n").unwrap();
let mut app = App::new(dir.clone(), Config::default()).unwrap();
app.preview_kind = Some(app.cfg.resolve_preview(&csv));
app.preview_path = Some(csv.clone());
app.mode = Mode::Preview;
app.load_table();
(app, csv)
}
#[test]
fn csv_resolves_to_table_preview() {
let (app, _) = app_with_table();
assert!(
matches!(
app.preview_kind,
Some(PreviewKind::Table {
delimiter: b',',
..
})
),
"*.csv は Table(カンマ区切り)に解決される"
);
assert!(app.is_table_preview(), "パース成功でテーブル面が有効");
assert_eq!(app.surface(), crate::keymap::Surface::PreviewTable);
assert_eq!(app.display_mode(), crate::app::DisplayMode::Table);
}
#[test]
fn table_cursor_moves_and_clamps() {
let (mut app, _) = app_with_table();
assert_eq!(app.table_cursor(), (0, 0));
app.table_cursor_move(9, 9);
assert_eq!(app.table_cursor(), (1, 2));
app.table_cursor_move(-9, -9);
assert_eq!(app.table_cursor(), (0, 0));
app.table_row_to(true);
app.table_col_to(true);
assert_eq!(app.table_cursor(), (1, 2));
app.table_row_to(false);
app.table_col_to(false);
assert_eq!(app.table_cursor(), (0, 0));
}
#[test]
fn table_copy_text_cell_row_column() {
let (mut app, _) = app_with_table();
app.table_cursor_move(1, 1);
assert_eq!(
app.table_copy_text(TableCopyKind::Cell).as_deref(),
Some("e")
);
assert_eq!(
app.table_copy_text(TableCopyKind::Row).as_deref(),
Some("d,e,f")
);
assert_eq!(
app.table_copy_text(TableCopyKind::Column).as_deref(),
Some("h2\nb\ne")
);
}
#[test]
fn table_cursor_survives_tab_roundtrip() {
let (mut app, _) = app_with_table();
app.table_cursor_move(1, 2);
app.tab_new().unwrap(); app.tab_cycle(-1); assert!(
app.is_table_preview(),
"復元後もテーブルとして再パースされる"
);
assert_eq!(
app.table_cursor(),
(1, 2),
"セルカーソルがタブ跨ぎで保たれる"
);
}
#[test]
fn at_ref_is_strictly_relative_to_open_dir() {
let work = std::env::temp_dir().join("konoma_at_ref_test");
let _ = std::fs::remove_dir_all(&work);
let a = work.join("A");
std::fs::create_dir_all(&a).unwrap();
assert_eq!(
at_ref_text(&a, &a.join("src").join("main.rs")),
"@src/main.rs"
);
assert_eq!(at_ref_text(&a, &work.join("B").join("x.md")), "@../B/x.md");
std::fs::remove_dir_all(&work).ok();
}
#[test]
fn preview_selection_ref_formats_caret_and_ranges() {
let dir = std::env::temp_dir().join("konoma_sel_ref_test");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let file = dir.join("notes.txt");
std::fs::write(&file, b"l1\nl2\nl3\nl4\nl5\n").unwrap();
let mut app = App::new(dir.clone(), Config::default()).unwrap();
app.enter_preview(&file);
assert!(app.is_windowed(), "テキストは windowed");
app.preview_cursor_line = 2;
assert_eq!(
app.preview_selection_ref_text().as_deref(),
Some("@notes.txt#L3")
);
app.preview_enter_visual(true);
app.preview_cursor_line = 4;
assert_eq!(
app.preview_selection_ref_text().as_deref(),
Some("@notes.txt#L3-5")
);
app.preview_exit_visual();
app.preview_cursor_line = 1;
app.preview_cursor_col = 1;
app.preview_enter_visual(false);
app.preview_cursor_line = 3;
app.preview_cursor_col = 0;
assert_eq!(
app.preview_selection_ref_text().as_deref(),
Some("@notes.txt#L2-4")
);
std::fs::remove_dir_all(&dir).ok();
}
#[cfg(feature = "git")]
#[test]
fn changed_filter_lists_changed_files_flat_and_toggles_back() {
let dir = std::env::temp_dir().join("konoma_changed_filter_test");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
git_repo_with_commits(&dir); let canon = dir.canonicalize().unwrap();
std::fs::write(canon.join("a.txt"), b"changed\n").unwrap();
std::fs::create_dir_all(canon.join("sub")).unwrap();
std::fs::write(canon.join("sub").join("b.txt"), b"new\n").unwrap();
let mut app = App::new(canon.clone(), Config::default()).unwrap();
assert!(!app.changed_filter());
app.toggle_changed_filter();
assert!(app.changed_filter(), "変更があるので ON になる");
let paths: Vec<PathBuf> = app.entries.iter().map(|e| e.path.clone()).collect();
assert_eq!(
paths,
vec![canon.join("a.txt"), canon.join("sub").join("b.txt")],
"root 配下の変更ファイルのみ・パス順のフラット一覧"
);
assert!(app.entries.iter().all(|e| !e.is_dir), "ファイルのみ");
app.selected = 0;
app.tree_activate().unwrap();
assert!(matches!(app.mode, Mode::Preview));
assert_eq!(
app.preview_path.as_deref(),
Some(canon.join("a.txt").as_path())
);
app.back_to_tree();
app.tree_leave().unwrap();
assert!(!app.changed_filter());
assert_eq!(app.root, canon, "変更フィルタ解除であって親移動ではない");
assert!(
app.entries.iter().any(|e| e.is_dir),
"通常ツリーに戻る(ディレクトリ行が復活)"
);
std::fs::remove_dir_all(&dir).ok();
}
#[cfg(feature = "git")]
#[test]
fn jump_changed_reveals_collapsed_targets_and_wraps() {
let dir = std::env::temp_dir().join("konoma_jump_changed_test");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
git_repo_with_commits(&dir);
let canon = dir.canonicalize().unwrap();
std::fs::write(canon.join("a.txt"), b"changed\n").unwrap();
std::fs::create_dir_all(canon.join("sub")).unwrap();
std::fs::write(canon.join("sub").join("b.txt"), b"new\n").unwrap();
let mut app = App::new(canon.clone(), Config::default()).unwrap();
app.selected = 0; app.jump_changed(1);
assert_eq!(
app.entries[app.selected].path,
canon.join("sub").join("b.txt"),
"collapsed な sub/ を展開して選択(deep reveal)"
);
app.jump_changed(1);
assert_eq!(
app.entries[app.selected].path,
canon.join("a.txt"),
"末尾の次は先頭へ wrap"
);
app.jump_changed(-1);
assert_eq!(
app.entries[app.selected].path,
canon.join("sub").join("b.txt"),
"N は逆順"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn follow_jump_reveals_and_previews_only_valid_targets() {
let dir = std::env::temp_dir().join("konoma_follow_test");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("sub")).unwrap();
std::fs::create_dir_all(dir.join(".hidden")).unwrap();
std::fs::write(dir.join("a.txt"), b"a\n").unwrap();
std::fs::write(dir.join("sub").join("b.txt"), b"b\n").unwrap();
std::fs::write(dir.join(".hidden").join("c.txt"), b"c\n").unwrap();
let outside = std::env::temp_dir().join("konoma_follow_outside.txt");
std::fs::write(&outside, b"x\n").unwrap();
let mut app = App::new(dir.clone(), Config::default()).unwrap();
app.follow_jump(&dir.join("a.txt"));
assert!(matches!(app.mode, Mode::Tree));
app.toggle_follow();
assert!(app.follow_enabled());
app.follow_jump(&outside);
app.follow_jump(&dir.join(".hidden").join("c.txt"));
app.follow_jump(&dir.join("sub"));
assert!(matches!(app.mode, Mode::Tree), "無効ターゲットでは動かない");
app.follow_jump(&dir.join("sub").join("b.txt"));
assert!(matches!(app.mode, Mode::Preview));
assert_eq!(
app.preview_path.as_deref(),
Some(dir.join("sub").join("b.txt").as_path())
);
app.follow_jump(&dir.join("a.txt"));
assert_eq!(
app.preview_path.as_deref(),
Some(dir.join("a.txt").as_path())
);
app.follow_break();
assert!(!app.follow_enabled());
assert!(app.flash.is_some(), "解除は flash で見せる");
std::fs::remove_dir_all(&dir).ok();
std::fs::remove_file(&outside).ok();
}
#[cfg(feature = "git")]
#[test]
fn follow_jump_scrolls_to_first_changed_hunk() {
let dir = std::env::temp_dir().join("konoma_follow_scroll_test");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
init_git_repo(&dir);
let big = dir.join("big.txt");
let mut lines: Vec<String> = (1..=200).map(|i| format!("line {i:03}")).collect();
std::fs::write(&big, lines.join("\n")).unwrap();
let sh = |args: &[&str]| {
let out = std::process::Command::new("git")
.current_dir(&dir)
.args(args)
.output()
.unwrap();
assert!(out.status.success(), "git {args:?} 失敗");
};
sh(&["add", "-A"]);
sh(&["commit", "-m", "big"]);
lines[99] = "line 100 CHANGED".into(); std::fs::write(&big, lines.join("\n")).unwrap();
let canon = dir.canonicalize().unwrap();
let mut app = App::new(canon.clone(), Config::default()).unwrap();
app.cfg.ui.follow_view = "file".into(); app.toggle_follow();
app.follow_jump(&canon.join("big.txt"));
assert!(matches!(app.mode, Mode::Preview));
assert_eq!(
app.preview_top_line, 96,
"変更行(0-based 99)の3行上が窓の先頭"
);
assert_eq!(app.preview_cursor_line, 99, "キャレットは変更行");
assert!(app.preview_byte_top > 0, "窓はファイル先頭ではない");
let plain = canon.join("plain.txt");
std::fs::write(&plain, b"p1\np2\n").unwrap();
app.refresh().unwrap();
app.follow_jump(&plain);
assert_eq!(app.preview_path.as_deref(), Some(plain.as_path()));
assert_eq!(app.preview_top_line, 0);
std::fs::remove_dir_all(&dir).ok();
}
#[cfg(feature = "git")]
#[test]
fn follow_jump_opens_diff_view_by_default_and_falls_back() {
let dir = std::env::temp_dir().join("konoma_follow_diff_test");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
git_repo_with_commits(&dir); let canon = dir.canonicalize().unwrap();
std::fs::write(canon.join("a.txt"), b"one\nCHANGED\n").unwrap();
std::fs::write(canon.join("fresh.txt"), b"new file\n").unwrap();
let mut app = App::new(canon.clone(), Config::default()).unwrap();
assert_eq!(app.cfg.ui.follow_view, "diff", "既定は diff 表示");
app.toggle_follow();
app.follow_jump(&canon.join("a.txt"));
assert!(app.is_git_diff_preview(), "変更ファイルは diff で開く");
assert_eq!(
app.preview_path.as_deref(),
Some(canon.join("a.txt").as_path())
);
app.follow_jump(&canon.join("fresh.txt"));
assert!(
app.is_git_diff_preview(),
"未追跡も all-added の diff で開く"
);
assert_eq!(
app.preview_path.as_deref(),
Some(canon.join("fresh.txt").as_path()),
"diff ビューから次の変更ファイルへ切替わる"
);
app.close_git_diff();
assert!(matches!(app.mode, Mode::Tree));
let sh = |args: &[&str]| {
let out = std::process::Command::new("git")
.current_dir(&dir)
.args(args)
.output()
.unwrap();
assert!(out.status.success(), "git {args:?} 失敗");
};
std::fs::write(canon.join("clean.txt"), b"committed\n").unwrap();
sh(&["add", "clean.txt"]);
sh(&["commit", "-m", "clean file"]);
app.refresh().unwrap();
app.follow_jump(&canon.join("clean.txt"));
assert!(
!app.is_git_diff_preview(),
"変更なしはファイル表示へフォールバック"
);
assert!(matches!(app.mode, Mode::Preview));
assert!(app.is_windowed());
assert_eq!(
app.preview_path.as_deref(),
Some(canon.join("clean.txt").as_path())
);
std::fs::remove_dir_all(&dir).ok();
}
#[cfg(feature = "git")]
#[test]
fn diff_view_n_switches_changed_files_and_keeps_return_target() {
let dir = std::env::temp_dir().join("konoma_diff_nav_test");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
git_repo_with_commits(&dir); let canon = dir.canonicalize().unwrap();
std::fs::write(canon.join("a.txt"), b"CHANGED\n").unwrap();
std::fs::write(canon.join("b.txt"), b"new b\n").unwrap();
std::fs::write(canon.join("e.txt"), b"new e\n").unwrap();
let mut app = App::new(canon.clone(), Config::default()).unwrap();
app.refresh_git_if_needed(); app.open_git_diff(&canon.join("a.txt"));
assert_eq!(app.diff_change_position(), Some((1, 3)));
app.jump_changed(1); assert!(app.is_git_diff_preview(), "diff ビューのまま");
assert_eq!(
app.preview_path.as_deref(),
Some(canon.join("b.txt").as_path())
);
assert_eq!(app.diff_change_position(), Some((2, 3)));
assert_eq!(
app.entries[app.selected].path,
canon.join("b.txt"),
"ツリーカーソルも同期"
);
app.jump_changed(1); app.jump_changed(1); assert_eq!(
app.preview_path.as_deref(),
Some(canon.join("a.txt").as_path())
);
app.jump_changed(-1); assert_eq!(
app.preview_path.as_deref(),
Some(canon.join("e.txt").as_path())
);
app.came_from_git_view = true;
app.jump_changed(1); assert!(app.came_from_git_view, "戻り先(ハブ)が回遊で失われない");
app.close_git_diff();
assert!(app.is_git_view(), "q でハブへ戻る");
std::fs::remove_dir_all(&dir).ok();
}
#[cfg(feature = "git")]
#[test]
fn follow_diff_n_cycles_only_session_files_and_clears_flash() {
let dir = std::env::temp_dir().join("konoma_follow_session_test");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
git_repo_with_commits(&dir); let canon = dir.canonicalize().unwrap();
std::fs::write(canon.join("a.txt"), b"CHANGED\n").unwrap();
for name in ["b.txt", "c.txt", "d.txt", "e.txt"] {
std::fs::write(canon.join(name), format!("new {name}\n")).unwrap();
}
let mut app = App::new(canon.clone(), Config::default()).unwrap();
app.refresh_git_if_needed();
app.toggle_follow();
assert!(app.follow_session.is_empty(), "F ON でセッションは空から");
let outside = std::env::temp_dir().join("konoma_follow_session_outside.txt");
std::fs::write(&outside, b"x\n").unwrap();
assert!(!app.follow_note_change(&outside), "root 外は記録しない");
assert!(app.follow_note_change(&canon.join("b.txt")));
assert!(app.follow_note_change(&canon.join("d.txt")));
assert!(
app.follow_note_change(&canon.join("d.txt")),
"重複は許容(再変更)"
);
assert_eq!(app.follow_session.len(), 2, "重複は積まない");
app.flash = Some("stale flash".into());
app.follow_jump(&canon.join("d.txt"));
assert!(app.is_git_diff_preview());
assert!(app.flash.is_none(), "ジャンプ成立で flash を消す");
assert_eq!(
app.diff_change_position(),
Some((2, 2)),
"位置表示はセッション(2件)基準=全 git 変更(5件)ではない"
);
app.jump_changed(1);
assert_eq!(
app.preview_path.as_deref(),
Some(canon.join("b.txt").as_path())
);
assert_eq!(app.diff_change_position(), Some((1, 2)));
app.jump_changed(1);
assert_eq!(
app.preview_path.as_deref(),
Some(canon.join("d.txt").as_path())
);
app.toggle_follow();
app.toggle_follow();
assert!(app.follow_session.is_empty(), "次の ON で新セッション");
std::fs::remove_dir_all(&dir).ok();
std::fs::remove_file(&outside).ok();
}
#[test]
fn collapse_links_folds_table_hidden_targets_in_document_order() {
let md =
"See [first](a.md) here.\n\n| doc |\n|---|\n| [table](b.md) |\n\nAlso [last](c.md) end.\n";
let style = crate::preview::markdown::CodeStyle {
bg: None,
label_bg: None,
label_right: true,
tab_width: 4,
wrap: true,
};
let lines = crate::preview::markdown::render_markdown(md, 60, style, "TwoDark", false);
let (collapsed, targets) = collapse_links(lines, true); assert_eq!(
targets,
vec!["a.md".to_string(), "b.md".to_string(), "c.md".to_string()],
"targets は出現順(段落→表→段落)"
);
assert!(
collapsed.iter().any(|l| l
.spans
.iter()
.any(|sp| sp.content.as_ref() == "table" && is_link_span(sp))),
"表リンクのラベルがリンク様式で残る"
);
assert!(
collapsed.iter().all(|l| l
.spans
.iter()
.all(|sp| !crate::preview::markdown::is_hidden_link_target(sp))),
"隠しスパンは除去される"
);
let n_links: usize = collapsed
.iter()
.map(|l| l.spans.iter().filter(|sp| is_link_span(sp)).count())
.sum();
assert_eq!(n_links, targets.len());
}