refactor(test): move all unit tests from src/ into tests/<module>_tests.rs

Per AGENTS.md §5.1 every test must live under crates/<name>/tests/, so the
inline #[cfg(test)] modules in harness/tools/providers/transport are gone:

- harness: session_tests / compaction_tests / prompt_tests (days_to_ymd made pub)
- tools: read / write / edit / shell tests (Tool trait imported explicitly)
- providers: config_tests; request-body and stop-reason coverage folded into
  integration tests via mock-recorded requests and SSE events (private builders
  no longer tested directly)
- transport: ChunkedDecoder made pub (with Default), decoder tests moved into
  transport_tests.rs

The only #[cfg(test)] left in src/ is the test-only TLS infra in tls.rs.
This commit is contained in:
DaiChaoXiong 2026-08-09 21:09:01 +08:00
parent 7c4c465a18
commit f695acc845
22 changed files with 1107 additions and 1036 deletions

View File

@ -225,133 +225,3 @@ pub fn describe_plan(plan: &CompactionPlan) -> String {
plan.estimated_saved_tokens,
)
}
#[cfg(test)]
mod tests {
use super::*;
use focus_core::model::*;
use focus_json::JsonValue;
fn long_user_message(text: &str) -> Message {
Message::user_text(text)
}
#[test]
fn estimate_is_reasonable() {
// 80 个 ASCII 字符 ≈ 20 token。
// 80 ASCII chars ≈ 20 tokens.
let text = "a".repeat(80);
assert_eq!(estimate_tokens(&text), 20);
// 中文按字符计。
// CJK counted per char.
assert_eq!(estimate_tokens("中文"), 2);
// 消息开销计入。
// Per-message overhead is included.
assert!(estimate_messages(&[long_user_message("hi")]) > estimate_tokens("hi"));
}
#[test]
fn below_threshold_returns_none() {
let messages: Vec<Message> = (0..5)
.map(|i| long_user_message(&format!("msg {}", i)))
.collect();
// 窗口极大 → 不触发。
// Huge window → no trigger.
assert!(plan_compaction(
&messages,
1_000_000,
DEFAULT_THRESHOLD_RATIO,
DEFAULT_KEEP_RATIO
)
.is_none());
}
#[test]
fn above_threshold_plans_summarize_and_keep() {
// 每条消息约 50+ token"x"*200 → 50 token + 4 开销10 条约 540 token。
// Each message ≈ 50+ tokens; 10 of them ≈ 540 tokens.
let messages: Vec<Message> = (0..10)
.map(|_i| long_user_message(&"x".repeat(200)))
.collect();
let plan = plan_compaction(&messages, 200, DEFAULT_THRESHOLD_RATIO, DEFAULT_KEEP_RATIO)
.expect("plan");
assert!(!plan.summarize.is_empty());
assert!(!plan.keep.is_empty());
assert!(plan.summarize.len() + plan.keep.len() == messages.len());
// 摘要指令包含两个部分。
// The summary instruction has both sections.
assert!(plan.summary_instruction.contains("<summary>"));
assert!(plan.summary_instruction.contains("<key-facts>"));
// 保存的 token 为正。
// Saved tokens are positive.
assert!(plan.estimated_saved_tokens > 0);
}
#[test]
fn keeps_tool_result_pairs_intact() {
// 构造user → assistant(tool_call) → toolResult → assistant(text)
// 其中 tool 部分恰好落在边界附近。
// Build: user → assistant(tool_call) → toolResult → assistant(text),
// with the tool part landing near the boundary.
let mut args = JsonValue::obj();
args.insert("x", "1".into()).ok();
let mut messages = vec![long_user_message(&"a".repeat(300))];
messages.push(Message::Assistant(AssistantMessage {
content: vec![ContentBlock::ToolCall(ToolCall {
id: "c1".into(),
name: "read".into(),
arguments: args,
})],
model: "m".into(),
usage: Usage::default(),
stop_reason: StopReason::ToolUse,
error_message: None,
timestamp: 0,
}));
messages.push(Message::ToolResult(ToolResultMessage {
tool_call_id: "c1".into(),
tool_name: "read".into(),
content: vec![ContentBlock::text("r".repeat(300))],
details: JsonValue::obj(),
is_error: false,
timestamp: 0,
}));
messages.push(Message::Assistant(AssistantMessage {
content: vec![ContentBlock::text("final")],
model: "m".into(),
usage: Usage::default(),
stop_reason: StopReason::Stop,
error_message: None,
timestamp: 0,
}));
let plan = plan_compaction(&messages, 100, DEFAULT_THRESHOLD_RATIO, DEFAULT_KEEP_RATIO)
.expect("plan");
// 若 tool_result 在 keep 中,其 assistant 调用消息必须也在 keep 中。
// If the tool_result is kept, its assistant call message must be kept too.
let keep: Vec<&Message> = plan.keep.iter().collect();
if keep.iter().any(|m| matches!(m, Message::ToolResult(_))) {
assert!(keep.iter().any(|m| matches!(m, Message::Assistant(a) if a.content.iter().any(|c| c.as_tool_call().is_some()))));
}
}
#[test]
fn apply_summary_prepends_summary_message() {
let messages: Vec<Message> = (0..6)
.map(|_i| long_user_message(&"x".repeat(200)))
.collect();
let plan = plan_compaction(&messages, 100, 0.8, 0.6).expect("plan");
let out = apply_summary("SUMMARY TEXT", &plan);
assert_eq!(out.len(), plan.keep.len() + 1);
match &out[0] {
Message::User(u) => {
let text = u.content[0].as_text().unwrap().text.clone();
assert!(text.contains("SUMMARY TEXT"));
}
other => panic!("expected user summary, got {:?}", other),
}
// 保留部分原样在后。
// The kept part follows verbatim.
assert_eq!(out[1..], plan.keep[..]);
}
}

View File

