focus/crates/focus-tui/tests/state_tests.rs

282 lines
8.8 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! 纯 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<AtomicU64>);
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<ContentBlock>) -> 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…");
}