//! UI-visible application state and the footer status derivation. //! //! The state machine here only reflects **user intent** — "ready to //! record", "recording in progress", "config error, please fix". //! Background activity (upload queue, poll cycle, finalize flush) lives //! in a separate observable layer (`FooterStatus`) derived via a pure //! function so it can be table-tested without egui. use std::time::Duration; use doctate_client_core::server_sync::WorkerState; use uuid::Uuid; #[derive(Debug)] pub enum AppState { /// No valid config on disk — show settings panel. NotConfigured, /// Ready for a new recording. No ephemeral context. Idle, /// ffmpeg is actively capturing audio. Recording { case_id: Uuid, recorded_at: String, started_at: std::time::Instant, }, /// Terminal error; user must dismiss or fix config. Error { message: String, }, } impl AppState { pub fn label(&self) -> &'static str { match self { Self::NotConfigured => "Konfiguration fehlt", Self::Idle => "Bereit", Self::Recording { .. } => "Aufnahme läuft", Self::Error { .. } => "Fehler", } } } /// Derived observable rendered in the footer. Priority order encoded /// in `pick_footer_status` — active work beats connectivity indicators. #[derive(Debug, Clone, PartialEq, Eq)] pub enum FooterStatus { /// ffmpeg is flushing the MOOV atom. Spinner. Finalizing, /// Worker is uploading. Spinner. `count` = pending-dir depth. Uploading { count: usize }, /// Worker is polling `/api/oneliners`. Spinner. Synchronizing, /// Never got a successful request yet — cold start or bad config. NeverConnected, /// Last success older than threshold. Red text. Offline, /// Last success fresh. Green text. The default quiet state. Online, } /// Decide which footer status to show. Pure function — no side effects, /// fully table-testable. Inputs chosen to match what `DoctateApp` can /// observe cheaply per egui frame. /// /// Decision rules for the `Idle` branch: /// - A failure younger than the last success (or a failure with no /// prior success at all) wins → `Offline`. /// - Otherwise fall back to success-age vs. threshold (classic staleness). /// /// `last_*_age` arguments are `Option` = "seconds since that /// event, or `None` if it never happened". Smaller Duration = more recent. pub fn pick_footer_status( finalizing: bool, worker_state: WorkerState, queue_len: usize, last_success_age: Option, last_failure_age: Option, offline_threshold: Duration, ) -> FooterStatus { if finalizing { return FooterStatus::Finalizing; } match worker_state { WorkerState::Uploading => FooterStatus::Uploading { count: queue_len.max(1), }, WorkerState::Polling => FooterStatus::Synchronizing, WorkerState::Idle => { let failure_more_recent = match (last_success_age, last_failure_age) { (Some(s), Some(f)) => f < s, // smaller age = younger (None, Some(_)) => true, // only a failure on record _ => false, }; if failure_more_recent { return FooterStatus::Offline; } match last_success_age { None => FooterStatus::NeverConnected, Some(age) if age > offline_threshold => FooterStatus::Offline, Some(_) => FooterStatus::Online, } } } } #[cfg(test)] mod tests { use super::*; const THRESHOLD: Duration = Duration::from_secs(30); /// Convenience: most tests don't care about the failure field. fn status( finalizing: bool, worker_state: WorkerState, queue_len: usize, last_success_age: Option, ) -> FooterStatus { pick_footer_status(finalizing, worker_state, queue_len, last_success_age, None, THRESHOLD) } #[test] fn finalizing_wins_over_everything() { let s = pick_footer_status( true, WorkerState::Uploading, 5, Some(Duration::from_millis(1)), Some(Duration::from_millis(1)), THRESHOLD, ); assert_eq!(s, FooterStatus::Finalizing); } #[test] fn uploading_when_queue_non_empty() { let s = status( false, WorkerState::Uploading, 3, Some(Duration::from_millis(1)), ); assert_eq!(s, FooterStatus::Uploading { count: 3 }); } #[test] fn uploading_reports_at_least_one_even_if_scan_raced() { let s = status( false, WorkerState::Uploading, 0, Some(Duration::from_millis(1)), ); assert_eq!(s, FooterStatus::Uploading { count: 1 }); } #[test] fn synchronizing_when_polling_and_no_queue() { let s = status(false, WorkerState::Polling, 0, Some(Duration::from_millis(1))); assert_eq!(s, FooterStatus::Synchronizing); } #[test] fn never_connected_without_last_success() { let s = status(false, WorkerState::Idle, 0, None); assert_eq!(s, FooterStatus::NeverConnected); } #[test] fn offline_when_last_success_is_stale() { let s = status( false, WorkerState::Idle, 0, Some(Duration::from_secs(120)), ); assert_eq!(s, FooterStatus::Offline); } #[test] fn online_when_last_success_is_fresh() { let s = status(false, WorkerState::Idle, 0, Some(Duration::from_secs(5))); assert_eq!(s, FooterStatus::Online); } #[test] fn offline_threshold_is_inclusive_of_equal_age_as_online() { let s = status(false, WorkerState::Idle, 0, Some(THRESHOLD)); assert_eq!(s, FooterStatus::Online); } #[test] fn offline_while_queue_has_items_but_worker_backing_off() { let s = status( false, WorkerState::Idle, 3, Some(Duration::from_secs(120)), ); assert_eq!(s, FooterStatus::Offline); } #[test] fn online_while_queue_has_items_but_worker_idle_and_recent_success() { let s = status(false, WorkerState::Idle, 3, Some(Duration::from_secs(2))); assert_eq!(s, FooterStatus::Online); } #[test] fn never_connected_while_queue_has_items_and_no_success_ever() { let s = status(false, WorkerState::Idle, 3, None); assert_eq!(s, FooterStatus::NeverConnected); } // --- last_failure-driven scenarios --- /// Core regression this fix addresses: server was reachable (fresh /// last_success), then went offline mid-session; a single failed /// upload must flip the footer to Offline immediately, without /// waiting for the success-age threshold. #[test] fn failure_younger_than_success_forces_offline() { let s = pick_footer_status( false, WorkerState::Idle, 1, Some(Duration::from_secs(10)), // was online 10s ago Some(Duration::from_secs(1)), // but failed 1s ago THRESHOLD, ); assert_eq!(s, FooterStatus::Offline); } /// Complement: success younger than the last failure → the server /// recovered. Show Online. #[test] fn success_younger_than_failure_wins_back_online() { let s = pick_footer_status( false, WorkerState::Idle, 0, Some(Duration::from_secs(1)), // succeeded 1s ago Some(Duration::from_secs(10)), // previous failure 10s ago THRESHOLD, ); assert_eq!(s, FooterStatus::Online); } /// Only a failure ever, no success: treat as Offline, not /// NeverConnected — we tried and got rejected, which is a stronger /// signal than "silent cold start". #[test] fn only_failure_no_success_is_offline() { let s = pick_footer_status( false, WorkerState::Idle, 0, None, Some(Duration::from_secs(5)), THRESHOLD, ); assert_eq!(s, FooterStatus::Offline); } }