//! 纯 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(), &HashMap::new(), None, &expanded, &HashSet::new(), ); // 折叠态:思考块只显示摘要(无展开文本),工具调用只显示摘要。 // 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::Tool { 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(), &HashMap::new(), None, &expanded, &HashSet::new(), ); let mut expanded_count = 0; for b in &blocks { if let UiBlock::Thinking { expanded, .. } | UiBlock::Tool { 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(), &HashMap::new(), Some(&rs), &HashSet::new(), &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(), &HashMap::new(), None, &HashSet::new(), &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"); } /// commit_partial 清空流式 partial(消息已提交进 transcript 后避免重复显示)。 /// commit_partial clears the streaming partial (avoids double-rendering once /// the message is committed into the transcript). #[test] fn commit_partial_clears_live_message() { let fc = FakeClock::new(); let mut rs = RunState::new(fc.clock()); rs.on_agent_event(&AgentEvent::MessageStart { message: Message::Assistant(assistant(vec![ContentBlock::text("hi")])), }); assert!(rs.partial.is_some()); rs.commit_partial(); assert!(rs.partial.is_none()); } /// 工具结果与调用合并为一块时,耗时进入该块。 /// When a tool result merges with its call, the duration reaches the block. #[test] fn tool_duration_shows_in_merged_tool_block() { let transcript = vec![ Message::Assistant(assistant(vec![ContentBlock::ToolCall(ToolCall { id: "c1".into(), name: "shell".into(), arguments: JsonValue::obj(), })])), Message::ToolResult(ToolResultMessage { tool_call_id: "c1".into(), tool_name: "shell".into(), content: vec![ContentBlock::text("ok")], details: JsonValue::obj(), is_error: false, timestamp: 0, }), ]; let mut durations = HashMap::new(); durations.insert((1usize, 0usize), 12345u64); // tool_result 在索引 1 / at index 1 let blocks = build_blocks( &transcript, &HashMap::new(), &durations, None, &HashSet::new(), &HashSet::new(), ); let tr = blocks.iter().find_map(|b| match b { UiBlock::Tool { duration_ms, .. } => Some(*duration_ms), _ => None, }); assert_eq!(tr, Some(Some(12_345))); } /// 流式视图只显示运行中的工具执行(已结束的由 transcript 呈现)。 /// The live view only shows running tool executions (finished ones are /// presented by the transcript). #[test] fn live_renders_only_running_execs() { 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: "c1".into(), tool_name: "shell".into(), args, }); rs.on_agent_event(&AgentEvent::ToolExecutionEnd { tool_call_id: "c1".into(), tool_name: "shell".into(), result: ToolResult::text("done"), is_error: false, }); // 全部结束 → 不渲染任何执行块。 // All finished → no execution blocks rendered. let blocks = build_blocks( &[], &HashMap::new(), &HashMap::new(), Some(&rs), &HashSet::new(), &HashSet::new(), ); assert!( !blocks.iter().any(|b| matches!(b, UiBlock::Tool { .. })), "finished execs must not render live" ); // 一个新的运行中工具 → 渲染。 // A new running tool → rendered. let mut args = JsonValue::obj(); args.insert("command", "sleep 1".into()).ok(); rs.on_agent_event(&AgentEvent::ToolExecutionStart { tool_call_id: "c2".into(), tool_name: "shell".into(), args, }); let blocks = build_blocks( &[], &HashMap::new(), &HashMap::new(), Some(&rs), &HashSet::new(), &HashSet::new(), ); assert!( blocks.iter().any(|b| matches!( b, UiBlock::Tool { status: ExecStatus::Running, .. } )), "running exec must render" ); } /// 三态展开循环:折叠 → 部分 → 全部 → 折叠。 /// Three-state expansion cycle: collapsed → partial → full → collapsed. #[test] fn expansion_cycles_three_states() { let id = BlockId("live-0".into()); let mut expanded = HashSet::new(); let mut full = HashSet::new(); // 折叠 → 部分。 // Collapsed → partial. cycle_expansion(&mut expanded, &mut full, &id); assert!(expanded.contains(&id)); assert!(!full.contains(&id)); // 部分 → 全部。 // Partial → full. cycle_expansion(&mut expanded, &mut full, &id); assert!(!expanded.contains(&id)); assert!(full.contains(&id)); // 全部 → 折叠。 // Full → collapsed. cycle_expansion(&mut expanded, &mut full, &id); assert!(!expanded.contains(&id)); assert!(!full.contains(&id)); } /// 二级展开标志进入合并后的工具块。 /// The full-level flag reaches the merged tool block. #[test] fn full_flag_reaches_merged_tool_block() { let transcript = vec![ Message::Assistant(assistant(vec![ContentBlock::ToolCall(ToolCall { id: "c1".into(), name: "shell".into(), arguments: JsonValue::obj(), })])), Message::ToolResult(ToolResultMessage { tool_call_id: "c1".into(), tool_name: "shell".into(), content: vec![ContentBlock::text("out")], details: JsonValue::obj(), is_error: false, timestamp: 0, }), ]; let mut full = HashSet::new(); full.insert(BlockId("m0-0".into())); // 工具调用在 assistant 的索引 0 let blocks = build_blocks( &transcript, &HashMap::new(), &HashMap::new(), None, &HashSet::new(), &full, ); let tr = blocks .iter() .find_map(|b| match b { UiBlock::Tool { full, .. } => Some(*full), _ => None, }) .expect("merged tool block"); assert!(tr, "full flag must propagate"); }