use super::*;
fn visible_text(state: &MissionControlState, width: u16) -> String {
crate::tui::transcript_projection::visible_transcript_lines(state, width, 40)
.lines
.iter()
.map(|line| line.to_string())
.collect::<Vec<_>>()
.join("\n")
}
#[test]
fn activity_motion_decorates_tool_status_without_rebuilding_cache_or_copy_text() {
let mut state = linked_tool_state(
"motion-tool",
"dynamic_tool",
"dynamic_tool",
ActivityStatus::Running,
vec![],
"body ● stays literal",
);
state.start_running_prompt("work".into());
state.activity_motion.run_namespace = Some("motion-".into());
let before_copy = state.transcript_visual_text();
let first = visible_text(&state, 60);
assert!(first.contains(state.activity_spinner()), "{first}");
assert!(first.contains("body ● stays literal"));
let cached = std::sync::Arc::clone(&state.transcript_cache.borrow().visual_blocks[&60][&0]);
state.activity_motion.phase = 1;
let second = visible_text(&state, 60);
assert_ne!(first, second);
assert_eq!(state.transcript_visual_text(), before_copy);
assert!(std::sync::Arc::ptr_eq(
&cached,
&state.transcript_cache.borrow().visual_blocks[&60][&0]
));
state.reduced_motion = true;
let static_text = visible_text(&state, 60);
assert!(!static_text.contains(state.activity_spinner()));
assert_eq!(static_text, first.replace('⠋', "●"));
state.reduced_motion = false;
state.apply_activity_event(ActivityEvent::Finished {
id: ActivityId::new("motion-tool"),
status: ActivityStatus::Failed,
metadata: None,
});
let failed = visible_text(&state, 60);
assert!(!failed.contains(state.activity_spinner()));
assert!(failed.contains('✗'), "{failed}");
}
#[test]
fn activity_motion_tracks_subagent_status_and_narrow_clipping() {
let mut state = MissionControlState::default();
append_subagent_card_batch(
&mut state,
"motion-batch",
&[
ActivityStatus::Running,
ActivityStatus::Success,
ActivityStatus::Failed,
ActivityStatus::Canceled,
],
);
state.start_running_prompt("work".into());
state.activity_motion.run_namespace = Some("motion-".into());
let copy = state.transcript_visual_text();
let text = visible_text(&state, 120);
assert_eq!(text.matches(state.activity_spinner()).count(), 2, "{text}");
assert!(text.contains('✓') && text.contains('✗') && text.contains('⊘'));
for width in [1, 2, 3, 4, 8, 16] {
let static_lines = visible_scrollback_visual_lines(&state, width, 40, 0);
let animated =
crate::tui::transcript_projection::visible_transcript_lines(&state, width, 40);
for (original, decorated) in static_lines.lines.iter().zip(animated.lines.iter()) {
assert_eq!(original.line.width(), decorated.width());
}
}
assert_eq!(state.transcript_visual_text(), copy);
}
#[test]
fn activity_motion_keeps_selection_style_and_literal_dots() {
use ratatui::{
style::Style,
text::{Line, Span},
};
let mut state = MissionControlState::default();
state.start_running_prompt("work".into());
state.activity_motion.run_namespace = Some("run-1/".into());
let style = Style::default().add_modifier(Modifier::REVERSED);
let mut line = Line::from(vec![Span::styled("界 ● literal ●", style)]);
crate::tui::transcript_projection::decorate_status_indicators(
&state,
&[(3, Some(ActivityId::new("run-1/tool")))],
&mut line,
);
assert_eq!(
line.to_string(),
format!("界 {} literal ●", state.activity_spinner())
);
assert_eq!(line.spans[0].style, style);
}
fn finish_live_card(
state: &mut MissionControlState,
id: &str,
status: ActivityStatus,
now: std::time::Instant,
) {
let event = ActivityEvent::Finished {
id: ActivityId::new(id),
status,
metadata: None,
};
state.observe_live_card_completion(&event, now);
state.apply_activity_event(event);
}
fn accented_success_count(state: &MissionControlState, width: u16) -> usize {
crate::tui::transcript_projection::visible_transcript_lines(state, width, 40)
.lines
.iter()
.flat_map(|line| &line.spans)
.filter(|span| {
span.content == "✓" && span.style.add_modifier.contains(Modifier::UNDERLINED)
})
.count()
}
#[test]
fn activity_motion_success_accent_expires_without_changing_cache_or_copy() {
let mut state = linked_tool_state(
"success",
"dynamic_tool",
"dynamic_tool",
ActivityStatus::Running,
vec![],
"literal ✓ remains plain",
);
state.start_running_prompt("work".into());
let now = std::time::Instant::now();
finish_live_card(&mut state, "success", ActivityStatus::Success, now);
state.clear_running_prompt();
assert_eq!(accented_success_count(&state, 60), 1);
let copy = state.transcript_visual_text();
let text = visible_text(&state, 60);
let cached = std::sync::Arc::clone(&state.transcript_cache.borrow().visual_blocks[&60][&0]);
assert!(state.activity_motion_timeout(now).is_some());
assert!(state.tick_activity_motion(now + std::time::Duration::from_secs(1)));
assert_eq!(accented_success_count(&state, 60), 0);
assert_eq!(state.activity_motion_timeout(now), None);
assert_eq!(state.transcript_visual_text(), copy);
assert_eq!(visible_text(&state, 60), text);
assert!(std::sync::Arc::ptr_eq(
&cached,
&state.transcript_cache.borrow().visual_blocks[&60][&0]
));
}
#[test]
fn activity_motion_success_accents_exclude_replay_failures_and_cancellation() {
for status in [
ActivityStatus::Success,
ActivityStatus::Failed,
ActivityStatus::Canceled,
] {
let mut state = linked_tool_state(
"historical",
"dynamic_tool",
"dynamic_tool",
ActivityStatus::Running,
vec![],
"body",
);
state.apply_activity_event(ActivityEvent::Finished {
id: ActivityId::new("historical"),
status,
metadata: None,
});
state.start_running_prompt("next turn".into());
assert_eq!(accented_success_count(&state, 60), 0);
finish_live_card(&mut state, "historical", status, std::time::Instant::now());
assert_eq!(accented_success_count(&state, 60), 0);
}
for cancel in [false, true] {
let mut state = linked_tool_state(
"tool",
"dynamic_tool",
"dynamic_tool",
ActivityStatus::Running,
vec![],
"body",
);
state.start_running_prompt("work".into());
if cancel {
state.request_running_prompt_cancel();
} else {
state.reduced_motion = true;
}
finish_live_card(
&mut state,
"tool",
ActivityStatus::Success,
std::time::Instant::now(),
);
assert_eq!(accented_success_count(&state, 60), 0);
}
}
#[test]
fn activity_motion_subagent_completion_accents_only_the_completed_task() {
let mut state = MissionControlState::default();
append_subagent_card_batch(
&mut state,
"batch",
&[ActivityStatus::Running, ActivityStatus::Success],
);
state.start_running_prompt("work".into());
let id = state
.nodes
.iter()
.find(|(id, node)| id.as_str().ends_with("/g1") && node.status == ActivityStatus::Running)
.map(|(id, _)| id.clone())
.expect("running first task");
finish_live_card(
&mut state,
id.as_str(),
ActivityStatus::Success,
std::time::Instant::now(),
);
assert_eq!(accented_success_count(&state, 120), 2);
for width in [1, 2, 3, 8, 16] {
let original = visible_scrollback_visual_lines(&state, width, 40, 0);
let accented =
crate::tui::transcript_projection::visible_transcript_lines(&state, width, 40);
for (before, after) in original.lines.iter().zip(&accented.lines) {
assert_eq!(before.line.width(), after.width());
assert_eq!(before.line.to_string(), after.to_string());
}
}
}
#[test]
fn activity_motion_primary_tool_result_then_finished_accents_success() {
use crate::tui::TuiEvent;
use crate::tui::controller::{DrainResult, apply_tui_event_to_state};
let mut state = MissionControlState::default();
state.start_running_prompt("work".into());
let call = crate::providers::ToolCall {
id: "run-1/tool".into(),
name: "dynamic_tool".into(),
arguments: serde_json::json!({}),
};
let mut drain = DrainResult::default();
apply_tui_event_to_state(
&mut state,
TuiEvent::Output(OutputEvent::ToolStarted {
call: Box::new(call.clone()),
label: "dynamic_tool".into(),
}),
&mut drain,
);
let result = crate::tools::ToolResult {
tool_name: call.name.clone(),
success: true,
content: "done".into(),
metadata: serde_json::json!({}),
display: Default::default(),
};
let summary = crate::output::tool_display_summary(&call, &result);
apply_tui_event_to_state(
&mut state,
TuiEvent::Output(OutputEvent::ToolResult {
call: Box::new(call),
result: Box::new(result),
summary: Box::new(summary),
changed_paths: vec![],
}),
&mut drain,
);
assert_eq!(accented_success_count(&state, 60), 1);
apply_tui_event_to_state(
&mut state,
TuiEvent::Activity(ActivityEvent::Finished {
id: ActivityId::new("run-1/tool"),
status: ActivityStatus::Success,
metadata: None,
}),
&mut drain,
);
assert_eq!(accented_success_count(&state, 60), 1);
state.tick_activity_motion(std::time::Instant::now() + std::time::Duration::from_secs(1));
assert_eq!(accented_success_count(&state, 60), 0);
}
#[test]
fn activity_motion_resumed_unfinished_tool_stays_static_during_new_prompt() {
use crate::tui::sessions::commands::{
apply_session_hydration_snapshot, hydrate_session_history_snapshot,
};
let event = crate::sessions::SessionEvent::new(
"tool_call",
"session".into(),
std::path::PathBuf::from("."),
serde_json::json!({"id":"old-tool", "name":"dynamic_tool", "arguments":{}}),
);
let snapshot = hydrate_session_history_snapshot(&[event], &Default::default());
let mut state = MissionControlState::default();
apply_session_hydration_snapshot(&mut state, snapshot);
assert!(
state
.nodes
.values()
.any(|node| node.status == ActivityStatus::Running)
);
let historical = visible_text(&state, 60);
assert!(historical.contains('●'), "{historical}");
state.start_running_prompt("new work".into());
state.activity_motion.run_namespace = Some("run-2/".into());
assert_eq!(visible_text(&state, 60), historical);
state.apply_output_event(&OutputEvent::ToolStarted {
call: Box::new(crate::providers::ToolCall {
id: "run-2/new-tool".into(),
name: "dynamic_tool".into(),
arguments: serde_json::json!({}),
}),
label: "new tool".into(),
});
for phase in [0, 1] {
state.activity_motion.phase = phase;
let text = visible_text(&state, 60);
assert!(text.contains(&historical), "{text}");
assert_eq!(text.matches(state.activity_spinner()).count(), 1, "{text}");
}
}