79e91246c6
The `WorkerSnapshot` now includes `last_failure`, which tracks the time of the last recorded error. This allows the UI to display a more accurate status, especially when a failure occurs after a recent success. The `pick_footer_status` function has been updated to prioritize `last_failure`. If a failure occurred more recently than the last success, the footer will show `Offline`, regardless of the success age. This addresses scenarios where the server might have temporarily failed, and the UI should reflect that immediately. The `run_loop` function now updates `last_failure` upon transient or terminal upload errors and poll failures. This ensures that the new state information is correctly propagated.
265 lines
8.2 KiB
Rust
265 lines
8.2 KiB
Rust
//! 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<Duration>` = "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<Duration>,
|
|
last_failure_age: Option<Duration>,
|
|
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<Duration>,
|
|
) -> 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);
|
|
}
|
|
}
|