use super::bars::{BarState, ReindexUi, STAGE_LABELS};
use super::phase::{phase_to_bar_slot, ReindexPhase};
use super::timings::{format_timing_breakdown, print_timing_breakdown, ReindexTimings};
use crate::commands::format::fmt_elapsed;
#[test]
fn phase_labels_are_stable() {
assert_eq!(
ReindexPhase::Connecting.label(),
"Connecting to daemon\u{2026}"
);
assert_eq!(ReindexPhase::Walking.label(), "Walking files\u{2026}");
assert_eq!(ReindexPhase::Chunking.label(), "Chunking\u{2026}");
assert_eq!(
ReindexPhase::InitializingEmbedder.label(),
"Loading model\u{2026}"
);
assert_eq!(ReindexPhase::Embedding.label(), "Embedding chunks\u{2026}");
assert_eq!(ReindexPhase::ParseEmbed.label(), "Embedding chunks\u{2026}");
assert_eq!(ReindexPhase::Bm25.label(), "Building BM25 index\u{2026}");
assert_eq!(
ReindexPhase::KnowledgeGraph.label(),
"Building knowledge graph\u{2026}"
);
assert_eq!(ReindexPhase::Upsert.label(), "Upserting vectors\u{2026}");
assert_eq!(ReindexPhase::Done.label(), "Done");
}
#[test]
fn phase_to_bar_slot_coverage() {
assert_eq!(phase_to_bar_slot(ReindexPhase::Connecting), None);
assert_eq!(phase_to_bar_slot(ReindexPhase::Walking), Some(0));
assert_eq!(phase_to_bar_slot(ReindexPhase::Chunking), Some(1));
assert_eq!(
phase_to_bar_slot(ReindexPhase::InitializingEmbedder),
Some(1)
);
assert_eq!(phase_to_bar_slot(ReindexPhase::Embedding), Some(2));
assert_eq!(phase_to_bar_slot(ReindexPhase::ParseEmbed), Some(2));
assert_eq!(phase_to_bar_slot(ReindexPhase::KnowledgeGraph), Some(3));
assert_eq!(phase_to_bar_slot(ReindexPhase::Bm25), None);
assert_eq!(phase_to_bar_slot(ReindexPhase::Upsert), None);
assert_eq!(phase_to_bar_slot(ReindexPhase::Done), None);
}
#[test]
fn ui_builds_hidden_when_not_interactive() {
let mut ui = ReindexUi::new("test-index", false);
assert_eq!(ui.phase, ReindexPhase::Connecting);
ui.set_phase(ReindexPhase::Walking, "test-index");
assert_eq!(ui.phase, ReindexPhase::Walking);
ui.set_total(1_000);
ui.set_position(1_000);
ui.mark_stage_done(0, 1_200);
ui.set_phase(ReindexPhase::Chunking, "test-index");
assert_eq!(ui.phase, ReindexPhase::Chunking);
ui.set_total(1_000);
ui.mark_stage_done(1, 300);
ui.set_phase(ReindexPhase::Embedding, "test-index");
assert_eq!(ui.phase, ReindexPhase::Embedding);
ui.set_total(1_000);
ui.set_position(500);
ui.update_stats(500, 4_096, 3, 128, 10);
ui.mark_stage_done(2, 90_000);
ui.set_phase(ReindexPhase::KnowledgeGraph, "test-index");
assert_eq!(ui.phase, ReindexPhase::KnowledgeGraph);
ui.set_total(1);
ui.set_position(1);
ui.clear_stats();
ui.mark_stage_done(3, 800);
ui.finish("done".to_string());
}
#[test]
fn ui_builds_interactive() {
let ui = ReindexUi::new("test-index", true);
assert_eq!(ui.phase, ReindexPhase::Connecting);
ui.abandon("aborted".to_string());
}
#[test]
fn phase_transitions_activate_correct_bar() {
let mut ui = ReindexUi::new("idx", false);
ui.set_phase(ReindexPhase::Walking, "idx");
assert_eq!(ui.phase, ReindexPhase::Walking);
assert_eq!(ui.bar_states[0], BarState::Active);
ui.set_phase(ReindexPhase::Chunking, "idx");
assert_eq!(ui.phase, ReindexPhase::Chunking);
assert_eq!(ui.bar_states[1], BarState::Active);
ui.set_phase(ReindexPhase::Embedding, "idx");
assert_eq!(ui.phase, ReindexPhase::Embedding);
assert_eq!(ui.bar_states[2], BarState::Active);
ui.set_phase(ReindexPhase::KnowledgeGraph, "idx");
assert_eq!(ui.phase, ReindexPhase::KnowledgeGraph);
assert_eq!(ui.bar_states[3], BarState::Active);
ui.finish("done".to_string());
}
#[test]
fn mark_stage_done_freezes_bar() {
let mut ui = ReindexUi::new("idx", false);
ui.set_phase(ReindexPhase::Walking, "idx");
ui.set_total(500);
ui.set_position(500);
ui.mark_stage_done(0, 1_200);
assert_eq!(ui.bar_states[0], BarState::Done);
ui.set_phase(ReindexPhase::Walking, "idx");
assert_eq!(ui.bar_states[0], BarState::Done);
ui.finish("done".to_string());
}
#[test]
fn set_total_and_position_affect_active_bar() {
let mut ui = ReindexUi::new("idx", false);
ui.set_phase(ReindexPhase::Chunking, "idx");
ui.set_total(200);
ui.set_position(100);
assert_eq!(ui.stage_bars[1].length(), Some(200));
assert_eq!(ui.stage_bars[1].position(), 100);
ui.finish("done".to_string());
}
#[test]
fn update_stats_formats_message() {
let mut ui = ReindexUi::new("idx", false);
ui.set_phase(ReindexPhase::Embedding, "idx");
ui.update_stats(0, 0, 0, 0, 0);
ui.update_stats(500, 4_096, 3, 128, 10);
ui.finish("done".to_string());
}
#[test]
fn update_stats_label_matches_phase() {
let mut ui = ReindexUi::new("idx", false);
ui.set_phase(ReindexPhase::Chunking, "idx");
ui.set_total(3_263);
ui.update_stats(0, 0, 0, 0, 1);
let msg = ui.stats_bar().message();
assert!(
msg.starts_with("Chunking\u{2026}"),
"expected stats to start with 'Chunking…', got: {msg:?}"
);
ui.set_phase(ReindexPhase::InitializingEmbedder, "idx");
ui.update_stats(0, 0, 0, 0, 10);
let msg = ui.stats_bar().message();
assert!(
msg.starts_with("Loading model\u{2026}"),
"expected stats to start with 'Loading model…', got: {msg:?}"
);
ui.set_phase(ReindexPhase::Embedding, "idx");
ui.set_total(3_263);
ui.update_stats(128, 1_024, 0, 22, 46);
let msg = ui.stats_bar().message();
assert!(
msg.starts_with("Embedding chunks\u{2026}"),
"expected stats to start with 'Embedding chunks…', got: {msg:?}"
);
ui.finish("done".to_string());
}
#[test]
fn clear_stats_empties_message() {
let ui = ReindexUi::new("idx", false);
ui.clear_stats();
ui.finish("done".to_string());
}
#[test]
fn finish_all_clears_pending_bars() {
let mut ui = ReindexUi::new("idx", false);
ui.set_phase(ReindexPhase::Walking, "idx");
ui.set_total(100);
ui.set_position(100);
ui.mark_stage_done(0, 500);
ui.set_phase(ReindexPhase::Chunking, "idx");
ui.set_total(100);
ui.mark_stage_done(1, 200);
ui.set_phase(ReindexPhase::Embedding, "idx");
ui.set_total(100);
ui.set_position(100);
ui.mark_stage_done(2, 80_000);
assert_eq!(ui.bar_states[3], BarState::Pending);
ui.finish("lexical-only done".to_string());
}
#[test]
fn abandon_does_not_panic() {
let ui = ReindexUi::new("idx", false);
ui.abandon("timed out".to_string());
}
#[test]
fn set_embed_total_primes_slot2_while_chunking() {
let mut ui = ReindexUi::new("idx", false);
ui.set_phase(ReindexPhase::Chunking, "idx");
ui.set_total(500); ui.set_embed_total(500); assert_eq!(ui.stage_bars[1].length(), Some(500));
assert_eq!(
ui.stage_bars[2].length(),
Some(500),
"Embed bar must be primed to total_files, not left at ProgressBar::new(1)"
);
ui.finish("done".to_string());
}
#[test]
fn activate_embed_bar_does_not_change_phase() {
let mut ui = ReindexUi::new("idx", false);
ui.set_phase(ReindexPhase::Chunking, "idx");
assert_eq!(ui.phase, ReindexPhase::Chunking);
assert_eq!(ui.bar_states[2], BarState::Pending);
ui.activate_embed_bar();
assert_eq!(ui.phase, ReindexPhase::Chunking);
assert_eq!(ui.bar_states[2], BarState::Active);
ui.activate_embed_bar();
assert_eq!(ui.bar_states[2], BarState::Active);
ui.finish("done".to_string());
}
#[test]
fn advance_embed_bar_sets_slot2_position() {
let mut ui = ReindexUi::new("idx", false);
ui.set_phase(ReindexPhase::Chunking, "idx");
ui.set_total(200);
ui.set_embed_total(200);
ui.activate_embed_bar();
ui.advance_embed_bar(42);
assert_eq!(
ui.stage_bars[2].position(),
42,
"Embed bar must advance independently of active phase"
);
assert_eq!(ui.stage_bars[1].position(), 0);
ui.finish("done".to_string());
}
#[test]
fn chunk_and_embed_bars_live_simultaneously() {
let mut ui = ReindexUi::new("idx", false);
ui.set_phase(ReindexPhase::Walking, "idx");
ui.set_total(100);
ui.set_position(100);
ui.mark_stage_done(0, 500);
ui.set_phase(ReindexPhase::Chunking, "idx");
ui.set_total(100);
ui.set_embed_total(100);
ui.activate_embed_bar();
ui.set_position(50); ui.advance_embed_bar(30);
assert_eq!(
ui.bar_states[1],
BarState::Active,
"Chunk bar must stay Active"
);
assert_eq!(
ui.bar_states[2],
BarState::Active,
"Embed bar must stay Active"
);
assert_eq!(ui.stage_bars[1].position(), 50);
assert_eq!(ui.stage_bars[2].position(), 30);
ui.set_position(100);
ui.mark_stage_done(1, 5_000);
assert_eq!(
ui.bar_states[1],
BarState::Done,
"Chunk bar must be Done after mark"
);
assert_eq!(
ui.bar_states[2],
BarState::Active,
"Embed bar must still be Active after Chunk done"
);
ui.advance_embed_bar(100);
ui.mark_stage_done(2, 90_000);
assert_eq!(ui.bar_states[2], BarState::Done);
ui.finish("done".to_string());
}
#[test]
fn timing_breakdown_bm25_only_does_not_panic() {
let t = ReindexTimings {
walk_ms: 0,
parse_ms: 1_000,
embed_ms: 0,
bm25_ms: 200,
vector_upsert_ms: 0,
kg_ms: 50,
vector_count: 0,
symbol_count: 10,
edge_count: 4,
};
print_timing_breakdown(&t, 1_234, 1_500, false, false);
}
#[test]
fn timing_breakdown_normal_does_not_panic() {
let t = ReindexTimings {
walk_ms: 300,
parse_ms: 5_000,
embed_ms: 90_000,
bm25_ms: 1_200,
vector_upsert_ms: 3_400,
kg_ms: 800,
vector_count: 62_926,
symbol_count: 14_823,
edge_count: 41_002,
};
print_timing_breakdown(&t, 62_926, 95_000, false, false);
}
#[test]
fn timing_breakdown_contains_overlap_disclaimer() {
colored::control::set_override(false);
let t = ReindexTimings {
walk_ms: 0,
parse_ms: 5_000,
embed_ms: 90_000,
bm25_ms: 1_200,
vector_upsert_ms: 3_400,
kg_ms: 800,
vector_count: 62_926,
symbol_count: 14_823,
edge_count: 41_002,
};
let out = format_timing_breakdown(&t, 62_926, 95_000, false, false);
assert!(
out.contains("overlapping"),
"output must contain 'overlapping'; got:\n{out}"
);
assert!(
out.contains("do not sum"),
"output must contain 'do not sum'; got:\n{out}"
);
assert!(
out.contains("Wall-clock total"),
"output must contain 'Wall-clock total'; got:\n{out}"
);
assert!(
out.contains("tail stage"),
"output must contain 'tail stage' for KG; got:\n{out}"
);
assert!(
!fmt_elapsed(95_000).is_empty(),
"fmt_elapsed must return a non-empty string"
);
assert!(
out.contains("overlapping pipeline"),
"EMBED_STAR_NOTE footnote must appear when vector_count > 0; got:\n{out}"
);
print_timing_breakdown(&t, 62_926, 95_000, false, false);
}
#[test]
fn embed_star_footnote_guarded_by_vector_count() {
colored::control::set_override(false);
let bm25_only = ReindexTimings {
walk_ms: 0,
parse_ms: 1_000,
embed_ms: 0,
bm25_ms: 200,
vector_upsert_ms: 0,
kg_ms: 50,
vector_count: 0,
symbol_count: 10,
edge_count: 4,
};
let out_bm25 = format_timing_breakdown(&bm25_only, 1_234, 1_500, false, false);
assert!(
!out_bm25.contains("overlapping pipeline"),
"EMBED_STAR_NOTE must be absent when vector_count==0; got:\n{out_bm25}"
);
let with_vectors = ReindexTimings {
walk_ms: 0,
parse_ms: 5_000,
embed_ms: 90_000,
bm25_ms: 1_200,
vector_upsert_ms: 3_400,
kg_ms: 800,
vector_count: 62_926,
symbol_count: 14_823,
edge_count: 41_002,
};
let out_vec = format_timing_breakdown(&with_vectors, 62_926, 95_000, false, false);
assert!(
out_vec.contains("overlapping pipeline"),
"EMBED_STAR_NOTE must be present when vector_count>0; got:\n{out_vec}"
);
}
#[test]
fn upsert_vector_count_annotation_is_unambiguous() {
colored::control::set_override(false);
let t = ReindexTimings {
walk_ms: 0,
parse_ms: 5_000,
embed_ms: 90_000,
bm25_ms: 1_200,
vector_upsert_ms: 3_400,
kg_ms: 800,
vector_count: 62_926,
symbol_count: 14_823,
edge_count: 41_002,
};
let out = format_timing_breakdown(&t, 62_926, 95_000, false, false);
assert!(
out.contains("vectors upserted"),
"output must contain 'vectors upserted' to unambiguously attribute the \
count to the upsert subsystem; got:\n{out}"
);
assert!(
out.contains("62,926"),
"output must contain formatted vector count; got:\n{out}"
);
}
#[test]
fn stage_label_slot2_is_lexical_bm25() {
assert_eq!(
STAGE_LABELS[2], "Lexical(BM25)",
"Stage 2 label must be 'Lexical(BM25)' (issue #929); got {:?}",
STAGE_LABELS[2]
);
}
#[test]
fn timing_breakdown_shows_walk_when_nonzero() {
let t = ReindexTimings {
walk_ms: 150,
parse_ms: 2_000,
embed_ms: 40_000,
bm25_ms: 500,
vector_upsert_ms: 1_000,
kg_ms: 200,
vector_count: 10_000,
symbol_count: 3_000,
edge_count: 8_000,
};
print_timing_breakdown(&t, 10_000, 44_000, false, false);
}
#[test]
fn timing_breakdown_ends_with_newline() {
colored::control::set_override(false);
let with_vectors = ReindexTimings {
walk_ms: 0,
parse_ms: 5_000,
embed_ms: 90_000,
bm25_ms: 1_200,
vector_upsert_ms: 3_400,
kg_ms: 800,
vector_count: 62_926,
symbol_count: 14_823,
edge_count: 41_002,
};
let out_vec = format_timing_breakdown(&with_vectors, 62_926, 95_000, false, false);
assert!(
out_vec.ends_with('\n'),
"vector>0 path: output must end with '\\n'; got:\n{out_vec:?}"
);
assert!(
!out_vec.ends_with("\n\n"),
"vector>0 path: output must not have double trailing newline; got:\n{out_vec:?}"
);
let bm25_only = ReindexTimings {
walk_ms: 0,
parse_ms: 1_000,
embed_ms: 0,
bm25_ms: 200,
vector_upsert_ms: 0,
kg_ms: 50,
vector_count: 0,
symbol_count: 10,
edge_count: 4,
};
let out_bm25 = format_timing_breakdown(&bm25_only, 1_234, 1_500, false, false);
assert!(
out_bm25.ends_with('\n'),
"BM25-only path: output must end with '\\n'; got:\n{out_bm25:?}"
);
assert!(
!out_bm25.ends_with("\n\n"),
"BM25-only path: output must not have double trailing newline; got:\n{out_bm25:?}"
);
}
#[test]
fn defer_embed_path_suppresses_upsert_zero_annotation() {
colored::control::set_override(false);
let defer_timings = ReindexTimings {
walk_ms: 50,
parse_ms: 800,
embed_ms: 0,
bm25_ms: 1_200,
vector_upsert_ms: 0,
kg_ms: 300,
vector_count: 0,
symbol_count: 100,
edge_count: 42,
};
let out = format_timing_breakdown(&defer_timings, 5_000, 3_000, true, false);
assert!(
!out.contains("upsert"),
"defer-embed path must not show 'upsert' line (issue #1174); got:\n{out}"
);
assert!(
!out.contains("0 vectors"),
"defer-embed path must not show '0 vectors upserted' (issue #1174); got:\n{out}"
);
assert!(
out.contains("deferred"),
"defer-embed path must show 'deferred' annotation so operator knows \
vectors will be committed asynchronously (issue #1174); got:\n{out}"
);
assert!(
out.contains("bm25"),
"defer-embed path must still show 'bm25' timing; got:\n{out}"
);
assert!(
out.ends_with('\n'),
"defer-embed path: output must end with '\\n'; got:\n{out:?}"
);
assert!(
!out.ends_with("\n\n"),
"defer-embed path: output must not have double trailing newline; got:\n{out:?}"
);
}