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

557 lines
17 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::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");
}
/// 流式消息中的工具调用必须以「待执行」状态渲染(回归:此前被丢弃,
/// 工具信息要等整个回合输出完才出现)。
/// Tool calls inside a streaming message must render as pending blocks
/// (regression: they used to be dropped, appearing only after the turn).
#[test]
fn live_partial_renders_pending_tool_calls() {
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"),
ContentBlock::ToolCall(ToolCall {
id: "call_0".into(),
name: "read".into(),
arguments: {
let mut a = JsonValue::obj();
a.insert("path", "src/main.rs".into()).ok();
a
},
}),
])),
});
let blocks = build_blocks(
&[],
&HashMap::new(),
&HashMap::new(),
Some(&rs),
&HashSet::new(),
&HashSet::new(),
);
let tool = blocks
.iter()
.find_map(|b| match b {
UiBlock::Tool {
status, summary, ..
} => Some((*status, summary.clone())),
_ => None,
})
.expect("live tool block");
assert_eq!(tool.0, ExecStatus::Pending);
assert_eq!(tool.1, "read src/main.rs");
}