//! 纯 UI 状态机的测试:思考计时、工具执行、块构建与折叠。 //! Tests for the pure UI state machine: thinking timings, tool executions, //! block building and collapsing. use focus_core::event::AgentEvent; use focus_core::model::*; use focus_core::tool::{ToolResult, ToolUpdate}; use focus_json::JsonValue; use focus_tui::state::*; use std::collections::{HashMap, HashSet}; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::Arc; /// 可推进的假时钟。 /// An advanceable fake clock. struct FakeClock(std::sync::Arc); impl FakeClock { fn new() -> Self { Self(std::sync::Arc::new(AtomicU64::new(0))) } fn clock(&self) -> Clock { let inner = self.0.clone(); Arc::new(move || inner.load(Ordering::SeqCst)) } fn advance(&self, ms: u64) { self.0.fetch_add(ms, Ordering::SeqCst); } } fn thinking_block(text: &str) -> ContentBlock { ContentBlock::Thinking(ThinkingContent { thinking: text.into(), signature: None, redacted: false, }) } fn assistant(content: Vec) -> AssistantMessage { AssistantMessage { content, model: "m".into(), usage: Usage::default(), stop_reason: StopReason::Stop, error_message: None, timestamp: 0, } } #[test] fn thinking_duration_is_measured() { let fc = FakeClock::new(); let mut rs = RunState::new(fc.clock()); // 思考块出现(t=0)。 // Thinking block appears (t=0). let p1 = assistant(vec![thinking_block("hmm")]); rs.on_agent_event(&AgentEvent::MessageStart { message: Message::Assistant(p1), }); fc.advance(500); // 思考 + 文本(思考结束于 t=500)。 // Thinking + text (thinking ends at t=500). let p2 = assistant(vec![thinking_block("hmm"), ContentBlock::text("answer")]); rs.on_agent_event(&AgentEvent::MessageUpdate { message: Message::Assistant(p2), event_type: "text_start".into(), }); fc.advance(300); let p3 = assistant(vec![ thinking_block("hmm"), ContentBlock::text("answer extended"), ]); rs.on_agent_event(&AgentEvent::MessageUpdate { message: Message::Assistant(p3.clone()), event_type: "text_delta".into(), }); rs.on_agent_event(&AgentEvent::MessageEnd { message: Message::Assistant(p3.clone()), }); rs.finalize(); let timing = rs.thinking.get(&(0, 0)).expect("thinking timing"); assert_eq!(timing.duration_ms(), 500); } #[test] fn tool_execution_tracks_summary_output_and_duration() { let fc = FakeClock::new(); let mut rs = RunState::new(fc.clock()); let mut args = JsonValue::obj(); args.insert("command", "cargo test".into()).ok(); rs.on_agent_event(&AgentEvent::ToolExecutionStart { tool_call_id: "call_1".into(), tool_name: "shell".into(), args, }); assert_eq!(rs.tool_execs.len(), 1); assert_eq!(rs.tool_execs[0].status, ExecStatus::Running); assert_eq!(rs.tool_execs[0].summary, "shell: cargo test"); fc.advance(100); rs.on_agent_event(&AgentEvent::ToolExecutionUpdate { tool_call_id: "call_1".into(), tool_name: "shell".into(), partial_result: ToolUpdate { content: vec![ContentBlock::text("compiling…")], details: JsonValue::obj(), }, }); fc.advance(100); rs.on_agent_event(&AgentEvent::ToolExecutionUpdate { tool_call_id: "call_1".into(), tool_name: "shell".into(), partial_result: ToolUpdate { content: vec![ContentBlock::text("2 passed")], details: JsonValue::obj(), }, }); rs.on_agent_event(&AgentEvent::ToolExecutionEnd { tool_call_id: "call_1".into(), tool_name: "shell".into(), result: ToolResult::text("2 passed"), is_error: false, }); let exec = &rs.tool_execs[0]; assert_eq!(exec.status, ExecStatus::Done); assert_eq!(exec.duration_ms(), Some(200)); assert_eq!(exec.output, "compiling…\n2 passed"); assert_eq!(exec.result_text, "2 passed"); assert!(!exec.is_error); } #[test] fn blocks_are_collapsed_by_default_and_summarized() { let mut expanded = HashSet::new(); let transcript = vec![ Message::user_text("hi"), Message::Assistant(assistant(vec![ thinking_block("secret reasoning"), ContentBlock::ToolCall(ToolCall { id: "c1".into(), name: "read".into(), arguments: { let mut a = JsonValue::obj(); a.insert("path", "src/main.rs".into()).ok(); a }, }), ])), ]; let blocks = build_blocks(&transcript, &HashMap::new(), None, &expanded); // 折叠态:思考块只显示摘要(无展开文本),工具调用只显示摘要。 // Collapsed: thinking shows only its summary; tool calls show only their // summary. for b in &blocks { match b { UiBlock::Thinking { text, expanded, duration_ms, .. } => { assert!(!expanded); assert_eq!(text, "secret reasoning"); assert_eq!(*duration_ms, None); } UiBlock::ToolCall { summary, expanded, .. } => { assert!(!expanded); assert_eq!(summary, "read src/main.rs"); } _ => {} } } // 展开后:expanded 标记生效。 // After expanding: the expanded flag takes effect. for b in &blocks { if let Some(id) = b.id() { toggle_expanded(&mut expanded, id); } } let blocks = build_blocks(&transcript, &HashMap::new(), None, &expanded); let mut expanded_count = 0; for b in &blocks { if let UiBlock::Thinking { expanded, .. } | UiBlock::ToolCall { expanded, .. } = b { assert!(*expanded); expanded_count += 1; } } assert_eq!(expanded_count, 2); } #[test] fn build_blocks_includes_live_run() { let fc = FakeClock::new(); let mut rs = RunState::new(fc.clock()); rs.on_agent_event(&AgentEvent::MessageStart { message: Message::Assistant(assistant(vec![thinking_block("why")])), }); fc.advance(250); rs.on_agent_event(&AgentEvent::MessageUpdate { message: Message::Assistant(assistant(vec![ thinking_block("why"), ContentBlock::text("because"), ])), event_type: "text_start".into(), }); let blocks = build_blocks(&[], &HashMap::new(), Some(&rs), &HashSet::new()); // live 头 + 思考 + 文本。 // live header + thinking + text. let thinking = blocks .iter() .find_map(|b| match b { UiBlock::Thinking { duration_ms, text, .. } => Some((text.clone(), *duration_ms)), _ => None, }) .expect("live thinking block"); assert_eq!(thinking.0, "why"); // 思考块结束后有文本 → 已有耗时。 // A later block appeared → the duration is settled. assert_eq!(thinking.1, Some(250)); } /// working 阶段检测:运行中的工具 > 思考中 > 流式输出。 /// Working-phase detection: running tool > thinking > streaming. #[test] fn working_phase_detection() { let fc = FakeClock::new(); let mut rs = RunState::new(fc.clock()); // 无状态 → 一般工作。 // No state → generic work. assert_eq!(rs.working_phase(), "working…"); // 思考中。 // Thinking. rs.on_agent_event(&AgentEvent::MessageStart { message: Message::Assistant(assistant(vec![thinking_block("hmm")])), }); assert_eq!(rs.working_phase(), "thinking…"); // 流式输出(思考后出现文本)。 // Streaming (text after thinking). rs.on_agent_event(&AgentEvent::MessageUpdate { message: Message::Assistant(assistant(vec![ thinking_block("hmm"), ContentBlock::text("answer"), ])), event_type: "text_start".into(), }); assert_eq!(rs.working_phase(), "streaming…"); // 运行中的工具优先。 // A running tool takes precedence. let mut args = JsonValue::obj(); args.insert("command", "cargo test".into()).ok(); rs.on_agent_event(&AgentEvent::ToolExecutionStart { tool_call_id: "c1".into(), tool_name: "shell".into(), args, }); assert_eq!(rs.working_phase(), "shell: cargo test…"); // 工具结束后回到流式。 // Back to streaming after the tool finishes. rs.on_agent_event(&AgentEvent::ToolExecutionEnd { tool_call_id: "c1".into(), tool_name: "shell".into(), result: ToolResult::text("ok"), is_error: false, }); assert_eq!(rs.working_phase(), "streaming…"); } /// 错误消息必须渲染出具体的错误文本(而不是只有消息头)。 /// An error message must render its concrete error text (not just a header). #[test] fn error_message_renders_as_error_block() { let transcript = vec![Message::Assistant(AssistantMessage { content: Vec::new(), model: "m".into(), usage: Usage::default(), stop_reason: StopReason::Error, error_message: Some("http error 400: bad request detail".into()), timestamp: 0, })]; let blocks = build_blocks(&transcript, &HashMap::new(), None, &HashSet::new()); let err = blocks .iter() .find_map(|b| match b { UiBlock::Error { text, .. } => Some(text.clone()), _ => None, }) .expect("error block"); assert_eq!(err, "http error 400: bad request detail"); }