Skip to content

Commit 9a9899e

Browse files
committed
Add state-transition tests for ActivityPhase
DebugStats is the journal the debug-window timeline strip reads. Cover the documented pipeline transitions (Idle->Replaying, Replaying->Live, Scanning->Aggregating->Reconciling->Live, *->Idle on shutdown), the duration-closing branch that the timeline depends on, the 20-entry ring-buffer cap, the reset path, and close_phase_with_stats attaching to the current (not a historical) phase. Tests construct a fresh DebugStats per case rather than poking at the global DEBUG_STATS, so they don't fight over the singleton or with indexer activity in other tests.
1 parent 9dd3250 commit 9a9899e

1 file changed

Lines changed: 160 additions & 0 deletions

File tree

apps/desktop/src-tauri/src/indexing/events.rs

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -333,3 +333,163 @@ impl DebugStats {
333333
}
334334

335335
pub(crate) static DEBUG_STATS: LazyLock<DebugStats> = LazyLock::new(DebugStats::new);
336+
337+
#[cfg(test)]
338+
mod tests {
339+
//! ActivityPhase transition tests.
340+
//!
341+
//! `DebugStats` is the journal the debug window reads. Transitions are
342+
//! one-way appends (each `set_phase` closes the previous entry and pushes
343+
//! a new one) — this isn't a strict state machine, but it does encode a
344+
//! pipeline order (`Replaying -> Live`, `Scanning -> Aggregating ->
345+
//! Reconciling -> Live`) that the UI relies on for the timeline strip.
346+
//!
347+
//! We construct a fresh `DebugStats` per test (not the global) so tests
348+
//! don't fight over the singleton.
349+
use super::*;
350+
use std::time::Duration;
351+
352+
fn last_phase(stats: &DebugStats) -> ActivityPhase {
353+
let history = stats.phase_history.lock().expect("phase_history poisoned");
354+
history.last().expect("phase_history must always have an entry").phase.clone()
355+
}
356+
357+
fn nth_phase(stats: &DebugStats, n: usize) -> ActivityPhase {
358+
let history = stats.phase_history.lock().expect("phase_history poisoned");
359+
history.get(n).expect("phase_history index out of bounds").phase.clone()
360+
}
361+
362+
fn history_len(stats: &DebugStats) -> usize {
363+
stats.phase_history.lock().expect("phase_history poisoned").len()
364+
}
365+
366+
#[test]
367+
fn debug_stats_initial_phase_is_idle() {
368+
let stats = DebugStats::new();
369+
assert!(matches!(last_phase(&stats), ActivityPhase::Idle));
370+
}
371+
372+
#[test]
373+
fn set_phase_idle_to_replaying_transition() {
374+
// Pins `manager.rs:184`: app launch with pending FSEvents.
375+
let stats = DebugStats::new();
376+
stats.set_phase(ActivityPhase::Replaying, "app launch, pending FSEvents");
377+
assert!(matches!(last_phase(&stats), ActivityPhase::Replaying));
378+
}
379+
380+
#[test]
381+
fn set_phase_replaying_to_live_transition() {
382+
// Pins `event_loop.rs:769`: post-replay handoff to live event processing.
383+
let stats = DebugStats::new();
384+
stats.set_phase(ActivityPhase::Replaying, "replay start");
385+
stats.set_phase(ActivityPhase::Live, "replay complete");
386+
assert!(matches!(last_phase(&stats), ActivityPhase::Live));
387+
}
388+
389+
#[test]
390+
fn set_phase_full_scan_pipeline_transitions() {
391+
// Pins the documented scan pipeline order:
392+
// Idle -> Scanning -> Aggregating -> Reconciling -> Live.
393+
// The UI's timeline strip depends on this exact sequence.
394+
let stats = DebugStats::new();
395+
stats.set_phase(ActivityPhase::Scanning, "user-initiated scan");
396+
stats.set_phase(ActivityPhase::Aggregating, "scan complete");
397+
stats.set_phase(ActivityPhase::Reconciling, "aggregation complete");
398+
stats.set_phase(ActivityPhase::Live, "reconciliation complete");
399+
400+
// Initial Idle + 4 transitions = 5 history entries.
401+
assert_eq!(history_len(&stats), 5);
402+
assert!(matches!(nth_phase(&stats, 0), ActivityPhase::Idle));
403+
assert!(matches!(nth_phase(&stats, 1), ActivityPhase::Scanning));
404+
assert!(matches!(nth_phase(&stats, 2), ActivityPhase::Aggregating));
405+
assert!(matches!(nth_phase(&stats, 3), ActivityPhase::Reconciling));
406+
assert!(matches!(nth_phase(&stats, 4), ActivityPhase::Live));
407+
}
408+
409+
#[test]
410+
fn set_phase_to_idle_on_shutdown_transition() {
411+
// Pins `manager.rs:621,746`: any phase can be closed out to Idle
412+
// when the indexer is stopped or shut down.
413+
let stats = DebugStats::new();
414+
stats.set_phase(ActivityPhase::Scanning, "user scan");
415+
stats.set_phase(ActivityPhase::Idle, "shutdown");
416+
assert!(matches!(last_phase(&stats), ActivityPhase::Idle));
417+
}
418+
419+
#[test]
420+
fn set_phase_closes_previous_entry_with_duration() {
421+
// Pins the "close last entry's duration_ms before appending the new
422+
// one" branch (events.rs:296–303). If this regresses, the timeline
423+
// strip would show only the latest phase without elapsed times.
424+
let stats = DebugStats::new();
425+
stats.set_phase(ActivityPhase::Scanning, "scan");
426+
// Sleep a tiny bit so the next set_phase computes a non-zero duration
427+
// for the Scanning entry it just closed.
428+
std::thread::sleep(Duration::from_millis(2));
429+
stats.set_phase(ActivityPhase::Live, "live");
430+
431+
let history = stats.phase_history.lock().unwrap();
432+
// Entry index 1 is Scanning; it should be closed (duration_ms = Some).
433+
assert!(matches!(history[1].phase, ActivityPhase::Scanning));
434+
assert!(
435+
history[1].duration_ms.is_some(),
436+
"previous phase must be closed with a duration when a new phase begins"
437+
);
438+
// The newest entry (Live) is still in progress.
439+
assert!(history[2].duration_ms.is_none());
440+
}
441+
442+
#[test]
443+
fn set_phase_caps_history_at_20_entries() {
444+
// Pins the ring-buffer cap (events.rs:315–318). 30 transitions in
445+
// and we keep only the most recent 20, oldest dropped first.
446+
let stats = DebugStats::new();
447+
// The Idle initial entry counts toward the cap, so 30 more pushes
448+
// means the cap drains the oldest entries (the initial Idle + early
449+
// Scanning entries).
450+
for i in 0..30 {
451+
let phase = if i % 2 == 0 {
452+
ActivityPhase::Scanning
453+
} else {
454+
ActivityPhase::Live
455+
};
456+
stats.set_phase(phase, "stress");
457+
}
458+
assert_eq!(history_len(&stats), 20);
459+
// The newest entry (index 19) must be the last one pushed.
460+
// i=29 is odd -> Live.
461+
assert!(matches!(nth_phase(&stats, 19), ActivityPhase::Live));
462+
}
463+
464+
#[test]
465+
fn reset_collapses_history_to_a_single_idle_entry() {
466+
// Pins `reset()` (events.rs:266): after a stop+restart, the timeline
467+
// should start from a fresh Idle, not from the residual phases.
468+
let stats = DebugStats::new();
469+
stats.set_phase(ActivityPhase::Scanning, "scan");
470+
stats.set_phase(ActivityPhase::Aggregating, "aggregate");
471+
stats.reset();
472+
473+
assert_eq!(history_len(&stats), 1, "reset must collapse history");
474+
assert!(matches!(last_phase(&stats), ActivityPhase::Idle));
475+
// Counters must also be cleared.
476+
assert_eq!(stats.must_scan_sub_dirs_count.load(Ordering::Relaxed), 0);
477+
assert_eq!(stats.live_event_count.load(Ordering::Relaxed), 0);
478+
assert!(!stats.watcher_active.load(Ordering::Relaxed));
479+
}
480+
481+
#[test]
482+
fn close_phase_with_stats_attaches_to_current_phase_only() {
483+
// Pins `close_phase_with_stats`: attaches to the LAST entry, not to
484+
// a closed historical one. If this regresses, scan-completion stats
485+
// would land on the wrong phase or on no phase at all.
486+
let stats = DebugStats::new();
487+
stats.set_phase(ActivityPhase::Scanning, "scan");
488+
stats.close_phase_with_stats(vec![("entries", "1234".to_string())]);
489+
490+
let history = stats.phase_history.lock().unwrap();
491+
// index 0 = Idle (no stats), index 1 = Scanning (with stats).
492+
assert!(history[0].stats.is_empty());
493+
assert_eq!(history[1].stats, vec![("entries".to_string(), "1234".to_string())]);
494+
}
495+
}

0 commit comments

Comments
 (0)