use super::*;
use crate::output::ActivityStatus;
#[test]
fn best_effort_sends_leave_queue_reserve_for_critical_events() {
let (sender, receiver) = bounded::<TuiEvent>(128);
for index in 0..64 {
sender
.send(TuiEvent::Output(OutputEvent::AssistantDelta {
text: format!("queued {index}"),
}))
.unwrap();
}
send_best_effort(
&sender,
TuiEvent::Output(OutputEvent::AssistantDelta {
text: "dropped preview".to_string(),
}),
);
send_critical(&sender, TuiEvent::Done).unwrap();
let events = receiver.try_iter().collect::<Vec<_>>();
assert_eq!(events.len(), 65);
assert!(matches!(events.last(), Some(TuiEvent::Done)));
assert!(!events.iter().any(|event| {
matches!(event, TuiEvent::Output(OutputEvent::AssistantDelta { text }) if text == "dropped preview")
}));
}
#[test]
fn budgeted_drain_stops_at_event_count_budget() {
let (sender, receiver) = bounded::<TuiEvent>(4);
for _ in 0..3 {
sender.send(TuiEvent::Done).unwrap();
}
let mut ui_state = state::MissionControlState::default();
let outcome = drain_tui_events_budgeted(
&receiver,
&mut ui_state,
DrainBudget {
max_events: 2,
max_elapsed: Duration::from_secs(1),
},
);
assert_eq!(outcome.processed, 2);
assert!(outcome.exhausted);
assert_eq!(receiver.len(), 1);
assert!(outcome.result.changed);
}
#[test]
fn budgeted_drain_stops_at_elapsed_budget() {
let (sender, receiver) = bounded::<TuiEvent>(4);
for _ in 0..3 {
sender.send(TuiEvent::Done).unwrap();
}
let mut ui_state = state::MissionControlState::default();
let start = Instant::now();
let mut calls = 0;
let outcome = drain_tui_events_budgeted_with_clock(
&receiver,
&mut ui_state,
DrainBudget {
max_events: 4,
max_elapsed: Duration::from_millis(2),
},
|| {
calls += 1;
if calls == 1 {
start
} else {
start + Duration::from_millis(3)
}
},
);
assert_eq!(outcome.processed, 1);
assert!(outcome.exhausted);
assert_eq!(receiver.len(), 2);
}
#[test]
fn later_budgeted_drain_resumes_and_applies_run_finished() {
let (sender, receiver) = bounded::<TuiEvent>(4);
sender.send(TuiEvent::Done).unwrap();
sender
.send(TuiEvent::RunFinished {
worker_id: 0,
final_event: None,
})
.unwrap();
let mut ui_state = state::MissionControlState {
active_worker_id: Some(0),
..Default::default()
};
let budget = DrainBudget {
max_events: 1,
max_elapsed: Duration::from_secs(1),
};
let first = drain_tui_events_budgeted(&receiver, &mut ui_state, budget);
let second = drain_tui_events_budgeted(&receiver, &mut ui_state, budget);
assert_eq!(first.processed, 1);
assert!(!first.result.run_finished);
assert_eq!(second.processed, 1);
assert!(second.result.run_finished);
assert_eq!(receiver.len(), 0);
}
#[test]
fn idle_worker_completion_events_are_ignored() {
let (sender, receiver) = bounded::<TuiEvent>(2);
sender
.send(TuiEvent::WorkerCompletionDeliveryFailed {
worker_id: 7,
error: "stale completion failure".to_string(),
})
.unwrap();
sender
.send(TuiEvent::RunFinished {
worker_id: 7,
final_event: Some(Box::new(TuiEvent::Error("stale completion".to_string()))),
})
.unwrap();
let mut state = state::MissionControlState {
status: "idle".to_string(),
..Default::default()
};
let result = drain_tui_events(&receiver, &mut state);
assert!(!result.changed);
assert!(!result.run_finished);
assert!(result.run_finished_worker_ids.is_empty());
assert_eq!(state.status, "idle");
assert!(state.active_worker_id.is_none());
assert!(state.transcript.is_empty());
}
#[test]
fn thinking_summary_flushes_pending_assistant_delta_before_transcript_insert() {
let (sender, receiver) = bounded::<TuiEvent>(4);
let mut ui_state = state::MissionControlState::default();
sender
.send(TuiEvent::Output(OutputEvent::AssistantDelta {
text: "partial".to_string(),
}))
.unwrap();
sender
.send(TuiEvent::Output(OutputEvent::ThinkingSummaryComplete {
text: "provider summary".to_string(),
}))
.unwrap();
drain_tui_events(&receiver, &mut ui_state);
assert_eq!(
ui_state.transcript,
vec![
"assistant: partial".to_string(),
"thinking: provider summary".to_string(),
]
);
}
#[test]
fn model_catalog_event_opens_picker_with_captured_viewport_rows() {
let (sender, receiver) = bounded::<TuiEvent>(4);
let mut ui_state = state::MissionControlState {
provider: crate::providers::OPENAI_CODEX_PROVIDER.to_string(),
model: "gpt-5".to_string(),
pending_model_catalog_request_id: Some(7),
..Default::default()
};
sender
.send(TuiEvent::ModelCatalog {
request_id: 7,
result: Ok(crate::model_catalog::CatalogForUi {
entries: (0..6)
.map(|index| {
crate::model_catalog::ModelCatalogEntry::new_codex(format!("gpt-{index}"))
})
.collect(),
stale: false,
notice: None,
}),
visible_rows: 2,
})
.unwrap();
let result = drain_tui_events(&receiver, &mut ui_state);
assert!(result.changed);
assert_eq!(ui_state.pending_model_catalog_request_id, None);
assert_eq!(result.model_catalog_loaded.len(), 1);
assert_eq!(result.model_catalog_loaded[0].2, 2);
assert!(ui_state.modals.model_picker.is_none());
}
#[test]
fn model_catalog_stale_cache_sets_stale_status() {
let (sender, receiver) = bounded::<TuiEvent>(4);
let mut ui_state = state::MissionControlState {
pending_model_catalog_request_id: Some(11),
..Default::default()
};
sender
.send(TuiEvent::ModelCatalog {
request_id: 11,
result: Ok(crate::model_catalog::CatalogForUi {
entries: vec![crate::model_catalog::ModelCatalogEntry::new_codex(
"gpt-stale",
)],
stale: true,
notice: Some("using stale openai-codex model catalog".to_string()),
}),
visible_rows: 4,
})
.unwrap();
let result = drain_tui_events(&receiver, &mut ui_state);
assert!(result.changed);
assert_eq!(ui_state.pending_model_catalog_request_id, None);
assert_eq!(result.model_catalog_loaded.len(), 1);
assert!(matches!(result.model_catalog_loaded[0].1, Ok(ref catalog) if catalog.stale));
}
#[test]
fn stale_model_catalog_event_does_not_mutate_picker() {
let (sender, receiver) = bounded::<TuiEvent>(4);
let mut ui_state = state::MissionControlState {
pending_model_catalog_request_id: Some(2),
..Default::default()
};
ui_state.open_model_picker(
vec![crate::model_catalog::ModelCatalogEntry::new_codex(
"gpt-current",
)],
std::collections::BTreeSet::new(),
Some("current notice".to_string()),
4,
);
sender
.send(TuiEvent::ModelCatalog {
request_id: 1,
result: Ok(crate::model_catalog::CatalogForUi {
entries: vec![crate::model_catalog::ModelCatalogEntry::new_codex(
"gpt-stale",
)],
stale: false,
notice: Some("stale notice".to_string()),
}),
visible_rows: 4,
})
.unwrap();
let result = drain_tui_events(&receiver, &mut ui_state);
assert!(!result.model_catalog_finished);
assert_eq!(ui_state.pending_model_catalog_request_id, Some(2));
assert_eq!(ui_state.status, "");
let picker = ui_state.modals.model_picker.as_ref().unwrap();
assert_eq!(picker.entries[0].model, "gpt-current");
assert_eq!(picker.notice.as_deref(), Some("current notice"));
}
#[test]
fn best_effort_drops_when_channel_is_full() {
let (sender, receiver) = bounded::<TuiEvent>(1);
sender.send(TuiEvent::Done).unwrap();
send_best_effort(&sender, TuiEvent::Error("drop".to_string()));
assert!(matches!(receiver.recv().unwrap(), TuiEvent::Done));
assert!(receiver.try_recv().is_err());
}
#[test]
fn critical_reports_saturation_without_deadlock() {
let (sender, _receiver) = bounded::<TuiEvent>(1);
sender.send(TuiEvent::Done).unwrap();
let error = send_critical(&sender, TuiEvent::Done)
.unwrap_err()
.to_string();
assert!(error.contains("tui event channel blocked or closed"));
}
#[test]
fn next_ui_timer_timeout_uses_cursor_blink_due_time() {
let state = state::MissionControlState {
focus_pane: state::TuiFocusPane::Prompt,
..Default::default()
};
let now = Instant::now();
let last_blink = now - Duration::from_millis(200);
assert_eq!(
next_ui_timer_timeout(&state, last_blink, now, None, now),
Some(PROMPT_CURSOR_BLINK_INTERVAL - Duration::from_millis(200))
);
}
#[test]
fn next_ui_timer_timeout_uses_footer_git_branch_refresh_due_time() {
let state = state::MissionControlState {
focus_pane: state::TuiFocusPane::Transcript,
..Default::default()
};
let now = Instant::now();
assert_eq!(
next_ui_timer_timeout(&state, now, now, None, now),
Some(FOOTER_GIT_BRANCH_REFRESH_INTERVAL)
);
}
#[test]
fn prompt_cursor_blink_due_only_when_prompt_cursor_can_change() {
let mut state = state::MissionControlState {
focus_pane: state::TuiFocusPane::Prompt,
..Default::default()
};
let old_blink = Instant::now() - PROMPT_CURSOR_BLINK_INTERVAL;
assert!(prompt_cursor_blink_due(&state, old_blink));
state.focus_pane = state::TuiFocusPane::Transcript;
assert!(!prompt_cursor_blink_due(&state, old_blink));
state.focus_prompt();
state.set_prompt_text("typed", "typed".len());
assert!(!prompt_cursor_blink_due(&state, old_blink));
}
#[test]
fn redraw_intent_draws_immediate_and_scroll() {
assert!(should_draw_frame(RedrawIntent::Immediate));
assert!(should_draw_frame(RedrawIntent::ScrollOnly));
assert!(!should_draw_frame(RedrawIntent::None));
}
#[test]
fn activity_delta_drain_applies_all_queued_events_without_visible_node_gate() {
let (sender, receiver) = bounded::<TuiEvent>(4);
let id = crate::output::ActivityId::new("tool-1");
sender
.send(TuiEvent::Activity(ActivityEvent::Started {
id: id.clone(),
parent_id: None,
kind: crate::output::ActivityKind::Tool,
status: ActivityStatus::Running,
metadata: crate::output::ActivityMetadata::new("running"),
}))
.unwrap();
sender
.send(TuiEvent::Activity(ActivityEvent::Delta {
id: id.clone(),
preview: "a".to_string(),
}))
.unwrap();
sender
.send(TuiEvent::Activity(ActivityEvent::Delta {
id: id.clone(),
preview: "b".to_string(),
}))
.unwrap();
sender
.send(TuiEvent::Activity(ActivityEvent::Finished {
id: id.clone(),
status: ActivityStatus::Success,
metadata: None,
}))
.unwrap();
let mut state = state::MissionControlState::default();
let result = drain_tui_events(&receiver, &mut state);
let node = state.nodes.get(&id).unwrap();
assert!(result.changed);
assert_eq!(node.preview, "ab");
assert_eq!(node.status, ActivityStatus::Success);
}
#[test]
fn activity_structural_events_drain_without_pacing() {
let (sender, receiver) = bounded::<TuiEvent>(3);
for index in 0..3 {
sender
.send(TuiEvent::Activity(ActivityEvent::Started {
id: crate::output::ActivityId::new(format!("tool-{index}")),
parent_id: None,
kind: crate::output::ActivityKind::Tool,
status: ActivityStatus::Running,
metadata: crate::output::ActivityMetadata::new(format!("tool {index}")),
}))
.unwrap();
}
let mut state = state::MissionControlState::default();
let result = drain_tui_events(&receiver, &mut state);
assert!(result.changed);
assert_eq!(state.visible_nodes().len(), 3);
}
#[test]
fn activity_started_then_terminal_order_is_preserved() {
let (sender, receiver) = bounded::<TuiEvent>(2);
let id = crate::output::ActivityId::new("tool-1");
sender
.send(TuiEvent::Activity(ActivityEvent::Started {
id: id.clone(),
parent_id: None,
kind: crate::output::ActivityKind::Tool,
status: ActivityStatus::Running,
metadata: crate::output::ActivityMetadata::new("running"),
}))
.unwrap();
sender
.send(TuiEvent::Activity(ActivityEvent::Finished {
id: id.clone(),
status: ActivityStatus::Success,
metadata: Some(crate::output::ActivityMetadata::new("done")),
}))
.unwrap();
let mut state = state::MissionControlState::default();
let _result = drain_tui_events(&receiver, &mut state);
assert_eq!(
state.nodes.get(&id).unwrap().status,
ActivityStatus::Success
);
}
#[test]
fn assistant_delta_drain_applies_full_text_without_visual_pacing() {
let (sender, receiver) = bounded::<TuiEvent>(1);
let text = "界".repeat(34);
sender
.send(TuiEvent::Output(OutputEvent::AssistantDelta { text }))
.unwrap();
let mut state = state::MissionControlState::default();
drain_tui_events(&receiver, &mut state);
let streamed = state.transcript[0].trim_start_matches("assistant: ");
assert_eq!(streamed.chars().count(), 34);
}
#[test]
fn assistant_complete_after_delta_reconciles_without_duplication() {
let (sender, receiver) = bounded::<TuiEvent>(2);
let text = "x".repeat(40);
sender
.send(TuiEvent::Output(OutputEvent::AssistantDelta {
text: text.clone(),
}))
.unwrap();
sender
.send(TuiEvent::Output(OutputEvent::AssistantComplete {
text: text.clone(),
}))
.unwrap();
let mut state = state::MissionControlState::default();
let result = drain_tui_events(&receiver, &mut state);
assert!(result.changed);
assert_eq!(state.transcript, vec![format!("assistant: {text}")]);
}
#[test]
fn drain_tui_events_applies_activity_updates_without_effect_triggers() {
let (sender, receiver) = bounded::<TuiEvent>(4);
let id = crate::output::ActivityId::new("tool-1");
sender
.send(TuiEvent::Activity(ActivityEvent::Started {
id: id.clone(),
parent_id: None,
kind: crate::output::ActivityKind::Tool,
status: ActivityStatus::Queued,
metadata: crate::output::ActivityMetadata::new("queued"),
}))
.unwrap();
sender
.send(TuiEvent::Activity(ActivityEvent::Started {
id: id.clone(),
parent_id: None,
kind: crate::output::ActivityKind::Tool,
status: ActivityStatus::Running,
metadata: crate::output::ActivityMetadata::new("running"),
}))
.unwrap();
sender
.send(TuiEvent::Activity(ActivityEvent::Delta {
id: id.clone(),
preview: "ignored for movement".to_string(),
}))
.unwrap();
sender
.send(TuiEvent::Activity(ActivityEvent::FinalPreview {
id: id.clone(),
preview: "preview only".to_string(),
metadata: None,
status: None,
}))
.unwrap();
let mut state = state::MissionControlState::default();
let _result = drain_tui_events(&receiver, &mut state);
assert_eq!(
state.nodes.get(&id).unwrap().status,
ActivityStatus::Running
);
assert_eq!(state.nodes.get(&id).unwrap().preview, "preview only");
}
#[test]
fn drain_tui_events_applies_selected_metadata_refresh() {
let (sender, receiver) = bounded::<TuiEvent>(1);
let id = crate::output::ActivityId::new("tool-1");
let mut state = state::MissionControlState::default();
state.apply_activity_event(ActivityEvent::Started {
id: id.clone(),
parent_id: None,
kind: crate::output::ActivityKind::Tool,
status: ActivityStatus::Running,
metadata: crate::output::ActivityMetadata::new("before"),
});
state.selected = 0;
sender
.send(TuiEvent::Activity(ActivityEvent::FinalPreview {
id: id.clone(),
preview: "metadata refresh".to_string(),
metadata: Some(crate::output::ActivityMetadata::new("after")),
status: Some(ActivityStatus::Running),
}))
.unwrap();
let _result = drain_tui_events(&receiver, &mut state);
assert_eq!(state.nodes.get(&id).unwrap().metadata.label, "after");
}
#[test]
fn drain_tui_events_applies_all_pending_events() {
let (sender, receiver) = bounded::<TuiEvent>(8);
sender
.send(TuiEvent::Output(
crate::output::OutputEvent::AssistantDelta {
text: "fast".to_string(),
},
))
.unwrap();
sender
.send(TuiEvent::Output(
crate::output::OutputEvent::AssistantComplete {
text: "fast enough".to_string(),
},
))
.unwrap();
sender
.send(TuiEvent::Error("running prompt…".to_string()))
.unwrap();
sender.send(TuiEvent::Done).unwrap();
let mut state = state::MissionControlState::default();
let result = drain_tui_events(&receiver, &mut state);
assert!(result.changed);
assert!(!result.run_finished);
assert_eq!(state.transcript, vec!["assistant: fast enough".to_string()]);
assert_eq!(state.status, "run complete");
assert!(receiver.try_recv().is_err());
let empty = drain_tui_events(&receiver, &mut state);
assert!(!empty.changed);
assert!(!empty.run_finished);
}
#[test]
fn drain_tui_events_reports_run_finished_without_overwriting_status() {
let (sender, receiver) = bounded::<TuiEvent>(2);
sender.send(TuiEvent::Error("failed".to_string())).unwrap();
sender
.send(TuiEvent::RunFinished {
worker_id: 0,
final_event: None,
})
.unwrap();
let mut state = state::MissionControlState {
active_worker_id: Some(0),
..Default::default()
};
let result = drain_tui_events(&receiver, &mut state);
assert!(result.changed);
assert!(result.run_finished);
assert_eq!(state.status, "failed");
}
#[test]
fn drain_tui_events_records_canceled_prompt_without_error_event() {
let (sender, receiver) = bounded::<TuiEvent>(2);
sender
.send(TuiEvent::RunCanceled {
prompt: "inspect repo".to_string(),
})
.unwrap();
sender
.send(TuiEvent::RunFinished {
worker_id: 0,
final_event: None,
})
.unwrap();
let mut state = state::MissionControlState::default();
state.start_running_prompt("inspect repo".to_string());
state.active_worker_id = Some(0);
let result = drain_tui_events(&receiver, &mut state);
assert!(result.changed);
assert!(result.run_finished);
assert_eq!(state.status, "canceled: inspect repo");
assert_eq!(state.transcript, vec!["canceled: inspect repo".to_string()]);
assert!(state.running_prompt.is_none());
}
#[test]
fn compaction_started_pushes_compact_started_transcript() {
let mut ui_state = state::MissionControlState::default();
ui_state.record_compaction_started_transcript();
assert_eq!(ui_state.transcript.len(), 1);
assert_eq!(ui_state.transcript[0], "compact: started");
}
#[test]
fn compaction_activity_lifecycle_updates_single_node() {
let id = crate::output::ActivityId::new("compaction:session-1:1");
let mut ui_state = state::MissionControlState::default();
let mut metadata = crate::output::ActivityMetadata::new("compaction");
metadata.fields = vec![
("session_id".to_string(), "session-1".to_string()),
("provider".to_string(), "local".to_string()),
("model".to_string(), "compact-model".to_string()),
];
ui_state.apply_activity_event(ActivityEvent::Started {
id: id.clone(),
parent_id: None,
kind: crate::output::ActivityKind::Compaction,
status: ActivityStatus::Running,
metadata: metadata.clone(),
});
ui_state.apply_activity_event(ActivityEvent::FinalPreview {
id: id.clone(),
preview: "summary text".to_string(),
metadata: None,
status: Some(ActivityStatus::Success),
});
ui_state.apply_activity_event(ActivityEvent::Finished {
id: id.clone(),
status: ActivityStatus::Success,
metadata: Some(metadata),
});
assert_eq!(ui_state.roots, vec![id.clone()]);
let node = ui_state.nodes.get(&id).unwrap();
assert_eq!(node.kind, crate::output::ActivityKind::Compaction);
assert_eq!(node.status, ActivityStatus::Success);
assert_eq!(node.preview, "summary text");
let detail = ui_state.selected_activity_text();
assert!(detail.contains("type: compaction"), "{detail}");
assert!(detail.contains("summary text"), "{detail}");
}
#[test]
fn compaction_activity_finished_failed_without_duplicate_node() {
let id = crate::output::ActivityId::new("compaction:session-1:2");
let mut ui_state = state::MissionControlState::default();
ui_state.apply_activity_event(ActivityEvent::Started {
id: id.clone(),
parent_id: None,
kind: crate::output::ActivityKind::Compaction,
status: ActivityStatus::Running,
metadata: crate::output::ActivityMetadata::new("compaction"),
});
ui_state.apply_activity_event(ActivityEvent::Finished {
id: id.clone(),
status: ActivityStatus::Failed,
metadata: None,
});
assert_eq!(ui_state.roots, vec![id.clone()]);
assert_eq!(ui_state.nodes.len(), 1);
assert_eq!(
ui_state.nodes.get(&id).unwrap().status,
ActivityStatus::Failed
);
}
#[test]
fn drain_stale_tui_events_discards_queued_previous_run_events() {
let (sender, receiver) = bounded::<TuiEvent>(8);
sender
.send(TuiEvent::Output(OutputEvent::UserPrompt {
text: "old".to_string(),
}))
.unwrap();
sender
.send(TuiEvent::Activity(ActivityEvent::Started {
id: crate::output::ActivityId::new("old-tool"),
parent_id: None,
kind: crate::output::ActivityKind::Tool,
status: crate::output::ActivityStatus::Running,
metadata: crate::output::ActivityMetadata::new("old tool"),
}))
.unwrap();
sender.send(TuiEvent::Done).unwrap();
sender
.send(TuiEvent::RunFinished {
worker_id: 0,
final_event: None,
})
.unwrap();
let result = drain_stale_tui_events(&receiver);
assert!(result.changed);
assert!(result.run_finished);
assert!(receiver.try_recv().is_err());
}