use crate::config::Config;
use crate::test_support::unique_tmp;
use std::path::Path;
use std::time::{Duration, Instant};
#[cfg(test)]
fn preview_app(dir: &Path, name: &str) -> crate::app::App {
use ratatui::backend::TestBackend;
use ratatui::Terminal;
let mut app = crate::app::App::new(dir.to_path_buf(), Config::default()).unwrap();
app.tab.selected = app
.tab
.entries
.iter()
.position(|e| e.path.ends_with(name))
.unwrap();
app.tree_activate().unwrap();
let mut term = Terminal::new(TestBackend::new(80, 30)).unwrap();
term.draw(|f| crate::ui::render(f, &mut app)).unwrap();
app
}
fn sample_path(name: &str) -> std::path::PathBuf {
std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("samples")
.join(name)
}
fn sample_path_or_skip(name: &str) -> Option<std::path::PathBuf> {
let p = sample_path(name);
if p.exists() {
Some(p)
} else {
eprintln!(
"SKIP: samples/{name} not found (excluded from the published crate) — this test verifies nothing this run"
);
None
}
}
fn rust_source(n: usize) -> String {
let mut s = String::with_capacity(n * 32);
for i in 0..n {
s.push_str(&format!(
"fn item_{i}(x: i32) -> i32 {{ let y = x + {i}; y * 2 }} // comment {i}\n"
));
}
s
}
#[test]
fn highlight_lang_large_source_is_bounded() {
let _ = crate::preview::code::highlight_lang(&rust_source(10), "rust", "TwoDark");
let small = rust_source(1500);
let large = rust_source(3000); let small_alloc = crate::mem_tests::allocated_by(|| {
let lines = crate::preview::code::highlight_lang(&small, "rust", "TwoDark");
assert_eq!(lines.len(), 1500, "全行ハイライトされる");
});
let large_alloc = crate::mem_tests::allocated_by(|| {
let lines = crate::preview::code::highlight_lang(&large, "rust", "TwoDark");
assert_eq!(lines.len(), 3000, "全行ハイライトされる");
});
assert!(
large_alloc < small_alloc.saturating_mul(3),
"2倍の行数で確保バイト数が3倍を超えた(回帰: O(n^2)?): small={small_alloc} large={large_alloc}"
);
}
#[test]
fn warm_then_highlight_is_fast() {
let dir = unique_tmp("konoma_speed_warm_test");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let f = dir.join("a.rs");
std::fs::write(&f, rust_source(300).as_bytes()).unwrap();
let t = Instant::now();
crate::preview::code::warm_file("rs", &f);
let warm_dt = t.elapsed();
assert!(
warm_dt < Duration::from_secs(5),
"warm_file が遅すぎる: {warm_dt:?}"
);
let src = std::fs::read_to_string(&f).unwrap();
let t = Instant::now();
let lines = crate::preview::code::highlight(&src, &f, "TwoDark");
let hot_dt = t.elapsed();
assert_eq!(lines.len(), 300);
assert!(
hot_dt < Duration::from_secs(2),
"ウォーム後のハイライトが遅すぎる: {hot_dt:?}"
);
std::fs::remove_dir_all(&dir).ok();
}
fn md_doc(blocks: usize) -> String {
let mut md = String::new();
for i in 0..blocks {
md.push_str(&format!(
"## Heading {i}\n\nSome *body* text with `code` and a [link](https://x/{i}).\n\n"
));
}
md.push_str("```rust\n");
md.push_str(&rust_source(blocks));
md.push_str("```\n");
md
}
#[test]
fn render_markdown_large_doc_is_bounded() {
let render = |src: &str| {
crate::preview::markdown::render_markdown(
src,
80,
crate::preview::markdown::CodeStyle::default(),
"TwoDark",
false,
)
};
let _ = render(&md_doc(1));
let small = md_doc(100);
let large = md_doc(200); let small_alloc = crate::mem_tests::allocated_by(|| {
assert!(!render(&small).is_empty());
});
let large_alloc = crate::mem_tests::allocated_by(|| {
assert!(!render(&large).is_empty());
});
assert!(
large_alloc < small_alloc.saturating_mul(3),
"2倍のブロック数で確保バイト数が3倍を超えた(回帰: O(n^2)?): small={small_alloc} large={large_alloc}"
);
}
fn gfm_table(rows: usize, cols: usize, cell: &str) -> String {
let mut md = String::new();
md.push_str(&format!("|{}|\n", vec![" h "; cols].join("|")));
md.push_str(&format!("|{}|\n", vec!["---"; cols].join("|")));
for _ in 0..rows {
md.push_str(&format!(
"|{}|\n",
vec![format!(" {cell} "); cols].join("|")
));
}
md
}
fn html_table(rows: usize, cols: usize, cell: &str) -> String {
let mut md = String::from("<table>\n");
for _ in 0..rows {
md.push_str("<tr>");
for _ in 0..cols {
md.push_str(&format!("<td>{cell}</td>"));
}
md.push_str("</tr>\n");
}
md.push_str("</table>\n");
md
}
#[test]
fn render_markdown_large_table_is_bounded() {
let render = |src: &str| {
crate::preview::markdown::render_markdown(
src,
80,
crate::preview::markdown::CodeStyle::default(),
"TwoDark",
false,
)
};
let _ = render(&gfm_table(1, 1, "x"));
let alloc = |src: &str| {
crate::mem_tests::allocated_by(|| {
assert!(!render(src).is_empty());
})
};
for (why, small, large) in [
(
"gfm rows",
gfm_table(400, 8, "cell"),
gfm_table(800, 8, "cell"),
),
(
"html rows",
html_table(400, 8, "cell"),
html_table(800, 8, "cell"),
),
(
"html columns",
html_table(60, 25, "cell"),
html_table(60, 50, "cell"),
),
] {
let (small_alloc, large_alloc) = (alloc(&small), alloc(&large));
assert!(
large_alloc < small_alloc.saturating_mul(3),
"{why}: 2倍の表で確保バイト数が3倍を超えた(回帰: O(n^2)?): \
small={small_alloc} large={large_alloc}"
);
}
let src = html_table(1, 200, &"x".repeat(40));
let t = Instant::now();
assert!(!render(&src).is_empty());
let dt = t.elapsed();
assert!(
dt < Duration::from_secs(2),
"幅調整ループが遅すぎる(回帰: 列数に対して非線形?): {dt:?}"
);
}
#[test]
fn tree_build_and_visible_range_many_entries_is_bounded() {
use ratatui::backend::TestBackend;
use ratatui::Terminal;
let dir = unique_tmp("konoma_speed_tree_test");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
for i in 0..1000 {
std::fs::write(dir.join(format!("file_{i:04}.txt")), b"x").unwrap();
}
let mut app = crate::app::App::new(dir.clone(), Config::default()).unwrap();
let t = Instant::now();
app.rebuild_tree().unwrap();
let build_dt = t.elapsed();
assert_eq!(app.tab.entries.len(), 1000, "全件ツリー化");
assert!(
build_dt < Duration::from_secs(2),
"1000 件のツリー再構築が遅すぎる: {build_dt:?}"
);
app.tab.selected = app.tab.entries.len() - 1;
let mut term = Terminal::new(TestBackend::new(60, 30)).unwrap();
let t = Instant::now();
term.draw(|f| crate::ui::render(f, &mut app)).unwrap();
let draw_dt = t.elapsed();
assert!(
draw_dt < Duration::from_secs(1),
"可視範囲を含む1描画が遅すぎる: {draw_dt:?}"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn decode_gif_sample_is_bounded() {
let Some(p) = sample_path_or_skip("sample.gif") else {
return;
};
let t = Instant::now();
let frames = crate::preview::image::decode_gif(&p);
let dt = t.elapsed();
assert!(
frames.map(|f| f.len() > 1).unwrap_or(false),
"アニメ GIF として展開"
);
assert!(
dt < Duration::from_secs(5),
"sample.gif のデコードが遅すぎる: {dt:?}"
);
}
#[cfg(feature = "git")]
#[test]
fn same_repo_navigation_does_not_rescan_git_status() {
use std::process::Command;
let dir = crate::test_support::unique_tmp("konoma_speed_status_cache");
std::fs::create_dir_all(dir.join("sub")).unwrap();
for i in 0..20 {
std::fs::write(dir.join(format!("f{i:04}.txt")), b"x\n").unwrap();
}
let repo = git2::Repository::init(&dir).unwrap();
{
let mut c = repo.config().unwrap();
c.set_str("user.name", "T").unwrap();
c.set_str("user.email", "t@t").unwrap();
c.set_str("commit.gpgsign", "false").ok();
}
let git = |a: &[&str]| {
Command::new("git")
.current_dir(&dir)
.args(a)
.output()
.unwrap();
};
git(&["add", "-A"]);
git(&["commit", "-qm", "init"]);
std::fs::write(dir.join("f0000.txt"), b"y\n").unwrap();
let root = dir.canonicalize().unwrap();
let mut app = crate::app::App::new(root.clone(), Config::default()).unwrap();
let (tx, rx) = std::sync::mpsc::channel();
app.attach_status_loader(tx);
app.refresh_git_if_needed(); let res = rx
.recv_timeout(Duration::from_secs(30))
.expect("1本目の結果");
assert!(app.apply_statuses(res));
assert!(
app.git_has_changes(),
"変更が status に反映(スキャンが走った)"
);
for i in 0..40 {
app.tab.root = if i % 2 == 0 {
root.join("sub")
} else {
root.clone()
};
app.refresh_git_if_needed();
}
assert_eq!(
rx.try_iter().count(),
0,
"同一 repo の 40 往復で git status を再スキャンした(workdir キャッシュ喪失)"
);
std::fs::remove_dir_all(&dir).ok();
}
#[cfg(feature = "git")]
#[test]
fn worktree_chip_is_not_recomputed_on_every_render() {
use ratatui::backend::TestBackend;
use ratatui::Terminal;
let dir = unique_tmp("konoma_speed_worktree_chip_main");
let linked = unique_tmp("konoma_speed_worktree_chip_linked");
let _ = std::fs::remove_dir_all(&dir);
let _ = std::fs::remove_dir_all(&linked);
std::fs::create_dir_all(&dir).unwrap();
let repo = git2::Repository::init(&dir).unwrap();
{
let mut c = repo.config().unwrap();
c.set_str("user.name", "T").unwrap();
c.set_str("user.email", "t@t").unwrap();
c.set_str("commit.gpgsign", "false").ok();
}
std::fs::write(dir.join("a.txt"), b"x\n").unwrap();
let git = |cwd: &Path, a: &[&str]| {
let out = std::process::Command::new("git")
.current_dir(cwd)
.args(a)
.output()
.unwrap();
assert!(out.status.success(), "git {a:?}: {out:?}");
};
git(&dir, &["add", "-A"]);
git(&dir, &["commit", "-qm", "init"]);
let main_root = dir.canonicalize().unwrap();
git(
&main_root,
&[
"worktree",
"add",
"-q",
"-b",
"feat",
linked.to_str().unwrap(),
],
);
let linked_root = linked.canonicalize().unwrap();
let mut app = crate::app::App::new(linked_root, Config::default()).unwrap();
let (tx, rx) = std::sync::mpsc::channel();
app.attach_status_loader(tx);
app.refresh_git_if_needed(); let res = rx
.recv_timeout(std::time::Duration::from_secs(30))
.expect("1本目の結果");
assert!(app.apply_statuses(res));
assert!(
app.worktree_origin().is_some(),
"linked worktree のはずが origin が取れていない"
);
let mut term = Terminal::new(TestBackend::new(80, 24)).unwrap();
for _ in 0..50 {
term.draw(|f| crate::ui::render(f, &mut app)).unwrap();
}
assert_eq!(
rx.try_iter().count(),
0,
"同じ root への描画50回で worktree_origin(scan_statuses)を再計算してはいけない"
);
assert!(
app.worktree_origin().is_some(),
"50回の描画後も表示は保たれているはず"
);
std::fs::remove_dir_all(&linked).ok();
std::fs::remove_dir_all(&dir).ok();
}
#[cfg(test)]
fn feature_markdown(blocks: usize) -> String {
let mut s = String::from("---\ntitle: Speed\ntags: [a, b]\n---\n\n# Top\n\n");
for i in 0..blocks {
s.push_str(&format!("## Section {i}\n\n"));
s.push_str("Body with *italic*, **bold**, `code`, ~~strike~~, and a bare URL https://example.com/x plus :rocket: emoji.[^note]\n\n");
s.push_str("[^note]: A footnote body.\n\n");
s.push_str("> [!NOTE]\n> An alert with a [link](https://example.com) inside.\n\n");
s.push_str(
"| Col A | Col B | Col C |\n|:--|:-:|--:|\n| a | b | c |\n| 日本語 | x | y |\n\n",
);
s.push_str("- [ ] task one\n- [x] task two\n - nested\n\n");
s.push_str("<details>\n<summary>More</summary>\n\nHidden body text.\n\n</details>\n\n");
s.push_str("```rust\nfn demo() { let _ = 1 + 2; }\n```\n\n");
}
s
}
#[test]
fn preview_large_markdown_is_bounded() {
use ratatui::backend::TestBackend;
use ratatui::Terminal;
let dir = crate::test_support::unique_tmp("konoma_speed_md_preview");
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("big.md"), feature_markdown(30)).unwrap();
let _ = crate::preview::code::highlight_lang(&rust_source(1), "rust", "TwoDark");
let mut app = None;
let build_alloc = crate::mem_tests::allocated_by(|| {
app = Some(preview_app(&dir, "big.md"));
});
let mut app = app.unwrap();
let mut term = Terminal::new(TestBackend::new(80, 30)).unwrap();
for i in 0..5 {
app.preview_scroll((i % 7) + 1);
term.draw(|f| crate::ui::render(f, &mut app)).unwrap();
}
let one_scroll_alloc = crate::mem_tests::allocated_by(|| {
app.preview_scroll(3);
term.draw(|f| crate::ui::render(f, &mut app)).unwrap();
});
assert!(
one_scroll_alloc.saturating_mul(20) < build_alloc,
"装飾 md の1スクロールがキャッシュを再利用していない(スクロール {one_scroll_alloc} が初回装飾 {build_alloc} に近い=毎フレーム再装飾?)"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn preview_large_csv_is_bounded() {
use ratatui::backend::TestBackend;
use ratatui::Terminal;
let dir = unique_tmp("konoma_speed_csv");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let mut body = String::from("c0,c1,c2,c3,c4,c5,c6,c7\n");
for i in 0..2000 {
body.push_str(&format!("r{i}a,r{i}b,r{i}c,日本語{i},e,f,g,h\n"));
}
std::fs::write(dir.join("big.csv"), body).unwrap();
let t = Instant::now();
let mut app = preview_app(&dir, "big.csv");
let load_dt = t.elapsed();
assert!(app.is_table_preview(), "テーブル面で開く");
assert!(
load_dt < Duration::from_secs(5),
"大きな CSV のパース+初回描画が遅すぎる: {load_dt:?}"
);
let mut term = Terminal::new(TestBackend::new(60, 24)).unwrap();
let t = Instant::now();
for i in 0..80 {
app.table_cursor_move(i % 5, (i % 3) - 1);
term.draw(|f| crate::ui::render(f, &mut app)).unwrap();
}
let nav_dt = t.elapsed();
assert!(
nav_dt < Duration::from_secs(2),
"CSV のセル移動 80 描画が遅すぎる: {nav_dt:?}"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn preview_large_file_windowing_is_bounded() {
use ratatui::backend::TestBackend;
use ratatui::Terminal;
let dir = unique_tmp("konoma_speed_window");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let mut body = String::new();
for i in 0..20000 {
body.push_str(&format!("line {i} with some MATCHME content 日本語\n"));
}
std::fs::write(dir.join("big.txt"), &body).unwrap();
let t = Instant::now();
let mut app = preview_app(&dir, "big.txt");
let open_dt = t.elapsed();
assert!(
open_dt < Duration::from_secs(2),
"大きなテキストの初回プレビューが遅すぎる(全文走査?): {open_dt:?}"
);
let mut term = Terminal::new(TestBackend::new(80, 30)).unwrap();
let t = Instant::now();
for _ in 0..100 {
app.preview_scroll(25);
term.draw(|f| crate::ui::render(f, &mut app)).unwrap();
}
let page_dt = t.elapsed();
assert!(
page_dt < Duration::from_secs(3),
"ページ送り 100 描画が遅すぎる: {page_dt:?}"
);
let mut win = crate::preview::window::FileWindow::open(&dir.join("big.txt")).unwrap();
let t = Instant::now();
let hits = win.find_all_matches("MATCHME", 5000).unwrap();
let find_dt = t.elapsed();
assert!(!hits.is_empty(), "一致を見つける");
assert!(
find_dt < Duration::from_secs(3),
"20000 行の検索が遅すぎる: {find_dt:?}"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn kitty_transmit_large_image_is_bounded() {
use image::{DynamicImage, Rgba, RgbaImage};
let font = ratatui_image::FontSize {
width: 8,
height: 16,
};
let mut big = RgbaImage::new(1600, 1000);
for (x, y, px) in big.enumerate_pixels_mut() {
*px = Rgba([(x % 256) as u8, (y % 256) as u8, 128, 255]);
}
let src = DynamicImage::ImageRgba8(big);
let ki =
crate::preview::kitty::build_from_source(&src, (0, 0, 1600, 1000), 120, 40, font, false)
.expect("kitty 画像を構築");
assert_eq!(ki.cell_size(), (120, 40), "表示セルサイズで構築");
let (pw, ph) = (120u32 * 8, 40u32 * 16);
let mut disp = RgbaImage::new(pw, ph);
for (x, y, px) in disp.enumerate_pixels_mut() {
*px = Rgba([(x % 256) as u8, (y % 256) as u8, 128, 255]);
}
let esc = crate::preview::kitty::build_transmit(&disp, 1, false);
assert!(esc.contains("o=z"), "o=z 圧縮チャンクを含む");
let raw = (pw * ph * 4) as usize; assert!(
esc.len() < raw / 2,
"圧縮が効いていない(esc {} vs raw {})",
esc.len(),
raw
);
}
#[cfg(feature = "git")]
fn diff_body(n: usize, marker: &str) -> String {
let mut s = String::with_capacity(n * (marker.len() + 8));
for i in 0..n {
s.push_str(&format!("{marker} {i}\n"));
}
s
}
#[cfg(feature = "git")]
#[test]
fn git_views_and_large_diff_render_is_bounded() {
use ratatui::backend::TestBackend;
use ratatui::Terminal;
let dir = crate::test_support::unique_tmp("konoma_speed_git_views");
std::fs::create_dir_all(&dir).unwrap();
let git = |a: &[&str]| {
std::process::Command::new("git")
.current_dir(&dir)
.args(a)
.output()
.unwrap();
};
git(&["init", "-q", "."]);
git(&["config", "user.email", "t@t"]);
git(&["config", "user.name", "t"]);
git(&["config", "commit.gpgsign", "false"]);
for c in 0..3 {
std::fs::write(dir.join(format!("f{c}.txt")), format!("v{c}\n")).unwrap();
git(&["add", "-A"]);
git(&["commit", "-qm", &format!("commit {c}")]);
}
std::fs::write(dir.join("big.txt"), diff_body(5, "line")).unwrap();
git(&["add", "big.txt"]);
git(&["commit", "-qm", "add big"]);
let root = dir.canonicalize().unwrap();
let diff_alloc_for = |n: usize| -> u64 {
std::fs::write(dir.join("big.txt"), diff_body(n, "LINE changed")).unwrap();
crate::mem_tests::allocated_by(|| {
let dl = crate::git::file_diff(&root, &root.join("big.txt"));
assert!(!dl.is_empty(), "差分が取れる");
let lines = crate::preview::gitdiff::diff_lines(&dl, "txt", "TwoDark", 90);
assert!(!lines.is_empty(), "縦 diff 行を生成");
let _maxh = crate::preview::gitdiff::side_by_side_max_hscroll(&dl, 90);
let sbs =
crate::preview::gitdiff::diff_lines_side_by_side(&dl, "txt", "TwoDark", 90, 40);
assert!(!sbs.is_empty(), "横並び diff 行を生成");
})
};
let small_alloc = diff_alloc_for(500);
let large_alloc = diff_alloc_for(1000); assert!(
large_alloc < small_alloc.saturating_mul(5) / 2, "2倍の差分行数で確保バイト数が2.5倍を超えた(回帰: O(n^2)?): small={small_alloc} large={large_alloc}"
);
let mut app = crate::app::App::new(root.clone(), Config::default()).unwrap();
let mut term = Terminal::new(TestBackend::new(100, 40)).unwrap();
let views_alloc = crate::mem_tests::allocated_by(|| {
app.open_git_view();
term.draw(|f| crate::ui::render(f, &mut app)).unwrap();
app.open_git_log();
term.draw(|f| crate::ui::render(f, &mut app)).unwrap();
app.close_git_log();
app.open_git_graph();
term.draw(|f| crate::ui::render(f, &mut app)).unwrap();
app.close_git_graph();
app.open_git_branches();
term.draw(|f| crate::ui::render(f, &mut app)).unwrap();
app.close_git_branches();
app.open_git_diff(&root.join("big.txt"));
term.draw(|f| crate::ui::render(f, &mut app)).unwrap();
});
assert!(
views_alloc < 60_000_000, "git ビュー群+大きな diff の描画の確保バイト数が上限を超えた(回帰?): {views_alloc}"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn tab_switch_reloads_are_bounded() {
use ratatui::backend::TestBackend;
use ratatui::Terminal;
let dir = crate::test_support::unique_tmp("konoma_speed_tabswitch");
std::fs::create_dir_all(&dir).unwrap();
for i in 0..3000 {
std::fs::write(dir.join(format!("f{i:05}.txt")), b"x").unwrap();
}
std::fs::write(dir.join("a.md"), "# T\n\nbody [l](https://e/1)\n").unwrap();
std::fs::write(dir.join("b.csv"), "x,y,z\n1,b,c\n2,b,c\n").unwrap();
std::fs::write(dir.join("c.txt"), rust_source(20)).unwrap();
std::fs::write(dir.join("d.rs"), rust_source(20)).unwrap();
let _ = crate::preview::code::highlight_lang(&rust_source(1), "rust", "TwoDark");
let mut app = crate::app::App::new(dir.clone(), Config::default()).unwrap();
let rebuild_alloc = crate::mem_tests::allocated_by(|| {
app.rebuild_tree().unwrap();
});
assert_eq!(app.tab.entries.len(), 3004, "全件ツリー化(3000+4ファイル)");
let mut term = Terminal::new(TestBackend::new(80, 30)).unwrap();
for name in ["a.md", "b.csv", "c.txt", "d.rs"] {
app.tab.selected = app
.tab
.entries
.iter()
.position(|e| e.path.ends_with(name))
.unwrap();
app.tab_new_from_selection().unwrap();
app.enter_visual();
app.visual_select_scope(true);
term.draw(|f| crate::ui::render(f, &mut app)).unwrap();
}
assert!(app.tab_count() >= 4, "4 つ以上のタブ");
let cycle_alloc = crate::mem_tests::allocated_by(|| {
for _ in 0..20 {
app.tab_cycle(1);
term.draw(|f| crate::ui::render(f, &mut app)).unwrap();
assert!(
app.active_tab_slot_is_vacated_for_test(),
"tab_cycle 直後、アクティブタブの self.tabs スロットが既定値(空)に戻っていない = load_active が mem::take でなく clone している疑い"
);
}
});
assert!(
cycle_alloc < rebuild_alloc.saturating_mul(40), "20 回のタブ切替の確保バイト数がツリー再構築だけの20倍換算の2.0倍を超えた(回帰: 想定外の大きな確保が追加された?): cycle={cycle_alloc} rebuild_x40={}",
rebuild_alloc.saturating_mul(40)
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn burst_paths_handles_large_distinct_burst_without_quadratic_blowup() {
let t = Instant::now();
let mut burst = crate::BurstPaths::default();
for i in 0..50_000 {
let p = std::path::PathBuf::from(format!("/repo/generated/f{i:06}.txt"));
burst.push(&p);
}
let dt = t.elapsed();
assert_eq!(
burst.finish(),
None,
"上限を超える distinct バーストは「パス不明」として扱われる"
);
assert!(
dt < Duration::from_secs(2),
"distinct な大量パスの重複除去が遅すぎる(線形走査への回帰?): {dt:?}"
);
}