@ -217,9 +217,11 @@ pub fn today_iso() -> String {
}
/// 把「自 1970-01-01 的天数」转换为 (年, 月, 日)。Howard Hinnant 算法。
/// 公开暴露以便独立测试;正常使用经 [`today_iso`] 完成。
/// Convert "days since 1970-01-01" into (year, month, day). Howard Hinnant's
/// algorithm.
fn days_to_ymd(days: i64) -> (i64, u32, u32) {
/// algorithm. Exposed publicly so it can be tested standalone; normal use
/// happens through [`today_iso`].
pub fn days_to_ymd(days: i64) -> (i64, u32, u32) {
let z = days + 719_468;
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
let doe = (z - era * 146_097) as u64;
@ -232,83 +234,3 @@ fn days_to_ymd(days: i64) -> (i64, u32, u32) {
let y = if m <= 2 { y + 1 } else { y };
(y, m, d)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn days_to_ymd_known_dates() {
// 1970-01-01 → 0。
assert_eq!(days_to_ymd(0), (1970, 1, 1));
// 2000-01-01。
// 30 年 * 365.25 ≈ 10957 天。
assert_eq!(days_to_ymd(10_957), (2000, 1, 1));
// 2024-02-29闰年
// 2024-02-29 (leap year).
let days = (2024 - 1970) * 365 + (2024 - 1969) / 4 + 59;
assert_eq!(days_to_ymd(days), (2024, 2, 29));
}
#[test]
fn renders_default_template() {
let tpl = SystemPromptTemplate::default();
let mut ctx = default_context();
ctx.context_usage = Some(ContextUsage {
estimated_tokens: 12_345,
context_window: 128_000,
window_known: true,
});
ctx.last_usage = Some(Usage {
input_tokens: 100,
output_tokens: 50,
cache_read_tokens: 5,
cache_write_tokens: 0,
});
ctx.extra.push("remember: rust edition 2021".to_string());
let out = tpl.render(&ctx);
assert!(out.contains(&ctx.cwd.display().to_string()));
assert!(
out.contains("Context usage: 12,345 / 128,000 tokens (9.6%)."),
"got: {}",
out
);
assert!(out.contains("Last turn usage: input 100, output 50, cache-read 5, cache-write 0."));
assert!(out.contains("remember: rust edition 2021"));
assert!(out.contains(ctx.os));
}
#[test]
fn renders_unknown_window_note() {
let tpl = SystemPromptTemplate::default();
let mut ctx = default_context();
ctx.context_usage = Some(ContextUsage {
estimated_tokens: 50_000,
context_window: 128_000,
window_known: false,
});
let out = tpl.render(&ctx);
assert!(
out.contains("window unknown; assuming 128,000"),
"got: {}",
out
);
}
#[test]
fn missing_fields_render_empty() {
let tpl = SystemPromptTemplate::default();
let ctx = PromptContext {
cwd: PathBuf::from("/tmp"),
os: "linux",
date: "2025-01-01".into(),
shell: "bash".into(),
context_usage: None,
last_usage: None,
extra: Vec::new(),
};
let out = tpl.render(&ctx);
assert!(!out.contains("{context_usage}"));
assert!(!out.contains("{last_usage}"));
}
}

View File

@ -294,127 +294,3 @@ pub fn ensure_dir(path: &Path) -> CoreResult<()> {
std::fs::create_dir_all(path)
.map_err(|e| CoreError::Tool(format!("create dir {}: {}", path.display(), e)))
}
#[cfg(test)]
mod tests {
use super::*;
use focus_core::model::Message;
fn temp_dir(tag: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!(
"focus-harness-session-{}-{}",
tag,
std::process::id()
));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d
}
#[test]
fn appends_and_loads_entries() {
let dir = temp_dir("append");
let store = SessionStore::new(&dir);
store
.append(
"s1",
&SessionEntry::new("e1".into(), None, Message::user_text("hi")),
)
.unwrap();
store
.append(
"s1",
&SessionEntry::new("e2".into(), Some("e1".into()), Message::user_text("there")),
)
.unwrap();
let entries = store.load("s1").unwrap();
assert_eq!(entries.len(), 2);
assert_eq!(entries[0].id, "e1");
assert_eq!(entries[0].parent_id, None);
assert_eq!(entries[1].id, "e2");
assert_eq!(entries[1].parent_id.as_deref(), Some("e1"));
assert!(matches!(entries[0].message, Message::User(_)));
}
#[test]
fn next_entry_id_is_incremental() {
let dir = temp_dir("seq");
let store = SessionStore::new(&dir);
assert_eq!(store.next_entry_id("s1").unwrap(), "e1");
store
.append(
"s1",
&SessionEntry::new("e1".into(), None, Message::user_text("a")),
)
.unwrap();
assert_eq!(store.next_entry_id("s1").unwrap(), "e2");
store
.append(
"s1",
&SessionEntry::new("e3".into(), Some("e1".into()), Message::user_text("b")),
)
.unwrap();
// e3 已存在 → 下一个是 e4。
// e3 exists → the next is e4.
assert_eq!(store.next_entry_id("s1").unwrap(), "e4");
}
#[test]
fn lists_sessions() {
let dir = temp_dir("list");
let store = SessionStore::new(&dir);
store
.append(
"alpha",
&SessionEntry::new("e1".into(), None, Message::user_text("a")),
)
.unwrap();
store
.append(
"beta",
&SessionEntry::new("e1".into(), None, Message::user_text("b")),
)
.unwrap();
let ids = store.list_sessions().unwrap();
assert_eq!(ids, vec!["alpha".to_string(), "beta".to_string()]);
}
fn tree() -> SessionTree {
let entries = vec![
SessionEntry::new("e1".into(), None, Message::user_text("root")),
SessionEntry::new(
"e2".into(),
Some("e1".into()),
Message::user_text("child A"),
),
SessionEntry::new(
"e3".into(),
Some("e1".into()),
Message::user_text("child B"),
),
SessionEntry::new(
"e4".into(),
Some("e3".into()),
Message::user_text("grandchild"),
),
];
SessionTree::build(entries)
}
#[test]
fn tree_queries() {
let t = tree();
assert_eq!(t.roots().len(), 1);
assert_eq!(t.roots()[0].id, "e1");
let children = t.children_of("e1");
assert_eq!(children.len(), 2);
let path = t.branch("e4");
let ids: Vec<&str> = path.iter().map(|e| e.id.as_str()).collect();
assert_eq!(ids, vec!["e1", "e3", "e4"]);
let leaves = t.leaf_ids();
assert_eq!(leaves.len(), 2);
assert!(leaves.contains(&"e2"));
assert!(leaves.contains(&"e4"));
}
}

View File

@ -0,0 +1,135 @@
//! 上下文压缩(方案 D+E的单元测试。
//! Unit tests for context compaction (schemes D+E).
use focus_core::model::*;
use focus_harness::compaction::{
apply_summary, estimate_messages, estimate_tokens, plan_compaction, DEFAULT_KEEP_RATIO,
DEFAULT_THRESHOLD_RATIO,
};
use focus_json::JsonValue;
fn long_user_message(text: &str) -> Message {
Message::user_text(text)
}
#[test]
fn estimate_is_reasonable() {
// 80 个 ASCII 字符 ≈ 20 token。
// 80 ASCII chars ≈ 20 tokens.
let text = "a".repeat(80);
assert_eq!(estimate_tokens(&text), 20);
// 中文按字符计。
// CJK counted per char.
assert_eq!(estimate_tokens("中文"), 2);
// 消息开销计入。
// Per-message overhead is included.
assert!(estimate_messages(&[long_user_message("hi")]) > estimate_tokens("hi"));
}
#[test]
fn below_threshold_returns_none() {
let messages: Vec<Message> = (0..5)
.map(|i| long_user_message(&format!("msg {}", i)))
.collect();
// 窗口极大 → 不触发。
// Huge window → no trigger.
assert!(plan_compaction(
&messages,
1_000_000,
DEFAULT_THRESHOLD_RATIO,
DEFAULT_KEEP_RATIO
)
.is_none());
}
#[test]
fn above_threshold_plans_summarize_and_keep() {
// 每条消息约 50+ token"x"*200 → 50 token + 4 开销10 条约 540 token。
// Each message ≈ 50+ tokens; 10 of them ≈ 540 tokens.
let messages: Vec<Message> = (0..10)
.map(|_i| long_user_message(&"x".repeat(200)))
.collect();
let plan =
plan_compaction(&messages, 200, DEFAULT_THRESHOLD_RATIO, DEFAULT_KEEP_RATIO).expect("plan");
assert!(!plan.summarize.is_empty());
assert!(!plan.keep.is_empty());
assert!(plan.summarize.len() + plan.keep.len() == messages.len());
// 摘要指令包含两个部分。
// The summary instruction has both sections.
assert!(plan.summary_instruction.contains("<summary>"));
assert!(plan.summary_instruction.contains("<key-facts>"));
// 保存的 token 为正。
// Saved tokens are positive.
assert!(plan.estimated_saved_tokens > 0);
}
#[test]
fn keeps_tool_result_pairs_intact() {
// 构造user → assistant(tool_call) → toolResult → assistant(text)
// 其中 tool 部分恰好落在边界附近。
// Build: user → assistant(tool_call) → toolResult → assistant(text),
// with the tool part landing near the boundary.
let mut args = JsonValue::obj();
args.insert("x", "1".into()).ok();
let mut messages = vec![long_user_message(&"a".repeat(300))];
messages.push(Message::Assistant(AssistantMessage {
content: vec![ContentBlock::ToolCall(ToolCall {
id: "c1".into(),
name: "read".into(),
arguments: args,
})],
model: "m".into(),
usage: Usage::default(),
stop_reason: StopReason::ToolUse,
error_message: None,
timestamp: 0,
}));
messages.push(Message::ToolResult(ToolResultMessage {
tool_call_id: "c1".into(),
tool_name: "read".into(),
content: vec![ContentBlock::text("r".repeat(300))],
details: JsonValue::obj(),
is_error: false,
timestamp: 0,
}));
messages.push(Message::Assistant(AssistantMessage {
content: vec![ContentBlock::text("final")],
model: "m".into(),
usage: Usage::default(),
stop_reason: StopReason::Stop,
error_message: None,
timestamp: 0,
}));
let plan =
plan_compaction(&messages, 100, DEFAULT_THRESHOLD_RATIO, DEFAULT_KEEP_RATIO).expect("plan");
// 若 tool_result 在 keep 中,其 assistant 调用消息必须也在 keep 中。
// If the tool_result is kept, its assistant call message must be kept too.
let keep: Vec<&Message> = plan.keep.iter().collect();
if keep.iter().any(|m| matches!(m, Message::ToolResult(_))) {
assert!(keep.iter().any(|m| {
matches!(m, Message::Assistant(a)
if a.content.iter().any(|c| c.as_tool_call().is_some()))
}));
}
}
#[test]
fn apply_summary_prepends_summary_message() {
let messages: Vec<Message> = (0..6)
.map(|_i| long_user_message(&"x".repeat(200)))
.collect();
let plan = plan_compaction(&messages, 100, 0.8, 0.6).expect("plan");
let out = apply_summary("SUMMARY TEXT", &plan);
assert_eq!(out.len(), plan.keep.len() + 1);
match &out[0] {
Message::User(u) => {
let text = u.content[0].as_text().unwrap().text.clone();
assert!(text.contains("SUMMARY TEXT"));
}
other => panic!("expected user summary, got {:?}", other),
}
// 保留部分原样在后。
// The kept part follows verbatim.
assert_eq!(out[1..], plan.keep[..]);
}

View File

@ -0,0 +1,83 @@
//! 系统提示模板的单元测试。
//! Unit tests for system prompt templates.
use focus_core::model::Usage;
use focus_harness::prompt::{
days_to_ymd, default_context, ContextUsage, PromptContext, SystemPromptTemplate,
};
use std::path::PathBuf;
#[test]
fn days_to_ymd_known_dates() {
// 1970-01-01 → 0。
assert_eq!(days_to_ymd(0), (1970, 1, 1));
// 2000-01-01。
// 30 年 * 365.25 ≈ 10957 天。
assert_eq!(days_to_ymd(10_957), (2000, 1, 1));
// 2024-02-29闰年
// 2024-02-29 (leap year).
let days = (2024 - 1970) * 365 + (2024 - 1969) / 4 + 59;
assert_eq!(days_to_ymd(days), (2024, 2, 29));
}
#[test]
fn renders_default_template() {
let tpl = SystemPromptTemplate::default();
let mut ctx = default_context();
ctx.context_usage = Some(ContextUsage {
estimated_tokens: 12_345,
context_window: 128_000,
window_known: true,
});
ctx.last_usage = Some(Usage {
input_tokens: 100,
output_tokens: 50,
cache_read_tokens: 5,
cache_write_tokens: 0,
});
ctx.extra.push("remember: rust edition 2021".to_string());
let out = tpl.render(&ctx);
assert!(out.contains(&ctx.cwd.display().to_string()));
assert!(
out.contains("Context usage: 12,345 / 128,000 tokens (9.6%)."),
"got: {}",
out
);
assert!(out.contains("Last turn usage: input 100, output 50, cache-read 5, cache-write 0."));
assert!(out.contains("remember: rust edition 2021"));
assert!(out.contains(ctx.os));
}
#[test]
fn renders_unknown_window_note() {
let tpl = SystemPromptTemplate::default();
let mut ctx = default_context();
ctx.context_usage = Some(ContextUsage {
estimated_tokens: 50_000,
context_window: 128_000,
window_known: false,
});
let out = tpl.render(&ctx);
assert!(
out.contains("window unknown; assuming 128,000"),
"got: {}",
out
);
}
#[test]
fn missing_fields_render_empty() {
let tpl = SystemPromptTemplate::default();
let ctx = PromptContext {
cwd: PathBuf::from("/tmp"),
os: "linux",
date: "2025-01-01".into(),
shell: "bash".into(),
context_usage: None,
last_usage: None,
extra: Vec::new(),
};
let out = tpl.render(&ctx);
assert!(!out.contains("{context_usage}"));
assert!(!out.contains("{last_usage}"));
}

View File

@ -0,0 +1,124 @@
//! 会话树与 JSONL 持久化的单元测试。
//! Unit tests for session trees and JSONL persistence.
use focus_core::model::Message;
use focus_harness::session::{SessionEntry, SessionStore, SessionTree};
use std::path::PathBuf;
fn temp_dir(tag: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!(
"focus-harness-session-{}-{}",
tag,
std::process::id()
));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d
}
#[test]
fn appends_and_loads_entries() {
let dir = temp_dir("append");
let store = SessionStore::new(&dir);
store
.append(
"s1",
&SessionEntry::new("e1".into(), None, Message::user_text("hi")),
)
.unwrap();
store
.append(
"s1",
&SessionEntry::new("e2".into(), Some("e1".into()), Message::user_text("there")),
)
.unwrap();
let entries = store.load("s1").unwrap();
assert_eq!(entries.len(), 2);
assert_eq!(entries[0].id, "e1");
assert_eq!(entries[0].parent_id, None);
assert_eq!(entries[1].id, "e2");
assert_eq!(entries[1].parent_id.as_deref(), Some("e1"));
assert!(matches!(entries[0].message, Message::User(_)));
}
#[test]
fn next_entry_id_is_incremental() {
let dir = temp_dir("seq");
let store = SessionStore::new(&dir);
assert_eq!(store.next_entry_id("s1").unwrap(), "e1");
store
.append(
"s1",
&SessionEntry::new("e1".into(), None, Message::user_text("a")),
)
.unwrap();
assert_eq!(store.next_entry_id("s1").unwrap(), "e2");
store
.append(
"s1",
&SessionEntry::new("e3".into(), Some("e1".into()), Message::user_text("b")),
)
.unwrap();
// e3 已存在 → 下一个是 e4。
// e3 exists → the next is e4.
assert_eq!(store.next_entry_id("s1").unwrap(), "e4");
}
#[test]
fn lists_sessions() {
let dir = temp_dir("list");
let store = SessionStore::new(&dir);
store
.append(
"alpha",
&SessionEntry::new("e1".into(), None, Message::user_text("a")),
)
.unwrap();
store
.append(
"beta",
&SessionEntry::new("e1".into(), None, Message::user_text("b")),
)
.unwrap();
let ids = store.list_sessions().unwrap();
assert_eq!(ids, vec!["alpha".to_string(), "beta".to_string()]);
}
fn tree() -> SessionTree {
let entries = vec![
SessionEntry::new("e1".into(), None, Message::user_text("root")),
SessionEntry::new(
"e2".into(),
Some("e1".into()),
Message::user_text("child A"),
),
SessionEntry::new(
"e3".into(),
Some("e1".into()),
Message::user_text("child B"),
),
SessionEntry::new(
"e4".into(),
Some("e3".into()),
Message::user_text("grandchild"),
),
];
SessionTree::build(entries)
}
#[test]
fn tree_queries() {
let t = tree();
assert_eq!(t.roots().len(), 1);
assert_eq!(t.roots()[0].id, "e1");
let children = t.children_of("e1");
assert_eq!(children.len(), 2);
let path = t.branch("e4");
let ids: Vec<&str> = path.iter().map(|e| e.id.as_str()).collect();
assert_eq!(ids, vec!["e1", "e3", "e4"]);
let leaves = t.leaf_ids();
assert_eq!(leaves.len(), 2);
assert!(leaves.contains(&"e2"));
assert!(leaves.contains(&"e4"));
}

