test: add systematic test coverage for 7 bug pattern groups (#852)

Add ~105 test cases across 7 test groups identified in issue #852:

TG1 - Provider resolution (27 tests): Factory resolution, alias mapping,
      custom URLs, auth styles, credential wiring
TG2 - Config persistence (18 tests): Config defaults, TOML roundtrip,
      agent/memory config, workspace dirs
TG3 - Channel routing (14 tests): ChannelMessage identity contracts,
      SendMessage construction, Channel trait send/listen roundtrip
TG4 - Agent loop robustness (12 integration + 14 inline tests): Malformed
      tool calls, failing tools, iteration limits, empty responses, unicode
TG5 - Memory restart (14 tests): Dedup on same key, restart persistence,
      session scoping, recall, concurrent stores, categories
TG6 - Channel message splitting (8+8 inline tests): Code blocks at boundary,
      long words, emoji, CJK chars, whitespace edge cases
TG7 - Provider schema (21 tests): ChatMessage/ToolCall/ChatResponse
      serialization, tool_call_id preservation, auth style variants

Also fixes a bug in split_message_for_telegram() where byte-based indexing
could panic on multi-byte characters (emoji, CJK). Now uses char_indices()
consistent with the Discord split implementation.

Closes #852

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Alex Gorevski 2026-02-18 15:28:34 -08:00
parent b43e9eb325
commit 7f03ab77a9
9 changed files with 2272 additions and 8 deletions

245
tests/config_persistence.rs Normal file
View file

@ -0,0 +1,245 @@
//! TG2: Config Load/Save Round-Trip Tests
//!
//! Prevents: Pattern 2 — Config persistence & workspace discovery bugs (13% of user bugs).
//! Issues: #547, #417, #621, #802
//!
//! Tests Config::load_or_init() with isolated temp directories, env var overrides,
//! and config file round-trips to verify workspace discovery and persistence.
use std::fs;
use zeroclaw::config::{AgentConfig, Config, MemoryConfig};
// ─────────────────────────────────────────────────────────────────────────────
// Config default construction
// ─────────────────────────────────────────────────────────────────────────────
#[test]
fn config_default_has_expected_provider() {
let config = Config::default();
assert!(
config.default_provider.is_some(),
"default config should have a default_provider"
);
}
#[test]
fn config_default_has_expected_model() {
let config = Config::default();
assert!(
config.default_model.is_some(),
"default config should have a default_model"
);
}
#[test]
fn config_default_temperature_positive() {
let config = Config::default();
assert!(
config.default_temperature > 0.0,
"default temperature should be positive"
);
}
// ─────────────────────────────────────────────────────────────────────────────
// AgentConfig defaults
// ─────────────────────────────────────────────────────────────────────────────
#[test]
fn agent_config_default_max_tool_iterations() {
let agent = AgentConfig::default();
assert_eq!(
agent.max_tool_iterations, 10,
"default max_tool_iterations should be 10"
);
}
#[test]
fn agent_config_default_max_history_messages() {
let agent = AgentConfig::default();
assert_eq!(
agent.max_history_messages, 50,
"default max_history_messages should be 50"
);
}
#[test]
fn agent_config_default_tool_dispatcher() {
let agent = AgentConfig::default();
assert_eq!(
agent.tool_dispatcher, "auto",
"default tool_dispatcher should be 'auto'"
);
}
#[test]
fn agent_config_default_compact_context_off() {
let agent = AgentConfig::default();
assert!(
!agent.compact_context,
"compact_context should default to false"
);
}
// ─────────────────────────────────────────────────────────────────────────────
// MemoryConfig defaults
// ─────────────────────────────────────────────────────────────────────────────
#[test]
fn memory_config_default_backend() {
let memory = MemoryConfig::default();
assert!(
!memory.backend.is_empty(),
"memory backend should have a default value"
);
}
#[test]
fn memory_config_default_embedding_provider() {
let memory = MemoryConfig::default();
// Default embedding_provider should be set (even if "none")
assert!(
!memory.embedding_provider.is_empty(),
"embedding_provider should have a default value"
);
}
#[test]
fn memory_config_default_vector_keyword_weights_sum_to_one() {
let memory = MemoryConfig::default();
let sum = memory.vector_weight + memory.keyword_weight;
assert!(
(sum - 1.0).abs() < 0.01,
"vector_weight + keyword_weight should sum to ~1.0, got {sum}"
);
}
// ─────────────────────────────────────────────────────────────────────────────
// Config TOML serialization round-trip
// ─────────────────────────────────────────────────────────────────────────────
#[test]
fn config_toml_roundtrip_preserves_provider() {
let mut config = Config::default();
config.default_provider = Some("deepseek".into());
config.default_model = Some("deepseek-chat".into());
config.default_temperature = 0.5;
let toml_str = toml::to_string(&config).expect("config should serialize to TOML");
let parsed: Config = toml::from_str(&toml_str).expect("TOML should deserialize back");
assert_eq!(parsed.default_provider.as_deref(), Some("deepseek"));
assert_eq!(parsed.default_model.as_deref(), Some("deepseek-chat"));
assert!((parsed.default_temperature - 0.5).abs() < f64::EPSILON);
}
#[test]
fn config_toml_roundtrip_preserves_agent_config() {
let mut config = Config::default();
config.agent.max_tool_iterations = 5;
config.agent.max_history_messages = 25;
config.agent.compact_context = true;
let toml_str = toml::to_string(&config).expect("config should serialize to TOML");
let parsed: Config = toml::from_str(&toml_str).expect("TOML should deserialize back");
assert_eq!(parsed.agent.max_tool_iterations, 5);
assert_eq!(parsed.agent.max_history_messages, 25);
assert!(parsed.agent.compact_context);
}
#[test]
fn config_toml_roundtrip_preserves_memory_config() {
let mut config = Config::default();
config.memory.embedding_provider = "openai".into();
config.memory.embedding_model = "text-embedding-3-small".into();
config.memory.vector_weight = 0.8;
config.memory.keyword_weight = 0.2;
let toml_str = toml::to_string(&config).expect("config should serialize to TOML");
let parsed: Config = toml::from_str(&toml_str).expect("TOML should deserialize back");
assert_eq!(parsed.memory.embedding_provider, "openai");
assert_eq!(parsed.memory.embedding_model, "text-embedding-3-small");
assert!((parsed.memory.vector_weight - 0.8).abs() < f64::EPSILON);
assert!((parsed.memory.keyword_weight - 0.2).abs() < f64::EPSILON);
}
// ─────────────────────────────────────────────────────────────────────────────
// Config file write/read round-trip with tempdir
// ─────────────────────────────────────────────────────────────────────────────
#[test]
fn config_file_write_read_roundtrip() {
let tmp = tempfile::TempDir::new().expect("tempdir creation should succeed");
let config_path = tmp.path().join("config.toml");
let mut config = Config::default();
config.default_provider = Some("mistral".into());
config.default_model = Some("mistral-large".into());
config.agent.max_tool_iterations = 15;
let toml_str = toml::to_string(&config).expect("config should serialize");
fs::write(&config_path, &toml_str).expect("config file write should succeed");
let read_back = fs::read_to_string(&config_path).expect("config file read should succeed");
let parsed: Config = toml::from_str(&read_back).expect("TOML should parse back");
assert_eq!(parsed.default_provider.as_deref(), Some("mistral"));
assert_eq!(parsed.default_model.as_deref(), Some("mistral-large"));
assert_eq!(parsed.agent.max_tool_iterations, 15);
}
#[test]
fn config_file_with_missing_optional_fields_uses_defaults() {
// Simulate a minimal config TOML that omits optional sections
let minimal_toml = r#"
default_temperature = 0.7
"#;
let parsed: Config = toml::from_str(minimal_toml).expect("minimal TOML should parse");
// Agent config should use defaults
assert_eq!(parsed.agent.max_tool_iterations, 10);
assert_eq!(parsed.agent.max_history_messages, 50);
assert!(!parsed.agent.compact_context);
}
#[test]
fn config_file_with_custom_agent_section() {
let toml_with_agent = r#"
default_temperature = 0.7
[agent]
max_tool_iterations = 3
compact_context = true
"#;
let parsed: Config =
toml::from_str(toml_with_agent).expect("TOML with agent section should parse");
assert_eq!(parsed.agent.max_tool_iterations, 3);
assert!(parsed.agent.compact_context);
// max_history_messages should still use default
assert_eq!(parsed.agent.max_history_messages, 50);
}
// ─────────────────────────────────────────────────────────────────────────────
// Workspace directory creation
// ─────────────────────────────────────────────────────────────────────────────
#[test]
fn workspace_dir_creation_in_tempdir() {
let tmp = tempfile::TempDir::new().expect("tempdir creation should succeed");
let workspace_dir = tmp.path().join("workspace");
fs::create_dir_all(&workspace_dir).expect("workspace dir creation should succeed");
assert!(workspace_dir.exists(), "workspace dir should exist");
assert!(workspace_dir.is_dir(), "workspace path should be a directory");
}
#[test]
fn nested_workspace_dir_creation() {
let tmp = tempfile::TempDir::new().expect("tempdir creation should succeed");
let nested_dir = tmp.path().join("deep").join("nested").join("workspace");
fs::create_dir_all(&nested_dir).expect("nested dir creation should succeed");
assert!(nested_dir.exists(), "nested workspace dir should exist");
}