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

500 lines
15 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(),
&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::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(),
&HashMap::new(),
None,
&expanded,
&HashSet::new(),
);
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(),
&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());
}
/// 已提交 tool_result 消息的耗时进入摘要行。
/// A committed tool_result's duration reaches its summary.
#[test]
fn tool_duration_shows_in_tool_result_block() {
let transcript = vec![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((0usize, 0usize), 12345u64);
let blocks = build_blocks(
&transcript,
&HashMap::new(),
&durations,
None,
&HashSet::new(),
&HashSet::new(),
);
let tr = blocks.iter().find_map(|b| match b {
UiBlock::ToolResult { 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::ToolExec { .. })),
"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::ToolExec {
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));
}
/// 二级展开标志进入 ToolResult 块。
/// The full-level flag reaches ToolResult blocks.
#[test]
fn full_flag_reaches_blocks() {
let transcript = vec![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-r".into()));
let blocks = build_blocks(
&transcript,
&HashMap::new(),
&HashMap::new(),
None,
&HashSet::new(),
&full,
);
let tr = blocks
.iter()
.find_map(|b| match b {
UiBlock::ToolResult { full, .. } => Some(*full),
_ => None,
})
.expect("tool result block");
assert!(tr, "full flag must propagate");
}