View File

@ -556,119 +556,3 @@ impl AnthropicTurn {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use focus_core::tool::{ToolEffects, ToolRegistry};
/// 无副作用 echo 工具(供请求体测试)。
/// Side-effect-free echo tool (for request-body tests).
#[derive(Debug)]
struct EchoTool;
impl focus_core::Tool for EchoTool {
fn name(&self) -> &str {
"echo"
}
fn description(&self) -> &str {
"echoes text"
}
fn parameters(&self) -> JsonValue {
let mut o = JsonValue::obj();
o.insert("type", "object".into()).ok();
let mut props = JsonValue::obj();
props.insert("text", JsonValue::Str("the text".into())).ok();
o.insert("properties", props).ok();
o
}
fn effects(&self) -> ToolEffects {
ToolEffects::READ
}
fn execute(
&self,
_id: &str,
args: &JsonValue,
_u: Option<&focus_core::tool::ToolUpdateSink>,
) -> Result<focus_core::tool::ToolResult, CoreError> {
Ok(focus_core::tool::ToolResult::text(
args.get_str("text").unwrap_or("").to_string(),
))
}
}
#[test]
fn serializes_request_body() {
let provider = AnthropicProvider::new(ProviderConfig::new("sk-test"));
let request = ProviderRequest {
model: "claude-sonnet-4".into(),
system_prompt: "You are helpful.".into(),
messages: vec![
Message::user_text("hi"),
Message::Assistant(AssistantMessage {
content: vec![
ContentBlock::Thinking(ThinkingContent {
thinking: "hmm".into(),
signature: Some("sig".into()),
redacted: false,
}),
ContentBlock::ToolCall(ToolCall {
id: "call_1".into(),
name: "echo".into(),
arguments: focus_json::parse(r#"{"text":"x"}"#).unwrap(),
}),
],
model: "claude-sonnet-4".into(),
usage: Usage::default(),
stop_reason: StopReason::ToolUse,
error_message: None,
timestamp: 0,
}),
Message::ToolResult(ToolResultMessage {
tool_call_id: "call_1".into(),
tool_name: "echo".into(),
content: vec![ContentBlock::text("ok")],
details: JsonValue::obj(),
is_error: false,
timestamp: 0,
}),
],
tools: ToolRegistry::with(Box::new(EchoTool)).tool_definitions(),
max_tokens: Some(1024),
temperature: Some(0.5),
};
let body = provider.build_body(&request).unwrap();
let json: JsonValue = focus_json::parse(&body).unwrap();
assert_eq!(json.get_str("model"), Some("claude-sonnet-4"));
assert_eq!(json.get_str("system"), Some("You are helpful."));
assert_eq!(json.get_bool("stream"), Some(true));
// 工具被翻译成 input_schema。
// Tools are translated with input_schema.
let tools = json.get_arr("tools").unwrap();
assert_eq!(tools[0].get_str("name"), Some("echo"));
assert!(tools[0].get("input_schema").is_some());
// 思考块带签名回传。
// Thinking blocks are replayed with their signature.
let messages = json.get_arr("messages").unwrap();
let assistant = &messages[1];
let content = assistant.get_arr("content").unwrap();
assert_eq!(content[0].get_str("type"), Some("thinking"));
assert_eq!(content[0].get_str("signature"), Some("sig"));
assert_eq!(content[1].get_str("type"), Some("tool_use"));
assert_eq!(content[1].get_str("id"), Some("call_1"));
// tool_result 落在一条 user 消息里。
// tool_result lands inside one user message.
let tool_msg = &messages[2];
assert_eq!(tool_msg.get_str("role"), Some("user"));
let blocks = tool_msg.get_arr("content").unwrap();
assert_eq!(blocks[0].get_str("type"), Some("tool_result"));
assert_eq!(blocks[0].get_str("tool_use_id"), Some("call_1"));
}
#[test]
fn maps_stop_reasons() {
assert_eq!(map_stop_reason("end_turn"), StopReason::Stop);
assert_eq!(map_stop_reason("max_tokens"), StopReason::Length);
assert_eq!(map_stop_reason("tool_use"), StopReason::ToolUse);
assert_eq!(map_stop_reason("pause_turn"), StopReason::Stop);
}
}

View File

@ -123,49 +123,3 @@ pub fn split_base_url(base: &str) -> Result<(String, u16, String), String> {
}
Ok((host, port, path.trim_end_matches('/').to_string()))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn known_windows_prefix_matching() {
assert_eq!(
known_context_window("claude-sonnet-4-20250514"),
Some(200_000)
);
assert_eq!(known_context_window("gpt-4o"), Some(128_000));
assert_eq!(known_context_window("gpt-4.1-mini"), Some(1_000_000));
assert_eq!(known_context_window("unknown-model"), None);
}
#[test]
fn resolve_fallback_order() {
// 配置优先。
// Configured value wins.
assert_eq!(resolve_context_window(Some(42), "claude-sonnet-4"), 42);
// 已知表兜底。
// Known table as fallback.
assert_eq!(resolve_context_window(None, "claude-sonnet-4"), 200_000);
// 保守默认。
// Conservative default.
assert_eq!(
resolve_context_window(None, "totally-unknown"),
DEFAULT_CONTEXT_WINDOW
);
}
#[test]
fn splits_base_urls() {
assert_eq!(
split_base_url("https://api.anthropic.com").unwrap(),
("api.anthropic.com".to_string(), 443, String::new())
);
assert_eq!(
split_base_url("https://localhost:8080/v1").unwrap(),
("localhost".to_string(), 8080, "/v1".to_string())
);
assert!(split_base_url("http://insecure.example.com").is_err());
assert!(split_base_url("https:///no-host").is_err());
}
}

View File

