use super::*;
#[derive(Debug, Clone)]
pub(crate) struct CoordinatorState {
pub current_turn_id: String,
pub last_uploaded_hash: Option<String>,
pub last_uploaded_turn_id: Option<String>,
pub upload_in_flight: bool,
pub pending_capture: Option<PendingCapture>,
pub display_mode: DisplayMode,
pub first_screenshot_eligible: bool,
}
impl Default for CoordinatorState {
fn default() -> Self {
Self {
current_turn_id: String::new(),
last_uploaded_hash: None,
last_uploaded_turn_id: None,
upload_in_flight: false,
pending_capture: None,
display_mode: DisplayMode::Hidden,
first_screenshot_eligible: false,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct PendingCapture {
pub turn_id: String,
pub source: PendingSource,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum PendingSource {
Debounce,
Grace,
Fallback,
Refresh,
SetDisplayMode,
}
#[derive(Debug, Clone)]
pub(crate) struct UploadCompleted {
pub success: bool,
pub hash: String,
pub turn_id: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum Trigger {
TurnStarted { turn_id: String },
PaneUpdate,
Refresh,
SetDisplayMode(DisplayMode),
#[allow(dead_code)]
GraceTimeout,
FallbackTick,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum Action {
Skip,
Capture,
Debounce { delay_ms: u64 },
}
pub(crate) fn should_upload(
current_turn_id: &str,
screen_hash: &str,
state: &CoordinatorState,
) -> bool {
if state.last_uploaded_turn_id.as_deref() != Some(current_turn_id) {
return true;
}
state.last_uploaded_hash.as_deref() != Some(screen_hash)
}
pub(crate) fn record_upload(
state: &mut CoordinatorState,
current_turn_id: &str,
screen_hash: &str,
) {
state.last_uploaded_hash = Some(screen_hash.to_string());
state.last_uploaded_turn_id = Some(current_turn_id.to_string());
}
pub(crate) fn handle_trigger(
state: &mut CoordinatorState,
trigger: Trigger,
current_screen_hash: Option<&str>,
) -> Action {
match trigger {
Trigger::TurnStarted { turn_id } => {
state.current_turn_id = turn_id;
state.last_uploaded_turn_id = None;
state.pending_capture = None;
state.first_screenshot_eligible = true;
Action::Skip
}
Trigger::PaneUpdate => {
if state.display_mode != DisplayMode::Screenshot {
return Action::Skip;
}
if state.current_turn_id.is_empty() {
return Action::Skip;
}
if !state.first_screenshot_eligible {
return Action::Skip;
}
Action::Debounce { delay_ms: 250 }
}
Trigger::Refresh => Action::Capture,
Trigger::SetDisplayMode(mode) => {
state.display_mode = mode;
if mode == DisplayMode::Screenshot {
Action::Capture
} else {
Action::Skip
}
}
Trigger::GraceTimeout => {
if !claim_first_screenshot(state) {
return Action::Skip;
}
Action::Capture
}
Trigger::FallbackTick => {
let needs_capture = current_screen_hash
.is_some_and(|hash| should_upload(&state.current_turn_id, hash, state));
if needs_capture {
Action::Capture
} else {
Action::Skip
}
}
}
}
pub(crate) fn claim_first_screenshot(state: &mut CoordinatorState) -> bool {
if state.first_screenshot_eligible {
state.first_screenshot_eligible = false;
true
} else {
false
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn different_turn_same_hash_must_upload() {
let state = CoordinatorState {
current_turn_id: "turn2".into(),
last_uploaded_hash: Some("abc".into()),
last_uploaded_turn_id: Some("turn1".into()),
..Default::default()
};
assert!(should_upload("turn2", "abc", &state));
}
#[test]
fn same_turn_different_hash_must_upload() {
let state = CoordinatorState {
current_turn_id: "turn1".into(),
last_uploaded_hash: Some("abc".into()),
last_uploaded_turn_id: Some("turn1".into()),
..Default::default()
};
assert!(should_upload("turn1", "xyz", &state));
}
#[test]
fn same_turn_same_hash_skips() {
let state = CoordinatorState {
current_turn_id: "turn1".into(),
last_uploaded_hash: Some("abc".into()),
last_uploaded_turn_id: Some("turn1".into()),
..Default::default()
};
assert!(!should_upload("turn1", "abc", &state));
}
#[test]
fn first_upload_always_passes() {
let state = CoordinatorState::default();
assert!(should_upload("turn1", "abc", &state));
}
#[test]
fn different_turn_different_hash_must_upload() {
let state = CoordinatorState {
current_turn_id: "turn2".into(),
last_uploaded_hash: Some("abc".into()),
last_uploaded_turn_id: Some("turn1".into()),
..Default::default()
};
assert!(should_upload("turn2", "xyz", &state));
}
#[test]
fn record_upload_sets_hash_and_turn() {
let mut state = CoordinatorState::default();
record_upload(&mut state, "turn1", "abc");
assert_eq!(state.last_uploaded_hash.as_deref(), Some("abc"));
assert_eq!(state.last_uploaded_turn_id.as_deref(), Some("turn1"));
}
#[test]
fn record_upload_overwrites_previous() {
let mut state = CoordinatorState {
last_uploaded_hash: Some("old".into()),
last_uploaded_turn_id: Some("turn0".into()),
..Default::default()
};
record_upload(&mut state, "turn1", "new");
assert_eq!(state.last_uploaded_hash.as_deref(), Some("new"));
assert_eq!(state.last_uploaded_turn_id.as_deref(), Some("turn1"));
}
#[test]
fn turn_started_resets_turn_state() {
let mut state = CoordinatorState {
current_turn_id: "old_turn".into(),
last_uploaded_hash: Some("abc".into()),
last_uploaded_turn_id: Some("old_turn".into()),
pending_capture: Some(PendingCapture {
turn_id: "old_turn".into(),
source: PendingSource::Debounce,
}),
upload_in_flight: true,
first_screenshot_eligible: false,
..Default::default()
};
let action = handle_trigger(
&mut state,
Trigger::TurnStarted {
turn_id: "new_turn".into(),
},
None,
);
assert_eq!(action, Action::Skip);
assert_eq!(state.current_turn_id, "new_turn");
assert_eq!(state.last_uploaded_turn_id, None);
assert_eq!(state.pending_capture, None);
assert_eq!(state.last_uploaded_hash.as_deref(), Some("abc"));
assert!(state.upload_in_flight);
assert!(state.first_screenshot_eligible);
}
#[test]
fn pane_update_screenshot_returns_debounce() {
let mut state = CoordinatorState {
display_mode: DisplayMode::Screenshot,
current_turn_id: "turn1".into(),
first_screenshot_eligible: true,
..Default::default()
};
let action = handle_trigger(&mut state, Trigger::PaneUpdate, None);
assert_eq!(action, Action::Debounce { delay_ms: 250 });
}
#[test]
fn pane_update_hidden_skips() {
let mut state = CoordinatorState {
display_mode: DisplayMode::Hidden,
..Default::default()
};
let action = handle_trigger(&mut state, Trigger::PaneUpdate, Some("hash1"));
assert_eq!(action, Action::Skip);
}
#[test]
fn pane_update_during_inflight_still_returns_debounce() {
let mut state = CoordinatorState {
display_mode: DisplayMode::Screenshot,
current_turn_id: "turn1".into(),
first_screenshot_eligible: true,
upload_in_flight: true,
..Default::default()
};
let action = handle_trigger(&mut state, Trigger::PaneUpdate, None);
assert_eq!(action, Action::Debounce { delay_ms: 250 });
}
#[test]
fn refresh_always_captures() {
let mut state = CoordinatorState::default();
let action = handle_trigger(&mut state, Trigger::Refresh, None);
assert_eq!(action, Action::Capture);
}
#[test]
fn grace_timeout_always_captures() {
let mut state = CoordinatorState {
first_screenshot_eligible: true,
..Default::default()
};
let action = handle_trigger(&mut state, Trigger::GraceTimeout, None);
assert_eq!(action, Action::Capture);
assert!(!state.first_screenshot_eligible);
}
#[test]
fn set_display_mode_screenshot_captures() {
let mut state = CoordinatorState::default();
let action = handle_trigger(
&mut state,
Trigger::SetDisplayMode(DisplayMode::Screenshot),
None,
);
assert_eq!(action, Action::Capture);
assert_eq!(state.display_mode, DisplayMode::Screenshot);
}
#[test]
fn set_display_mode_hidden_skips() {
let mut state = CoordinatorState {
display_mode: DisplayMode::Screenshot,
..Default::default()
};
let action = handle_trigger(
&mut state,
Trigger::SetDisplayMode(DisplayMode::Hidden),
None,
);
assert_eq!(action, Action::Skip);
assert_eq!(state.display_mode, DisplayMode::Hidden);
}
#[test]
fn hidden_then_set_display_mode_screenshot_captures() {
let mut state = CoordinatorState {
display_mode: DisplayMode::Hidden,
..Default::default()
};
assert_eq!(
handle_trigger(&mut state, Trigger::PaneUpdate, Some("ignore_me")),
Action::Skip
);
let action = handle_trigger(
&mut state,
Trigger::SetDisplayMode(DisplayMode::Screenshot),
None,
);
assert_eq!(action, Action::Capture);
assert_eq!(state.display_mode, DisplayMode::Screenshot);
}
#[test]
fn fallback_tick_new_hash_captures() {
let mut state = CoordinatorState {
current_turn_id: "turn1".into(),
last_uploaded_hash: Some("old".into()),
last_uploaded_turn_id: Some("turn1".into()),
..Default::default()
};
let action = handle_trigger(&mut state, Trigger::FallbackTick, Some("new_hash"));
assert_eq!(action, Action::Capture);
}
#[test]
fn fallback_tick_same_hash_skips() {
let mut state = CoordinatorState {
current_turn_id: "turn1".into(),
last_uploaded_hash: Some("abc".into()),
last_uploaded_turn_id: Some("turn1".into()),
..Default::default()
};
let action = handle_trigger(&mut state, Trigger::FallbackTick, Some("abc"));
assert_eq!(action, Action::Skip);
}
#[test]
fn fallback_tick_no_hash_skips() {
let mut state = CoordinatorState::default();
let action = handle_trigger(&mut state, Trigger::FallbackTick, None);
assert_eq!(action, Action::Skip);
}
#[test]
fn fallback_tick_new_turn_same_hash_captures() {
let mut state = CoordinatorState {
current_turn_id: "turn2".into(),
last_uploaded_hash: Some("abc".into()),
last_uploaded_turn_id: Some("turn1".into()),
..Default::default()
};
let action = handle_trigger(&mut state, Trigger::FallbackTick, Some("abc"));
assert_eq!(action, Action::Capture);
}
#[test]
fn fallback_after_failure_retries_same_hash() {
let mut state = CoordinatorState {
current_turn_id: "turn1".into(),
last_uploaded_hash: Some("old_hash".into()),
last_uploaded_turn_id: Some("turn1".into()),
..Default::default()
};
let action = handle_trigger(&mut state, Trigger::FallbackTick, Some("new_hash"));
assert_eq!(action, Action::Capture);
let action = handle_trigger(&mut state, Trigger::FallbackTick, Some("new_hash"));
assert_eq!(action, Action::Capture);
}
#[test]
fn fallback_after_successful_upload_skips_same_hash() {
let mut state = CoordinatorState {
current_turn_id: "turn1".into(),
..Default::default()
};
record_upload(&mut state, "turn1", "abc123");
let action = handle_trigger(&mut state, Trigger::FallbackTick, Some("abc123"));
assert_eq!(action, Action::Skip);
}
#[test]
fn pending_capture_stored_and_cleared_by_runtime() {
let mut state = CoordinatorState {
current_turn_id: "turn1".into(),
upload_in_flight: true,
..Default::default()
};
state.pending_capture = Some(PendingCapture {
turn_id: "turn1".into(),
source: PendingSource::Debounce,
});
assert_eq!(state.pending_capture.as_ref().unwrap().turn_id, "turn1");
assert_eq!(
state.pending_capture.as_ref().unwrap().source,
PendingSource::Debounce
);
let pending = state.pending_capture.take();
assert!(pending.is_some());
assert!(state.pending_capture.is_none());
state.upload_in_flight = false;
record_upload(&mut state, "turn1", "hash_from_pending");
assert_eq!(
state.last_uploaded_hash.as_deref(),
Some("hash_from_pending")
);
assert_eq!(state.last_uploaded_turn_id.as_deref(), Some("turn1"));
}
#[test]
fn pending_capture_from_old_turn_is_discarded_after_turn_started() {
let mut state = CoordinatorState {
current_turn_id: "turn1".into(),
upload_in_flight: true,
pending_capture: Some(PendingCapture {
turn_id: "turn1".into(),
source: PendingSource::Fallback,
}),
..Default::default()
};
handle_trigger(
&mut state,
Trigger::TurnStarted {
turn_id: "turn2".into(),
},
None,
);
assert_eq!(state.current_turn_id, "turn2");
assert!(state.pending_capture.is_none());
state.upload_in_flight = false;
let pending = state.pending_capture.take();
assert!(pending.is_none());
}
#[test]
fn pending_capture_latest_wins() {
let mut state = CoordinatorState {
current_turn_id: "turn1".into(),
upload_in_flight: true,
..Default::default()
};
state.pending_capture = Some(PendingCapture {
turn_id: "turn1".into(),
source: PendingSource::Debounce,
});
state.pending_capture = Some(PendingCapture {
turn_id: "turn1".into(),
source: PendingSource::Fallback,
});
assert_eq!(
state.pending_capture.as_ref().unwrap().source,
PendingSource::Fallback
);
}
#[test]
fn failed_upload_does_not_prevent_pending_processing() {
let mut state = CoordinatorState {
current_turn_id: "turn1".into(),
last_uploaded_hash: Some("old_hash".into()),
last_uploaded_turn_id: Some("turn1".into()),
upload_in_flight: true,
pending_capture: Some(PendingCapture {
turn_id: "turn1".into(),
source: PendingSource::Fallback,
}),
..Default::default()
};
state.upload_in_flight = false;
let pending = state.pending_capture.take();
assert!(pending.is_some());
assert!(should_upload("turn1", "new_hash", &state));
}
#[test]
fn successful_upload_then_pending_with_same_hash_skips_via_dedup() {
let mut state = CoordinatorState {
current_turn_id: "turn1".into(),
upload_in_flight: true,
pending_capture: Some(PendingCapture {
turn_id: "turn1".into(),
source: PendingSource::Fallback,
}),
..Default::default()
};
state.upload_in_flight = false;
record_upload(&mut state, "turn1", "abc");
let pending = state.pending_capture.take();
assert!(pending.is_some());
assert!(!should_upload("turn1", "abc", &state));
state.pending_capture = Some(PendingCapture {
turn_id: "turn1".into(),
source: PendingSource::Grace,
});
let pending = state.pending_capture.take();
assert!(pending.is_some());
}
#[test]
fn state_sync_from_external_hash_overwrites_local() {
let mut state = CoordinatorState {
current_turn_id: "turn1".into(),
last_uploaded_hash: Some("coord_hash".into()),
last_uploaded_turn_id: Some("turn1".into()),
..Default::default()
};
let external_hash = Some("ext_hash".into());
if external_hash != state.last_uploaded_hash {
state.last_uploaded_hash = external_hash.clone();
if external_hash.is_none() {
state.last_uploaded_turn_id = None;
}
}
assert_eq!(state.last_uploaded_hash.as_deref(), Some("ext_hash"));
assert_eq!(state.last_uploaded_turn_id.as_deref(), Some("turn1"));
}
#[test]
fn state_sync_from_external_none_clears_turn_id() {
let mut state = CoordinatorState {
current_turn_id: "turn2".into(),
last_uploaded_hash: Some("abc".into()),
last_uploaded_turn_id: Some("turn1".into()),
..Default::default()
};
let external_hash: Option<String> = None;
if external_hash != state.last_uploaded_hash {
state.last_uploaded_hash = external_hash.clone();
if external_hash.is_none() {
state.last_uploaded_turn_id = None;
}
}
assert_eq!(state.last_uploaded_hash, None);
assert_eq!(state.last_uploaded_turn_id, None);
}
#[test]
fn pane_update_idle_skips() {
let mut state = CoordinatorState {
display_mode: DisplayMode::Screenshot,
current_turn_id: String::new(),
first_screenshot_eligible: true,
..Default::default()
};
let action = handle_trigger(&mut state, Trigger::PaneUpdate, None);
assert_eq!(action, Action::Skip);
}
#[test]
fn pane_update_not_eligible_skips() {
let mut state = CoordinatorState {
display_mode: DisplayMode::Screenshot,
current_turn_id: "turn1".into(),
first_screenshot_eligible: false,
..Default::default()
};
let action = handle_trigger(&mut state, Trigger::PaneUpdate, None);
assert_eq!(action, Action::Skip);
}
#[test]
fn claim_first_screenshot_when_eligible() {
let mut state = CoordinatorState {
first_screenshot_eligible: true,
..Default::default()
};
assert!(claim_first_screenshot(&mut state));
assert!(!state.first_screenshot_eligible);
}
#[test]
fn claim_first_screenshot_when_not_eligible() {
let mut state = CoordinatorState {
first_screenshot_eligible: false,
..Default::default()
};
assert!(!claim_first_screenshot(&mut state));
}
#[test]
fn grace_timeout_consumes_eligibility_via_claim() {
let mut state = CoordinatorState {
first_screenshot_eligible: true,
..Default::default()
};
let action = handle_trigger(&mut state, Trigger::GraceTimeout, None);
assert_eq!(action, Action::Capture);
assert!(!state.first_screenshot_eligible);
let action = handle_trigger(&mut state, Trigger::GraceTimeout, None);
assert_eq!(action, Action::Skip);
}
#[test]
fn turn_started_reenables_eligibility_after_capture() {
let mut state = CoordinatorState {
display_mode: DisplayMode::Screenshot,
..Default::default()
};
handle_trigger(
&mut state,
Trigger::TurnStarted {
turn_id: "turn1".into(),
},
None,
);
assert!(state.first_screenshot_eligible);
let action = handle_trigger(&mut state, Trigger::PaneUpdate, None);
assert_eq!(action, Action::Debounce { delay_ms: 250 });
assert!(claim_first_screenshot(&mut state));
let action = handle_trigger(&mut state, Trigger::PaneUpdate, None);
assert_eq!(action, Action::Skip);
handle_trigger(
&mut state,
Trigger::TurnStarted {
turn_id: "turn2".into(),
},
None,
);
assert!(state.first_screenshot_eligible);
let action = handle_trigger(&mut state, Trigger::PaneUpdate, None);
assert_eq!(action, Action::Debounce { delay_ms: 250 });
}
}