@@ -333,3 +333,163 @@ impl DebugStats {
333333}
334334
335335pub ( 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