@ -735,129 +735,3 @@ impl OpenAiTurn {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use focus_core::tool::{ToolEffects, ToolRegistry};
#[derive(Debug)]
struct EchoTool;
impl focus_core::Tool for EchoTool {
fn name(&self) -> &str {
"echo"
}
fn description(&self) -> &str {
"echoes text"
}
fn parameters(&self) -> JsonValue {
let mut o = JsonValue::obj();
o.insert("type", "object".into()).ok();
let mut props = JsonValue::obj();
props.insert("text", JsonValue::Str("the text".into())).ok();
o.insert("properties", props).ok();
o
}
fn effects(&self) -> ToolEffects {
ToolEffects::READ
}
fn execute(
&self,
_id: &str,
args: &JsonValue,
_u: Option<&focus_core::tool::ToolUpdateSink>,
) -> Result<focus_core::tool::ToolResult, CoreError> {
Ok(focus_core::tool::ToolResult::text(
args.get_str("text").unwrap_or("").to_string(),
))
}
}
fn sample_request() -> ProviderRequest {
ProviderRequest {
model: "gpt-4o".into(),
system_prompt: "Be concise.".into(),
messages: vec![
Message::user_text("hi"),
Message::Assistant(AssistantMessage {
content: vec![ContentBlock::ToolCall(ToolCall {
id: "call_abc".into(),
name: "echo".into(),
arguments: focus_json::parse(r#"{"text":"x"}"#).unwrap(),
})],
model: "gpt-4o".into(),
usage: Usage::default(),
stop_reason: StopReason::ToolUse,
error_message: None,
timestamp: 0,
}),
Message::ToolResult(ToolResultMessage {
tool_call_id: "call_abc".into(),
tool_name: "echo".into(),
content: vec![ContentBlock::text("ok")],
details: JsonValue::obj(),
is_error: false,
timestamp: 0,
}),
],
tools: ToolRegistry::with(Box::new(EchoTool)).tool_definitions(),
max_tokens: Some(512),
temperature: Some(0.2),
}
}
#[test]
fn chat_body_translation() {
let body = build_chat_body(&sample_request());
let messages = body.get_arr("messages").unwrap();
assert_eq!(messages[0].get_str("role"), Some("system"));
assert_eq!(messages[0].get_str("content"), Some("Be concise."));
// assistant 携带 tool_callsarguments 为 JSON 字符串)。
// Assistant carries tool_calls (arguments as a JSON string).
let assistant = &messages[2];
let calls = assistant.get_arr("tool_calls").unwrap();
assert_eq!(calls[0].get_str("id"), Some("call_abc"));
let func = calls[0].get("function").unwrap();
assert_eq!(func.get_str("name"), Some("echo"));
assert_eq!(func.get_str("arguments"), Some(r#"{"text":"x"}"#));
// tool 消息带 tool_call_id。
// Tool message carries tool_call_id.
assert_eq!(messages[3].get_str("role"), Some("tool"));
assert_eq!(messages[3].get_str("tool_call_id"), Some("call_abc"));
// tools 被翻译成 function 格式。
// Tools translated into the function format.
let tools = body.get_arr("tools").unwrap();
assert_eq!(tools[0].get_str("type"), Some("function"));
let f = tools[0].get("function").unwrap();
assert_eq!(f.get_str("name"), Some("echo"));
assert!(f.get("parameters").is_some());
assert_eq!(body.get_bool("stream"), Some(true));
assert_eq!(body.get_num("max_completion_tokens"), Some(512.0));
}
#[test]
fn responses_body_translation() {
let body = build_responses_body(&sample_request());
assert_eq!(body.get_str("instructions"), Some("Be concise."));
let input = body.get_arr("input").unwrap();
// 顺序user → assistant(role) → function_call → function_call_output。
// Order: user → assistant(role) → function_call → function_call_output.
assert_eq!(input[0].get_str("role"), Some("user"));
assert_eq!(input[1].get_str("role"), Some("assistant"));
assert_eq!(input[2].get_str("type"), Some("function_call"));
assert_eq!(input[2].get_str("call_id"), Some("call_abc"));
assert_eq!(input[3].get_str("type"), Some("function_call_output"));
assert_eq!(input[3].get_str("call_id"), Some("call_abc"));
let tools = body.get_arr("tools").unwrap();
assert_eq!(tools[0].get_str("name"), Some("echo"));
assert_eq!(body.get_num("max_output_tokens"), Some(512.0));
}
#[test]
fn maps_chat_finish_reasons() {
assert_eq!(map_chat_finish("stop"), StopReason::Stop);
assert_eq!(map_chat_finish("length"), StopReason::Length);
assert_eq!(map_chat_finish("tool_calls"), StopReason::ToolUse);
assert_eq!(map_chat_finish("content_filter"), StopReason::Stop);
}
}

View File

@ -5,6 +5,7 @@ mod common;
use focus_core::model::*;
use focus_core::provider::{ProviderRequest, StreamEvent};
use focus_core::tool::{Tool, ToolEffects, ToolRegistry, ToolResult, ToolUpdateSink};
use focus_providers::anthropic::AnthropicProvider;
use focus_providers::config::ProviderConfig;
use focus_transport::TransportError;
@ -234,4 +235,142 @@ fn request_is_well_formed() {
focus_json::parse(&String::from_utf8_lossy(&req.body)).unwrap();
assert_eq!(body.get_str("model"), Some("claude-sonnet-4"));
assert_eq!(body.get_bool("stream"), Some(true));
assert_eq!(body.get_str("system"), Some("You are helpful."));
}
/// 请求体必须完整携带会话思考块签名回传、tool_use 块、多个 tool_result
/// 合并进一条 user 消息、工具翻译成 input_schema回归原为内联私有函数测试
/// The request body must carry the full transcript: thinking signatures,
/// tool_use blocks, multiple tool_results merged into one user message, and
/// tools translated to input_schema (regression: was an inline private-fn test).
#[test]
fn request_body_carries_full_transcript() {
#[derive(Debug)]
struct EchoTool;
impl Tool for EchoTool {
fn name(&self) -> &str {
"echo"
}
fn description(&self) -> &str {
"echoes text"
}
fn parameters(&self) -> focus_json::JsonValue {
let mut o = focus_json::JsonValue::obj();
o.insert("type", "object".into()).ok();
let mut props = focus_json::JsonValue::obj();
props
.insert("text", focus_json::JsonValue::Str("the text".into()))
.ok();
o.insert("properties", props).ok();
o
}
fn effects(&self) -> ToolEffects {
ToolEffects::READ
}
fn execute(
&self,
_id: &str,
_args: &focus_json::JsonValue,
_u: Option<&ToolUpdateSink>,
) -> Result<ToolResult, focus_core::CoreError> {
Ok(ToolResult::text("ok"))
}
}
let rich_request = ProviderRequest {
model: "claude-sonnet-4".into(),
system_prompt: "You are helpful.".into(),
messages: vec![
Message::user_text("hi"),
Message::Assistant(AssistantMessage {
content: vec![
ContentBlock::Thinking(ThinkingContent {
thinking: "hmm".into(),
signature: Some("sig".into()),
redacted: false,
}),
ContentBlock::ToolCall(ToolCall {
id: "call_1".into(),
name: "echo".into(),
arguments: focus_json::parse(r#"{"text":"x"}"#).unwrap(),
}),
],
model: "claude-sonnet-4".into(),
usage: Usage::default(),
stop_reason: StopReason::ToolUse,
error_message: None,
timestamp: 0,
}),
Message::ToolResult(ToolResultMessage {
tool_call_id: "call_1".into(),
tool_name: "echo".into(),
content: vec![ContentBlock::text("ok")],
details: focus_json::JsonValue::obj(),
is_error: false,
timestamp: 0,
}),
],
tools: ToolRegistry::with(Box::new(EchoTool)).tool_definitions(),
max_tokens: Some(1024),
temperature: Some(0.5),
};
let mut mock = common::MockTransport::new();
mock.push_body("event: message_stop\ndata: {\"type\":\"message_stop\"}\n\n");
let provider = provider(mock.clone());
let _ = common::collect(&provider, &rich_request);
let req = mock.last_request();
let body: focus_json::JsonValue =
focus_json::parse(&String::from_utf8_lossy(&req.body)).unwrap();
// 工具翻译成 input_schema。
// Tools translated with input_schema.
let tools = body.get_arr("tools").unwrap();
assert_eq!(tools[0].get_str("name"), Some("echo"));
assert!(tools[0].get("input_schema").is_some());
// 思考块带签名回传。
// Thinking blocks replayed with their signature.
let messages = body.get_arr("messages").unwrap();
let assistant = &messages[1];
let content = assistant.get_arr("content").unwrap();
assert_eq!(content[0].get_str("type"), Some("thinking"));
assert_eq!(content[0].get_str("signature"), Some("sig"));
assert_eq!(content[1].get_str("type"), Some("tool_use"));
assert_eq!(content[1].get_str("id"), Some("call_1"));
// tool_result 落在一条 user 消息里。
// tool_result lands inside one user message.
let tool_msg = &messages[2];
assert_eq!(tool_msg.get_str("role"), Some("user"));
let blocks = tool_msg.get_arr("content").unwrap();
assert_eq!(blocks[0].get_str("type"), Some("tool_result"));
assert_eq!(blocks[0].get_str("tool_use_id"), Some("call_1"));
}
/// stop_reason 映射max_tokens → Length回归原为内联私有函数测试
/// Stop-reason mapping: max_tokens → Length (regression: was an inline
/// private-fn test).
#[test]
fn maps_max_tokens_stop_reason() {
let mut mock = common::MockTransport::new();
mock.push_body(
"event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"usage\":{}}}\n\n\
event: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n\n\
event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"partial\"}}\n\n\
event: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0}\n\n\
event: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"max_tokens\"},\"usage\":{}}\n\n\
event: message_stop\ndata: {\"type\":\"message_stop\"}\n\n",
);
let provider = provider(mock);
let events = common::collect(&provider, &request());
let done = events
.iter()
.find_map(|e| match e {
StreamEvent::Done { message } => Some(message),
_ => None,
})
.expect("done event");
assert_eq!(done.stop_reason, StopReason::Length);
}

View File

@ -0,0 +1,56 @@
//! provider 配置已知模型窗口表、base_url 拆分)的单元测试。
//! Unit tests for provider config (known-model windows, base_url splitting).
use focus_providers::config::{
known_context_window, resolve_context_window, split_base_url, ProviderConfig,
DEFAULT_CONTEXT_WINDOW,
};
#[test]
fn known_windows_prefix_matching() {
assert_eq!(
known_context_window("claude-sonnet-4-20250514"),
Some(200_000)
);
assert_eq!(known_context_window("gpt-4o"), Some(128_000));
assert_eq!(known_context_window("gpt-4.1-mini"), Some(1_000_000));
assert_eq!(known_context_window("unknown-model"), None);
}
#[test]
fn resolve_fallback_order() {
// 配置优先。
// Configured value wins.
assert_eq!(resolve_context_window(Some(42), "claude-sonnet-4"), 42);
// 已知表兜底。
// Known table as fallback.
assert_eq!(resolve_context_window(None, "claude-sonnet-4"), 200_000);
// 保守默认。
// Conservative default.
assert_eq!(
resolve_context_window(None, "totally-unknown"),
DEFAULT_CONTEXT_WINDOW
);
}
#[test]
fn splits_base_urls() {
assert_eq!(
split_base_url("https://api.anthropic.com").unwrap(),
("api.anthropic.com".to_string(), 443, String::new())
);
assert_eq!(
split_base_url("https://localhost:8080/v1").unwrap(),
("localhost".to_string(), 8080, "/v1".to_string())
);
assert!(split_base_url("http://insecure.example.com").is_err());
assert!(split_base_url("https:///no-host").is_err());
}
#[test]
fn config_defaults() {
let c = ProviderConfig::new("sk-test");
assert_eq!(c.api_key, "sk-test");
assert_eq!(c.base_url, None);
assert_eq!(c.context_window, None);
}

View File

@ -6,6 +6,7 @@ mod common;
use focus_core::model::*;
use focus_core::provider::{ProviderRequest, StreamEvent};
use focus_core::tool::{Tool, ToolEffects, ToolRegistry, ToolResult, ToolUpdateSink};
use focus_providers::config::ProviderConfig;
use focus_providers::openai::{OpenAiProtocol, OpenAiProvider};
use std::sync::Arc;
@ -191,3 +192,157 @@ fn request_headers_and_path() {
.iter()
.any(|(k, v)| { k.eq_ignore_ascii_case("authorization") && v == "Bearer sk-test" }));
}
/// Chat Completions 请求体翻译(回归:原为内联私有函数测试)。
/// Chat Completions request-body translation (regression: was an inline
/// private-fn test).
#[test]
fn chat_request_body_translation() {
let mut mock = common::MockTransport::new();
mock.push_body("data: [DONE]\n\n");
let provider = provider(mock.clone(), OpenAiProtocol::ChatCompletions);
let _ = common::collect(&provider, &rich_request());
let req = mock.last_request();
let body: focus_json::JsonValue =
focus_json::parse(&String::from_utf8_lossy(&req.body)).unwrap();
let messages = body.get_arr("messages").unwrap();
// system 提示作为第一条消息。
// The system prompt becomes the first message.
assert_eq!(messages[0].get_str("role"), Some("system"));
assert_eq!(messages[0].get_str("content"), Some("Be concise."));
// assistant 携带 tool_callsarguments 为 JSON 字符串)。
// Assistant carries tool_calls (arguments as a JSON string).
let assistant = &messages[2];
let calls = assistant.get_arr("tool_calls").unwrap();
assert_eq!(calls[0].get_str("id"), Some("call_abc"));
let func = calls[0].get("function").unwrap();
assert_eq!(func.get_str("name"), Some("echo"));
assert_eq!(func.get_str("arguments"), Some(r#"{"text":"x"}"#));
// tool 消息带 tool_call_id。
// Tool message carries tool_call_id.
assert_eq!(messages[3].get_str("role"), Some("tool"));
assert_eq!(messages[3].get_str("tool_call_id"), Some("call_abc"));
// tools 被翻译成 function 格式。
// Tools translated into the function format.
let tools = body.get_arr("tools").unwrap();
assert_eq!(tools[0].get_str("type"), Some("function"));
let f = tools[0].get("function").unwrap();
assert_eq!(f.get_str("name"), Some("echo"));
assert!(f.get("parameters").is_some());
assert_eq!(body.get_bool("stream"), Some(true));
assert_eq!(body.get_num("max_completion_tokens"), Some(512.0));
}
/// Responses API 请求体翻译(回归:原为内联私有函数测试)。
/// Responses API request-body translation (regression: was an inline
/// private-fn test).
#[test]
fn responses_request_body_translation() {
let mut mock = common::MockTransport::new();
mock.push_body("data: [DONE]\n\n");
let provider = provider(mock.clone(), OpenAiProtocol::Responses);
let _ = common::collect(&provider, &rich_request());
let req = mock.last_request();
assert_eq!(req.path, "/v1/responses");
let body: focus_json::JsonValue =
focus_json::parse(&String::from_utf8_lossy(&req.body)).unwrap();
assert_eq!(body.get_str("instructions"), Some("Be concise."));
let input = body.get_arr("input").unwrap();
// 顺序user → assistant(role) → function_call → function_call_output。
// Order: user → assistant(role) → function_call → function_call_output.
assert_eq!(input[0].get_str("role"), Some("user"));
assert_eq!(input[1].get_str("role"), Some("assistant"));
assert_eq!(input[2].get_str("type"), Some("function_call"));
assert_eq!(input[2].get_str("call_id"), Some("call_abc"));
assert_eq!(input[3].get_str("type"), Some("function_call_output"));
assert_eq!(input[3].get_str("call_id"), Some("call_abc"));
let tools = body.get_arr("tools").unwrap();
assert_eq!(tools[0].get_str("name"), Some("echo"));
assert_eq!(body.get_num("max_output_tokens"), Some(512.0));
}
/// finish_reason 映射length → Length回归原为内联私有函数测试
/// Finish-reason mapping: length → Length (regression: was an inline
/// private-fn test).
#[test]
fn maps_chat_length_finish_reason() {
let mut mock = common::MockTransport::new();
mock.push_body(
"data: {\"choices\":[{\"index\":0,\"delta\":{\"content\":\"partial\"},\"finish_reason\":null}]}\n\n\
data: {\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"length\"}]}\n\n\
data: [DONE]\n\n",
);
let provider = provider(mock, OpenAiProtocol::ChatCompletions);
let events = common::collect(&provider, &request());
let msg = final_message(&events);
assert_eq!(msg.stop_reason, StopReason::Length);
}
/// 一个携带工具调用与工具结果的更完整请求。
/// A richer request carrying a tool call and its result.
fn rich_request() -> ProviderRequest {
#[derive(Debug)]
struct EchoTool;
impl Tool for EchoTool {
fn name(&self) -> &str {
"echo"
}
fn description(&self) -> &str {
"echoes text"
}
fn parameters(&self) -> focus_json::JsonValue {
let mut o = focus_json::JsonValue::obj();
o.insert("type", "object".into()).ok();
let mut props = focus_json::JsonValue::obj();
props
.insert("text", focus_json::JsonValue::Str("the text".into()))
.ok();
o.insert("properties", props).ok();
o
}
fn effects(&self) -> ToolEffects {
ToolEffects::READ
}
fn execute(
&self,
_id: &str,
_args: &focus_json::JsonValue,
_u: Option<&ToolUpdateSink>,
) -> Result<ToolResult, focus_core::CoreError> {
Ok(ToolResult::text("ok"))
}
}
ProviderRequest {
model: "gpt-4o".into(),
system_prompt: "Be concise.".into(),
messages: vec![
Message::user_text("hi"),
Message::Assistant(AssistantMessage {
content: vec![ContentBlock::ToolCall(ToolCall {
id: "call_abc".into(),
name: "echo".into(),
arguments: focus_json::parse(r#"{"text":"x"}"#).unwrap(),
})],
model: "gpt-4o".into(),
usage: Usage::default(),
stop_reason: StopReason::ToolUse,
error_message: None,
timestamp: 0,
}),
Message::ToolResult(ToolResultMessage {
tool_call_id: "call_abc".into(),
tool_name: "echo".into(),
content: vec![ContentBlock::text("ok")],
details: focus_json::JsonValue::obj(),
is_error: false,
timestamp: 0,
}),
],
tools: ToolRegistry::with(Box::new(EchoTool)).tool_definitions(),
max_tokens: Some(512),
temperature: Some(0.2),
}
}

View File

@ -176,108 +176,3 @@ impl Tool for EditTool {
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
fn temp_dir(tag: &str) -> PathBuf {
let d =
std::env::temp_dir().join(format!("focus-tools-edit-{}-{}", tag, std::process::id()));
let _ = fs::remove_dir_all(&d);
fs::create_dir_all(&d).unwrap();
d
}
fn edit_args(path: &str, edits: Vec<JsonValue>) -> JsonValue {
let mut args = JsonValue::obj();
args.insert("path", path.into()).ok();
args.insert("edits", JsonValue::Arr(edits)).ok();
args
}
fn pair(old: &str, new: &str) -> JsonValue {
let mut e = JsonValue::obj();
e.insert("oldString", old.into()).ok();
e.insert("newString", new.into()).ok();
e
}
#[test]
fn applies_single_replacement() {
let dir = temp_dir("single");
fs::write(dir.join("f.txt"), "foo bar foo").unwrap();
let tool = EditTool::new(&dir);
let r = tool
.execute("id", &edit_args("f.txt", vec![pair("bar", "baz")]), None)
.unwrap();
assert!(r.content[0].as_text().unwrap().text.contains("1 edit"));
assert_eq!(
fs::read_to_string(dir.join("f.txt")).unwrap(),
"foo baz foo"
);
}
#[test]
fn applies_multiple_edits_in_order() {
let dir = temp_dir("multi");
fs::write(dir.join("f.txt"), "a b c").unwrap();
let tool = EditTool::new(&dir);
let r = tool
.execute(
"id",
&edit_args(
"f.txt",
vec![pair("a", "1"), pair("b", "2"), pair("c", "3")],
),
None,
)
.unwrap();
assert!(r.content[0].as_text().unwrap().text.contains("3 edit"));
assert_eq!(fs::read_to_string(dir.join("f.txt")).unwrap(), "1 2 3");
}
#[test]
fn ambiguous_match_requires_occurrence() {
let dir = temp_dir("ambig");
fs::write(dir.join("f.txt"), "x x x").unwrap();
let tool = EditTool::new(&dir);
// 无 occurrence → 报错。
// No occurrence → error.
let err = tool.execute("id", &edit_args("f.txt", vec![pair("x", "y")]), None);
assert!(err.is_err());
let msg = err.unwrap_err().to_string();
assert!(msg.contains("3 times"), "got: {}", msg);
// 带 occurrence → 只替换第 2 个。
// With occurrence → replace only the 2nd.
let mut e = pair("x", "y");
e.insert("occurrence", 2u64.into()).ok();
tool.execute("id", &edit_args("f.txt", vec![e]), None)
.unwrap();
assert_eq!(fs::read_to_string(dir.join("f.txt")).unwrap(), "x y x");
}
#[test]
fn not_found_reports_descriptively() {
let dir = temp_dir("nf");
fs::write(dir.join("f.txt"), "hello").unwrap();
let tool = EditTool::new(&dir);
let err = tool
.execute("id", &edit_args("f.txt", vec![pair("zzz", "y")]), None)
.unwrap_err();
assert!(err.to_string().contains("not found"), "got: {}", err);
}
#[test]
fn preserves_crlf_when_not_matched() {
let dir = temp_dir("crlf");
fs::write(dir.join("f.txt"), "a\r\nb\r\nc\r\n").unwrap();
let tool = EditTool::new(&dir);
tool.execute("id", &edit_args("f.txt", vec![pair("b", "B")]), None)
.unwrap();
// 未匹配的 CRLF 原样保留。
// Unmatched CRLF bytes are preserved as-is.
assert_eq!(fs::read(dir.join("f.txt")).unwrap(), b"a\r\nB\r\nc\r\n");
}
}

View File

@ -122,83 +122,3 @@ fn slice_lines(content: &str, start: Option<usize>, end: Option<usize>) -> Strin
}
lines[start_idx..end_idx].join("\n")
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
fn temp_dir(tag: &str) -> PathBuf {
let d =
std::env::temp_dir().join(format!("focus-tools-read-{}-{}", tag, std::process::id()));
let _ = fs::remove_dir_all(&d);
fs::create_dir_all(&d).unwrap();
d
}
#[test]
fn reads_file() {
let dir = temp_dir("reads");
fs::write(dir.join("a.txt"), "line1\nline2\nline3\n").unwrap();
let tool = ReadTool::new(&dir);
let mut args = JsonValue::obj();
args.insert("path", "a.txt".into()).ok();
let r = tool.execute("id", &args, None).unwrap();
let text = r.content[0].as_text().unwrap().text.clone();
assert_eq!(text, "line1\nline2\nline3\n");
}
#[test]
fn reads_line_range() {
let dir = temp_dir("range");
fs::write(dir.join("a.txt"), "l1\nl2\nl3\nl4\n").unwrap();
let tool = ReadTool::new(&dir);
let mut args = JsonValue::obj();
args.insert("path", "a.txt".into()).ok();
args.insert("startLine", 2u64.into()).ok();
args.insert("endLine", 3u64.into()).ok();
let r = tool.execute("id", &args, None).unwrap();
let text = r.content[0].as_text().unwrap().text.clone();
assert_eq!(text, "l2\nl3");
}
#[test]
fn missing_file_is_an_error_result() {
let dir = temp_dir("missing");
let tool = ReadTool::new(&dir);
let mut args = JsonValue::obj();
args.insert("path", "nope.txt".into()).ok();
// 可预期错误以 Err 返回agent 循环会将其编码为错误结果)。
// Expected errors surface as Err (the agent loop encodes them as
// error results).
let err = tool.execute("id", &args, None).unwrap_err().to_string();
assert!(err.contains("nope.txt"), "got: {}", err);
}
#[test]
fn binary_file_is_rejected() {
let dir = temp_dir("bin");
fs::write(dir.join("b.bin"), [0u8, 1, 2, 3]).unwrap();
let tool = ReadTool::new(&dir);
let mut args = JsonValue::obj();
args.insert("path", "b.bin".into()).ok();
let r = tool.execute("id", &args, None).unwrap();
assert!(r.content[0]
.as_text()
.map(|t| t.text.contains("binary"))
.unwrap_or(false));
}
#[test]
fn absolute_path_works() {
let dir = temp_dir("abs");
let abs = dir.join("x.txt");
fs::write(&abs, "hi").unwrap();
let tool = ReadTool::new(&dir);
let mut args = JsonValue::obj();
args.insert("path", abs.display().to_string().into()).ok();
let r = tool.execute("id", &args, None).unwrap();
let text = r.content[0].as_text().unwrap().text.clone();
assert_eq!(text, "hi");
}
}

View File

@ -307,98 +307,3 @@ fn truncate(s: &str, max: usize) -> String {
out.push_str(&s.chars().skip(s.len() - half).collect::<String>());
out
}
#[cfg(test)]
mod tests {
use super::*;
use focus_core::tool::ToolUpdate;
use std::fs;
use std::sync::{Arc, Mutex};
fn temp_dir(tag: &str) -> PathBuf {
let d =
std::env::temp_dir().join(format!("focus-tools-shell-{}-{}", tag, std::process::id()));
let _ = fs::remove_dir_all(&d);
fs::create_dir_all(&d).unwrap();
d
}
fn cmd_args(command: &str) -> JsonValue {
let mut args = JsonValue::obj();
args.insert("command", command.into()).ok();
args
}
#[cfg(unix)]
#[test]
fn runs_bash_and_captures_stdout() {
let dir = temp_dir("stdout");
let tool = ShellTool::new(&dir);
let r = tool.execute("id", &cmd_args("echo hello"), None).unwrap();
let text = r.content[0].as_text().unwrap().text.clone();
assert_eq!(text.trim(), "hello");
assert_eq!(r.details.get_num("exitCode"), Some(0.0));
assert_eq!(r.details.get_bool("timedOut"), Some(false));
}
#[cfg(unix)]
#[test]
fn captures_stderr_and_exit_code() {
let dir = temp_dir("stderr");
let tool = ShellTool::new(&dir);
let r = tool
.execute("id", &cmd_args("echo oops >&2; exit 3"), None)
.unwrap();
assert_eq!(r.details.get_num("exitCode"), Some(3.0));
let stderr = r.details.get_str("stderr").unwrap_or("");
assert!(stderr.contains("oops"), "got: {}", stderr);
}
#[cfg(unix)]
#[test]
fn times_out_and_kills() {
let dir = temp_dir("timeout");
let tool = ShellTool::new(&dir);
let mut args = cmd_args("sleep 5");
args.insert("timeoutMs", 200u64.into()).ok();
let r = tool.execute("id", &args, None).unwrap();
assert_eq!(r.details.get_bool("timedOut"), Some(true));
let text = r.content[0].as_text().unwrap().text.clone();
assert!(text.contains("timed out"), "got: {}", text);
}
#[cfg(unix)]
#[test]
fn streams_updates_line_by_line() {
let dir = temp_dir("updates");
let tool = ShellTool::new(&dir);
let lines: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
let sink: Box<dyn Fn(ToolUpdate) + Send + Sync> = {
let lines = lines.clone();
Box::new(move |u: ToolUpdate| {
if let Some(t) = u.content[0].as_text() {
lines.lock().unwrap().push(t.text.clone());
}
})
};
let r = tool
.execute("id", &cmd_args("printf 'a\\nb\\nc\\n'"), Some(&*sink))
.unwrap();
assert_eq!(r.details.get_num("exitCode"), Some(0.0));
let got = lines.lock().unwrap();
assert_eq!(
*got,
vec!["a".to_string(), "b".to_string(), "c".to_string()]
);
}
#[cfg(unix)]
#[test]
fn runs_in_project_root() {
let dir = temp_dir("cwd");
let tool = ShellTool::new(&dir);
let r = tool.execute("id", &cmd_args("pwd"), None).unwrap();
let text = r.content[0].as_text().unwrap().text.clone();
assert_eq!(text.trim(), dir.display().to_string());
}
}

View File

@ -98,47 +98,3 @@ impl Tool for WriteTool {
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
fn temp_dir(tag: &str) -> PathBuf {
let d =
std::env::temp_dir().join(format!("focus-tools-write-{}-{}", tag, std::process::id()));
let _ = fs::remove_dir_all(&d);
fs::create_dir_all(&d).unwrap();
d
}
#[test]
fn writes_and_overwrites() {
let dir = temp_dir("overwrite");
fs::write(dir.join("a.txt"), "old").unwrap();
let tool = WriteTool::new(&dir);
let mut args = JsonValue::obj();
args.insert("path", "a.txt".into()).ok();
args.insert("content", "new content".into()).ok();
let r = tool.execute("id", &args, None).unwrap();
assert!(r.content[0].as_text().unwrap().text.contains("11 bytes"));
assert_eq!(
fs::read_to_string(dir.join("a.txt")).unwrap(),
"new content"
);
}
#[test]
fn creates_parent_directories() {
let dir = temp_dir("nested");
let tool = WriteTool::new(&dir);
let mut args = JsonValue::obj();
args.insert("path", "deep/nested/file.txt".into()).ok();
args.insert("content", "hi".into()).ok();
tool.execute("id", &args, None).unwrap();
assert_eq!(
fs::read_to_string(dir.join("deep/nested/file.txt")).unwrap(),
"hi"
);
}
}

View File

@ -0,0 +1,106 @@
//! `edit` 工具的单元测试。
//! Unit tests for the `edit` tool.
use focus_core::tool::Tool;
use focus_json::JsonValue;
use focus_tools::EditTool;
use std::fs;
use std::path::PathBuf;
fn temp_dir(tag: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("focus-tools-edit-{}-{}", tag, std::process::id()));
let _ = fs::remove_dir_all(&d);
fs::create_dir_all(&d).unwrap();
d
}
fn edit_args(path: &str, edits: Vec<JsonValue>) -> JsonValue {
let mut args = JsonValue::obj();
args.insert("path", path.into()).ok();
args.insert("edits", JsonValue::Arr(edits)).ok();
args
}
fn pair(old: &str, new: &str) -> JsonValue {
let mut e = JsonValue::obj();
e.insert("oldString", old.into()).ok();
e.insert("newString", new.into()).ok();
e
}
#[test]
fn applies_single_replacement() {
let dir = temp_dir("single");
fs::write(dir.join("f.txt"), "foo bar foo").unwrap();
let tool = EditTool::new(&dir);
let r = tool
.execute("id", &edit_args("f.txt", vec![pair("bar", "baz")]), None)
.unwrap();
assert!(r.content[0].as_text().unwrap().text.contains("1 edit"));
assert_eq!(
fs::read_to_string(dir.join("f.txt")).unwrap(),
"foo baz foo"
);
}
#[test]
fn applies_multiple_edits_in_order() {
let dir = temp_dir("multi");
fs::write(dir.join("f.txt"), "a b c").unwrap();
let tool = EditTool::new(&dir);
let r = tool
.execute(
"id",
&edit_args(
"f.txt",
vec![pair("a", "1"), pair("b", "2"), pair("c", "3")],
),
None,
)
.unwrap();
assert!(r.content[0].as_text().unwrap().text.contains("3 edit"));
assert_eq!(fs::read_to_string(dir.join("f.txt")).unwrap(), "1 2 3");
}
#[test]
fn ambiguous_match_requires_occurrence() {
let dir = temp_dir("ambig");
fs::write(dir.join("f.txt"), "x x x").unwrap();
let tool = EditTool::new(&dir);
// 无 occurrence → 报错。
// No occurrence → error.
let err = tool.execute("id", &edit_args("f.txt", vec![pair("x", "y")]), None);
assert!(err.is_err());
let msg = err.unwrap_err().to_string();
assert!(msg.contains("3 times"), "got: {}", msg);
// 带 occurrence → 只替换第 2 个。
// With occurrence → replace only the 2nd.
let mut e = pair("x", "y");
e.insert("occurrence", 2u64.into()).ok();
tool.execute("id", &edit_args("f.txt", vec![e]), None)
.unwrap();
assert_eq!(fs::read_to_string(dir.join("f.txt")).unwrap(), "x y x");
}
#[test]
fn not_found_reports_descriptively() {
let dir = temp_dir("nf");
fs::write(dir.join("f.txt"), "hello").unwrap();
let tool = EditTool::new(&dir);
let err = tool
.execute("id", &edit_args("f.txt", vec![pair("zzz", "y")]), None)
.unwrap_err();
assert!(err.to_string().contains("not found"), "got: {}", err);
}
#[test]
fn preserves_crlf_when_not_matched() {
let dir = temp_dir("crlf");
fs::write(dir.join("f.txt"), "a\r\nb\r\nc\r\n").unwrap();
let tool = EditTool::new(&dir);
tool.execute("id", &edit_args("f.txt", vec![pair("b", "B")]), None)
.unwrap();
// 未匹配的 CRLF 原样保留。
// Unmatched CRLF bytes are preserved as-is.
assert_eq!(fs::read(dir.join("f.txt")).unwrap(), b"a\r\nB\r\nc\r\n");
}

View File

@ -0,0 +1,81 @@
//! `read` 工具的单元测试。
//! Unit tests for the `read` tool.
use focus_core::tool::Tool;
use focus_json::JsonValue;
use focus_tools::ReadTool;
use std::fs;
use std::path::PathBuf;
fn temp_dir(tag: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("focus-tools-read-{}-{}", tag, std::process::id()));
let _ = fs::remove_dir_all(&d);
fs::create_dir_all(&d).unwrap();
d
}
#[test]
fn reads_file() {
let dir = temp_dir("reads");
fs::write(dir.join("a.txt"), "line1\nline2\nline3\n").unwrap();
let tool = ReadTool::new(&dir);
let mut args = JsonValue::obj();
args.insert("path", "a.txt".into()).ok();
let r = tool.execute("id", &args, None).unwrap();
let text = r.content[0].as_text().unwrap().text.clone();
assert_eq!(text, "line1\nline2\nline3\n");
}
#[test]
fn reads_line_range() {
let dir = temp_dir("range");
fs::write(dir.join("a.txt"), "l1\nl2\nl3\nl4\n").unwrap();
let tool = ReadTool::new(&dir);
let mut args = JsonValue::obj();
args.insert("path", "a.txt".into()).ok();
args.insert("startLine", 2u64.into()).ok();
args.insert("endLine", 3u64.into()).ok();
let r = tool.execute("id", &args, None).unwrap();
let text = r.content[0].as_text().unwrap().text.clone();
assert_eq!(text, "l2\nl3");
}
#[test]
fn missing_file_is_an_error_result() {
let dir = temp_dir("missing");
let tool = ReadTool::new(&dir);
let mut args = JsonValue::obj();
args.insert("path", "nope.txt".into()).ok();
// 可预期错误以 Err 返回agent 循环会将其编码为错误结果)。
// Expected errors surface as Err (the agent loop encodes them as error
// results).
let err = tool.execute("id", &args, None).unwrap_err().to_string();
assert!(err.contains("nope.txt"), "got: {}", err);
}
#[test]
fn binary_file_is_rejected() {
let dir = temp_dir("bin");
fs::write(dir.join("b.bin"), [0u8, 1, 2, 3]).unwrap();
let tool = ReadTool::new(&dir);
let mut args = JsonValue::obj();
args.insert("path", "b.bin".into()).ok();
let r = tool.execute("id", &args, None).unwrap();
assert!(r.content[0]
.as_text()
.map(|t| t.text.contains("binary"))
.unwrap_or(false));
}
#[test]
fn absolute_path_works() {
let dir = temp_dir("abs");
let abs = dir.join("x.txt");
fs::write(&abs, "hi").unwrap();
let tool = ReadTool::new(&dir);
let mut args = JsonValue::obj();
args.insert("path", abs.display().to_string().into()).ok();
let r = tool.execute("id", &args, None).unwrap();
let text = r.content[0].as_text().unwrap().text.clone();
assert_eq!(text, "hi");
}

View File

@ -0,0 +1,97 @@
//! `shell` 工具的单元测试unix 分支Windows 分支在有 Windows 的 CI 上跑)。
//! Unit tests for the `shell` tool (unix branch; the windows branch runs on
//! Windows CI).
use focus_core::tool::Tool;
use focus_core::tool::ToolUpdate;
use focus_json::JsonValue;
use focus_tools::ShellTool;
use std::fs;
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
fn temp_dir(tag: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("focus-tools-shell-{}-{}", tag, std::process::id()));
let _ = fs::remove_dir_all(&d);
fs::create_dir_all(&d).unwrap();
d
}
fn cmd_args(command: &str) -> JsonValue {
let mut args = JsonValue::obj();
args.insert("command", command.into()).ok();
args
}
#[cfg(unix)]
#[test]
fn runs_bash_and_captures_stdout() {
let dir = temp_dir("stdout");
let tool = ShellTool::new(&dir);
let r = tool.execute("id", &cmd_args("echo hello"), None).unwrap();
let text = r.content[0].as_text().unwrap().text.clone();
assert_eq!(text.trim(), "hello");
assert_eq!(r.details.get_num("exitCode"), Some(0.0));
assert_eq!(r.details.get_bool("timedOut"), Some(false));
}
#[cfg(unix)]
#[test]
fn captures_stderr_and_exit_code() {
let dir = temp_dir("stderr");
let tool = ShellTool::new(&dir);
let r = tool
.execute("id", &cmd_args("echo oops >&2; exit 3"), None)
.unwrap();
assert_eq!(r.details.get_num("exitCode"), Some(3.0));
let stderr = r.details.get_str("stderr").unwrap_or("");
assert!(stderr.contains("oops"), "got: {}", stderr);
}
#[cfg(unix)]
#[test]
fn times_out_and_kills() {
let dir = temp_dir("timeout");
let tool = ShellTool::new(&dir);
let mut args = cmd_args("sleep 5");
args.insert("timeoutMs", 200u64.into()).ok();
let r = tool.execute("id", &args, None).unwrap();
assert_eq!(r.details.get_bool("timedOut"), Some(true));
let text = r.content[0].as_text().unwrap().text.clone();
assert!(text.contains("timed out"), "got: {}", text);
}
#[cfg(unix)]
#[test]
fn streams_updates_line_by_line() {
let dir = temp_dir("updates");
let tool = ShellTool::new(&dir);
let lines: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
let sink: Box<dyn Fn(ToolUpdate) + Send + Sync> = {
let lines = lines.clone();
Box::new(move |u: ToolUpdate| {
if let Some(t) = u.content[0].as_text() {
lines.lock().unwrap().push(t.text.clone());
}
})
};
let r = tool
.execute("id", &cmd_args("printf 'a\\nb\\nc\\n'"), Some(&*sink))
.unwrap();
assert_eq!(r.details.get_num("exitCode"), Some(0.0));
let got = lines.lock().unwrap();
assert_eq!(
*got,
vec!["a".to_string(), "b".to_string(), "c".to_string()]
);
}
#[cfg(unix)]
#[test]
fn runs_in_project_root() {
let dir = temp_dir("cwd");
let tool = ShellTool::new(&dir);
let r = tool.execute("id", &cmd_args("pwd"), None).unwrap();
let text = r.content[0].as_text().unwrap().text.clone();
assert_eq!(text.trim(), dir.display().to_string());
}

View File

@ -0,0 +1,45 @@
//! `write` 工具的单元测试。
//! Unit tests for the `write` tool.
use focus_core::tool::Tool;
use focus_json::JsonValue;
use focus_tools::WriteTool;
use std::fs;
use std::path::PathBuf;
fn temp_dir(tag: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("focus-tools-write-{}-{}", tag, std::process::id()));
let _ = fs::remove_dir_all(&d);
fs::create_dir_all(&d).unwrap();
d
}
#[test]
fn writes_and_overwrites() {
let dir = temp_dir("overwrite");
fs::write(dir.join("a.txt"), "old").unwrap();
let tool = WriteTool::new(&dir);
let mut args = JsonValue::obj();
args.insert("path", "a.txt".into()).ok();
args.insert("content", "new content".into()).ok();
let r = tool.execute("id", &args, None).unwrap();
assert!(r.content[0].as_text().unwrap().text.contains("11 bytes"));
assert_eq!(
fs::read_to_string(dir.join("a.txt")).unwrap(),
"new content"
);
}
#[test]
fn creates_parent_directories() {
let dir = temp_dir("nested");
let tool = WriteTool::new(&dir);
let mut args = JsonValue::obj();
args.insert("path", "deep/nested/file.txt".into()).ok();
args.insert("content", "hi".into()).ok();
tool.execute("id", &args, None).unwrap();
assert_eq!(
fs::read_to_string(dir.join("deep/nested/file.txt")).unwrap(),
"hi"
);
}

