Files
doctate/client-desktop/src/state.rs
T
Brummel 79e91246c6 Add last_failure to worker state
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.
2026-04-18 13:45:21 +02:00

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);
}
}