View File

@ -422,10 +422,13 @@ impl StreamingBody {
///
/// 字节以任意边界喂入(网络分块不保证与 HTTP 分块对齐),解码结果累积在
/// 内部缓冲区中,通过 [`take_decoded`](Self::take_decoded) 取走。
/// 公开暴露以便独立测试;正常使用经 [`ChunkStream`] 透明完成。
/// Bytes are fed at arbitrary boundaries (network chunks need not align with
/// HTTP chunks); decoded bytes accumulate internally and are drained via
/// [`take_decoded`](Self::take_decoded).
pub(crate) struct ChunkedDecoder {
/// [`take_decoded`](Self::take_decoded). Exposed publicly so it can be tested
/// standalone; normal use happens transparently through [`ChunkStream`].
#[derive(Default)]
pub struct ChunkedDecoder {
/// 尚未消费的原始字节。
/// Raw bytes not yet consumed.
raw: Vec<u8>,
@ -447,27 +450,22 @@ pub(crate) struct ChunkedDecoder {
}
impl ChunkedDecoder {
fn new() -> Self {
Self {
raw: Vec::new(),
pos: 0,
remaining: None,
expect_crlf: false,
finished: false,
decoded: Vec::new(),
}
/// 创建空的解码器。
/// Create an empty decoder.
pub fn new() -> Self {
Self::default()
}
/// 喂入一块原始字节(含分块框架)。
/// Feed a chunk of raw bytes (including chunk framing).
fn feed(&mut self, bytes: &[u8]) {
pub fn feed(&mut self, bytes: &[u8]) {
self.raw.extend_from_slice(bytes);
self.decode();
}
/// 取走当前已解码的全部字节。
/// Drain all currently decoded bytes.
fn take_decoded(&mut self) -> Option<Vec<u8>> {
pub fn take_decoded(&mut self) -> Option<Vec<u8>> {
if self.decoded.is_empty() {
None
} else {
@ -477,7 +475,7 @@ impl ChunkedDecoder {
/// 是否已到达流末尾(读到大小为 0 的终止分块)。
/// Whether the end of the stream (size-0 terminator) has been reached.
fn is_finished(&self) -> bool {
pub fn is_finished(&self) -> bool {
self.finished
}
@ -604,77 +602,3 @@ fn find_subsequence(haystack: &[u8], needle: &[u8]) -> Option<usize> {
.windows(needle.len())
.position(|window| window == needle)
}
#[cfg(test)]
mod tests {
use super::*;
/// 把所有解码输出合并为一个字符串(测试辅助)。
/// Concatenate all decoded output into one string (test helper).
fn drain(dec: &mut ChunkedDecoder) -> String {
let mut out = Vec::new();
loop {
match dec.take_decoded() {
Some(mut chunk) => out.append(&mut chunk),
None => {
if dec.is_finished() {
break;
}
return String::from_utf8_lossy(&out).to_string();
}
}
}
String::from_utf8_lossy(&out).to_string()
}
#[test]
fn decodes_single_chunk_fed_at_once() {
let mut dec = ChunkedDecoder::new();
dec.feed(b"5\r\nhello\r\n0\r\n\r\n");
assert_eq!(drain(&mut dec), "hello");
}
#[test]
fn decodes_chunk_split_across_feeds() {
// 网络分块与 HTTP 分块不对齐:一次喂一半。
// Network chunks misalign with HTTP chunks: feed half at a time.
let mut dec = ChunkedDecoder::new();
dec.feed(b"5\r\nhe");
assert_eq!(drain(&mut dec), "he");
dec.feed(b"llo\r\n0\r\n\r\n");
assert_eq!(drain(&mut dec), "llo");
}
#[test]
fn decodes_multiple_chunks_and_extension() {
let mut dec = ChunkedDecoder::new();
dec.feed(b"5;name=value\r\nhello\r\n6\r\n world\r\n0\r\n\r\n");
assert_eq!(drain(&mut dec), "hello world");
}
#[test]
fn yields_partial_data_before_terminator() {
let mut dec = ChunkedDecoder::new();
// 第一个分块完整、第二个分块只喂了部分,应先把已解码部分吐出。
// First chunk complete, second partial: yield decoded bytes so far.
dec.feed(b"3\r\nabc\r\n5\r\n12");
assert_eq!(drain(&mut dec), "abc12");
dec.feed(b"345\r\n0\r\n\r\n");
assert_eq!(drain(&mut dec), "345");
}
#[test]
fn chunked_streaming_body_end_to_end() {
// 模拟完整 SSE 流chunked 编码)经 StreamingBody 逐块吐出。
// Simulate a full SSE stream (chunked-encoded) being drained via StreamingBody.
// 这里只测解码器端到端;网络部分由 provider 的 mock transport 覆盖。
// Only the decoder is exercised here; the network side is covered by the
// providers' mock transport tests.
let mut dec = ChunkedDecoder::new();
let payload = "event: message_start\r\ndata: {\"type\":\"x\"}\r\n\r\n";
assert_eq!(payload.len(), 44);
dec.feed(format!("{:x}\r\n{}", payload.len(), payload).as_bytes());
dec.feed(b"0\r\n\r\n");
assert_eq!(drain(&mut dec), payload);
}
}

View File

@ -24,3 +24,73 @@ fn decodes_single_chunk() {
let body = b"3\r\nabc\r\n0\r\n\r\n";
assert_eq!(decode_chunked(body).unwrap(), b"abc");
}
// ---- 增量 chunked 解码器 ----
// ---- incremental chunked decoder ------------------------------------------
use focus_transport::transport::ChunkedDecoder;
/// 把所有解码输出合并为一个字符串(测试辅助)。
/// Concatenate all decoded output into one string (test helper).
fn drain(dec: &mut ChunkedDecoder) -> String {
let mut out = Vec::new();
loop {
match dec.take_decoded() {
Some(mut chunk) => out.append(&mut chunk),
None => {
if dec.is_finished() {
break;
}
return String::from_utf8_lossy(&out).to_string();
}
}
}
String::from_utf8_lossy(&out).to_string()
}
#[test]
fn decodes_single_chunk_fed_at_once() {
let mut dec = ChunkedDecoder::new();
dec.feed(b"5\r\nhello\r\n0\r\n\r\n");
assert_eq!(drain(&mut dec), "hello");
}
#[test]
fn decodes_chunk_split_across_feeds() {
// 网络分块与 HTTP 分块不对齐:一次喂一半。
// Network chunks misalign with HTTP chunks: feed half at a time.
let mut dec = ChunkedDecoder::new();
dec.feed(b"5\r\nhe");
assert_eq!(drain(&mut dec), "he");
dec.feed(b"llo\r\n0\r\n\r\n");
assert_eq!(drain(&mut dec), "llo");
}
#[test]
fn decodes_multiple_chunks_and_extension() {
let mut dec = ChunkedDecoder::new();
dec.feed(b"5;name=value\r\nhello\r\n6\r\n world\r\n0\r\n\r\n");
assert_eq!(drain(&mut dec), "hello world");
}
#[test]
fn yields_partial_data_before_terminator() {
let mut dec = ChunkedDecoder::new();
// 第一个分块完整、第二个分块只喂了部分,应先把已解码部分吐出。
// First chunk complete, second partial: yield decoded bytes so far.
dec.feed(b"3\r\nabc\r\n5\r\n12");
assert_eq!(drain(&mut dec), "abc12");
dec.feed(b"345\r\n0\r\n\r\n");
assert_eq!(drain(&mut dec), "345");
}
#[test]
fn decodes_an_sse_stream_in_chunked_framing() {
// 模拟完整 SSE 流chunked 编码)经解码器逐块吐出。
// Simulate a full SSE stream (chunked-encoded) drained through the decoder.
let payload = "event: message_start\r\ndata: {\"type\":\"x\"}\r\n\r\n";
let mut dec = ChunkedDecoder::new();
dec.feed(format!("{:x}\r\n{}\r\n", payload.len(), payload).as_bytes());
dec.feed(b"0\r\n\r\n");
assert_eq!(drain(&mut dec), payload);
}