test(protocols,skills): add 90 tests for MCP types + skill loader/runner

zclaw-protocols: +43 tests covering mcp_types serde, ContentBlock
variants, transport config builders, and domain type roundtrips.

zclaw-skills: +47 tests covering SKILL.md/TOML parsing, auto-classify,
PromptOnlySkill execution, and SkillManifest/SkillResult roundtrips.

Batch 8 of audit plan (plans/stateless-petting-rossum.md).
This commit is contained in:
iven
2026-04-19 11:24:57 +08:00
parent 226beb708b
commit beeb529d8f
6 changed files with 981 additions and 0 deletions

View File

@@ -0,0 +1,247 @@
//! Tests for skill loader — SKILL.md and TOML parsing
use zclaw_skills::*;
// === parse_skill_md ===
#[test]
fn parse_skill_md_basic_frontmatter() {
let content = r#"---
name: "Code Reviewer"
description: "Reviews code"
version: "1.0.0"
mode: prompt-only
tags: coding, review
---
# Code Reviewer
Reviews code for quality.
"#;
let manifest = parse_skill_md(content).unwrap();
assert_eq!(manifest.name, "Code Reviewer");
assert_eq!(manifest.description, "Reviews code");
assert_eq!(manifest.version, "1.0.0");
assert_eq!(manifest.mode, zclaw_skills::SkillMode::PromptOnly);
assert_eq!(manifest.tags, vec!["coding", "review"]);
}
#[test]
fn parse_skill_md_with_triggers_list() {
let content = r#"---
name: "Translator"
description: "Translates text"
version: "1.0.0"
mode: prompt-only
triggers:
- "翻译"
- "translate"
- "中译英"
---
# Translator
"#;
let manifest = parse_skill_md(content).unwrap();
assert_eq!(manifest.triggers, vec!["翻译", "translate", "中译英"]);
}
#[test]
fn parse_skill_md_with_tools_list() {
let content = r#"---
name: "Builder"
description: "Builds projects"
version: "1.0.0"
mode: shell
tools:
- "bash"
- "cargo"
---
# Builder
"#;
let manifest = parse_skill_md(content).unwrap();
assert_eq!(manifest.tools, vec!["bash", "cargo"]);
assert_eq!(manifest.mode, zclaw_skills::SkillMode::Shell);
}
#[test]
fn parse_skill_md_with_category() {
let content = r#"---
name: "Math Solver"
description: "Solves math problems"
version: "1.0.0"
mode: prompt-only
category: "math"
---
# Math Solver
"#;
let manifest = parse_skill_md(content).unwrap();
assert_eq!(manifest.category.unwrap(), "math");
}
#[test]
fn parse_skill_md_auto_classify_coding() {
let content = r#"---
name: "Code Helper"
description: "Helps with programming and debugging"
version: "1.0.0"
mode: prompt-only
---
# Code Helper
"#;
let manifest = parse_skill_md(content).unwrap();
// Should auto-classify as "coding" based on description
assert_eq!(manifest.category.unwrap(), "coding");
}
#[test]
fn parse_skill_md_auto_classify_translation() {
let content = r#"---
name: "Translator"
description: "Helps with translation between languages"
version: "1.0.0"
mode: prompt-only
---
# Translator
"#;
let manifest = parse_skill_md(content).unwrap();
// Should auto-classify based on "translat" keyword
assert!(manifest.category.is_some(), "Should auto-classify translation skill");
}
#[test]
fn parse_skill_md_no_frontmatter_extracts_name() {
let content = "# My Skill\n\nThis is a cool skill.";
let manifest = parse_skill_md(content).unwrap();
assert_eq!(manifest.name, "My Skill");
}
#[test]
fn parse_skill_md_fallback_name() {
let content = "Just some text without structure.";
let manifest = parse_skill_md(content).unwrap();
assert_eq!(manifest.name, "unnamed-skill");
}
#[test]
fn parse_skill_md_id_generation() {
let content = "---\nname: \"Hello World\"\n---\n";
let manifest = parse_skill_md(content).unwrap();
assert_eq!(manifest.id.as_str(), "hello-world");
}
#[test]
fn parse_skill_md_all_modes() {
for (mode_str, expected) in &[
("prompt-only", zclaw_skills::SkillMode::PromptOnly),
("python", zclaw_skills::SkillMode::Python),
("shell", zclaw_skills::SkillMode::Shell),
("wasm", zclaw_skills::SkillMode::Wasm),
("native", zclaw_skills::SkillMode::Native),
] {
let content = format!("---\nname: \"Test\"\nmode: {}\n---\n", mode_str);
let manifest = parse_skill_md(&content).unwrap();
assert_eq!(&manifest.mode, expected, "Failed for mode: {}", mode_str);
}
}
#[test]
fn parse_skill_md_capabilities_csv() {
let content = "---\nname: \"Multi\"\ncapabilities: llm, web, file\n---\n";
let manifest = parse_skill_md(content).unwrap();
assert_eq!(manifest.capabilities, vec!["llm", "web", "file"]);
}
// === parse_skill_toml ===
#[test]
fn parse_skill_toml_basic() {
let content = r#"
name = "Calculator"
description = "Performs calculations"
version = "2.0.0"
mode = "prompt_only"
"#;
let manifest = parse_skill_toml(content).unwrap();
assert_eq!(manifest.name, "Calculator");
assert_eq!(manifest.description, "Performs calculations");
assert_eq!(manifest.version, "2.0.0");
}
#[test]
fn parse_skill_toml_with_id() {
let content = r#"
id = "my-calc"
name = "Calculator"
description = "Calc"
"#;
let manifest = parse_skill_toml(content).unwrap();
assert_eq!(manifest.id.as_str(), "my-calc");
}
#[test]
fn parse_skill_toml_generates_id_from_name() {
let content = "name = \"Hello World\"\ndescription = \"x\"";
let manifest = parse_skill_toml(content).unwrap();
assert_eq!(manifest.id.as_str(), "hello-world");
}
#[test]
fn parse_skill_toml_requires_name() {
let content = r#"description = "no name""#;
let result = parse_skill_toml(content);
assert!(result.is_err());
}
#[test]
fn parse_skill_toml_arrays() {
let content = r#"
name = "X"
description = "x"
tags = ["a", "b", "c"]
capabilities = ["llm"]
triggers = ["go", "run"]
"#;
let manifest = parse_skill_toml(content).unwrap();
assert_eq!(manifest.tags, vec!["a", "b", "c"]);
assert_eq!(manifest.capabilities, vec!["llm"]);
assert_eq!(manifest.triggers, vec!["go", "run"]);
}
#[test]
fn parse_skill_toml_category() {
let content = r#"
name = "X"
description = "x"
category = "data"
"#;
let manifest = parse_skill_toml(content).unwrap();
assert_eq!(manifest.category.unwrap(), "data");
}
#[test]
fn parse_skill_toml_tools() {
let content = r#"
name = "X"
description = "x"
tools = ["bash", "cargo"]
"#;
let manifest = parse_skill_toml(content).unwrap();
assert_eq!(manifest.tools, vec!["bash", "cargo"]);
}
#[test]
fn parse_skill_toml_ignores_comments_and_sections() {
let content = r#"
# This is a comment
[section]
name = "X"
description = "x"
"#;
let manifest = parse_skill_toml(content).unwrap();
assert_eq!(manifest.name, "X");
}
// === discover_skills ===
#[test]
fn discover_skills_nonexistent_dir() {
let result = discover_skills(std::path::Path::new("/nonexistent/path")).unwrap();
assert!(result.is_empty());
}

View File

@@ -0,0 +1,78 @@
//! Tests for PromptOnlySkill runner
use zclaw_skills::*;
use zclaw_types::SkillId;
/// Helper to create a minimal manifest
fn test_manifest(mode: SkillMode) -> SkillManifest {
SkillManifest {
id: SkillId::new("test-prompt-skill"),
name: "Test Prompt Skill".to_string(),
description: "A test prompt skill".to_string(),
version: "1.0.0".to_string(),
author: None,
mode,
capabilities: vec![],
input_schema: None,
output_schema: None,
tags: vec![],
category: None,
triggers: vec![],
tools: vec![],
enabled: true,
}
}
#[tokio::test]
async fn prompt_only_skill_returns_formatted_prompt() {
let manifest = test_manifest(SkillMode::PromptOnly);
let template = "Hello {{input}}, welcome!".to_string();
let skill = PromptOnlySkill::new(manifest, template);
let ctx = SkillContext::default();
let skill_ref: &dyn Skill = &skill;
let result = skill_ref.execute(&ctx, serde_json::json!("World")).await.unwrap();
assert!(result.success);
let output = result.output.as_str().unwrap();
assert_eq!(output, "Hello World, welcome!");
}
#[tokio::test]
async fn prompt_only_skill_json_input() {
let manifest = test_manifest(SkillMode::PromptOnly);
let template = "Input: {{input}}".to_string();
let skill = PromptOnlySkill::new(manifest, template);
let ctx = SkillContext::default();
let input = serde_json::json!({"key": "value"});
let skill_ref: &dyn Skill = &skill;
let result = skill_ref.execute(&ctx, input).await.unwrap();
assert!(result.success);
let output = result.output.as_str().unwrap();
assert!(output.contains("key"));
assert!(output.contains("value"));
}
#[tokio::test]
async fn prompt_only_skill_no_placeholder() {
let manifest = test_manifest(SkillMode::PromptOnly);
let template = "Static prompt content".to_string();
let skill = PromptOnlySkill::new(manifest, template);
let ctx = SkillContext::default();
let skill_ref: &dyn Skill = &skill;
let result = skill_ref.execute(&ctx, serde_json::json!("ignored")).await.unwrap();
assert!(result.success);
assert_eq!(result.output.as_str().unwrap(), "Static prompt content");
}
#[tokio::test]
async fn prompt_only_skill_manifest() {
let manifest = test_manifest(SkillMode::PromptOnly);
let skill = PromptOnlySkill::new(manifest.clone(), "prompt".to_string());
assert_eq!(skill.manifest().id.as_str(), "test-prompt-skill");
assert_eq!(skill.manifest().name, "Test Prompt Skill");
}

View File

@@ -0,0 +1,148 @@
//! Tests for zclaw-skills types: SkillManifest, SkillMode, SkillResult, SkillContext
use serde_json;
use zclaw_skills::*;
use zclaw_types::SkillId;
// === SkillMode ===
#[test]
fn skill_mode_serialization_roundtrip() {
let modes = vec![
SkillMode::PromptOnly,
SkillMode::Python,
SkillMode::Shell,
SkillMode::Wasm,
SkillMode::Native,
];
for mode in modes {
let json = serde_json::to_string(&mode).unwrap();
let parsed: SkillMode = serde_json::from_str(&json).unwrap();
assert_eq!(mode, parsed);
}
}
#[test]
fn skill_mode_snake_case_serialization() {
let json = serde_json::to_string(&SkillMode::PromptOnly).unwrap();
assert!(json.contains("prompt_only"));
}
// === SkillResult ===
#[test]
fn skill_result_success() {
let result = SkillResult::success(serde_json::json!({"answer": 42}));
assert!(result.success);
assert!(result.error.is_none());
assert_eq!(result.output["answer"], 42);
}
#[test]
fn skill_result_error() {
let result = SkillResult::error("execution failed");
assert!(!result.success);
assert_eq!(result.error.unwrap(), "execution failed");
assert!(result.output.is_null());
}
#[test]
fn skill_result_roundtrip() {
let result = SkillResult {
success: true,
output: serde_json::json!("hello"),
error: None,
duration_ms: Some(150),
tokens_used: Some(42),
};
let json = serde_json::to_string(&result).unwrap();
let parsed: SkillResult = serde_json::from_str(&json).unwrap();
assert!(parsed.success);
assert_eq!(parsed.duration_ms.unwrap(), 150);
assert_eq!(parsed.tokens_used.unwrap(), 42);
}
// === SkillManifest ===
#[test]
fn skill_manifest_full_roundtrip() {
let manifest = SkillManifest {
id: SkillId::new("test-skill"),
name: "Test Skill".to_string(),
description: "A test skill".to_string(),
version: "2.0.0".to_string(),
author: Some("tester".to_string()),
mode: SkillMode::PromptOnly,
capabilities: vec!["llm".to_string()],
input_schema: Some(serde_json::json!({"type": "object"})),
output_schema: None,
tags: vec!["test".to_string()],
category: Some("coding".to_string()),
triggers: vec!["test trigger".to_string()],
tools: vec!["bash".to_string()],
enabled: true,
};
let json = serde_json::to_string(&manifest).unwrap();
let parsed: SkillManifest = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.id.as_str(), "test-skill");
assert_eq!(parsed.name, "Test Skill");
assert_eq!(parsed.mode, SkillMode::PromptOnly);
assert_eq!(parsed.capabilities.len(), 1);
assert_eq!(parsed.triggers.len(), 1);
assert_eq!(parsed.tools.len(), 1);
assert_eq!(parsed.category.unwrap(), "coding");
assert!(parsed.enabled);
}
#[test]
fn skill_manifest_default_enabled() {
let json = r#"{"id":"x","name":"X","description":"x","version":"1.0","mode":"prompt_only"}"#;
let manifest: SkillManifest = serde_json::from_str(json).unwrap();
assert!(manifest.enabled, "enabled should default to true");
}
#[test]
fn skill_manifest_disabled() {
let json = r#"{"id":"x","name":"X","description":"x","version":"1.0","mode":"prompt_only","enabled":false}"#;
let manifest: SkillManifest = serde_json::from_str(json).unwrap();
assert!(!manifest.enabled);
}
#[test]
fn skill_manifest_all_modes_roundtrip() {
for mode in &[SkillMode::PromptOnly, SkillMode::Python, SkillMode::Shell, SkillMode::Wasm] {
let manifest = SkillManifest {
id: SkillId::new("m"),
name: "M".into(),
description: "d".into(),
version: "1.0".into(),
author: None,
mode: mode.clone(),
capabilities: vec![],
input_schema: None,
output_schema: None,
tags: vec![],
category: None,
triggers: vec![],
tools: vec![],
enabled: true,
};
let json = serde_json::to_string(&manifest).unwrap();
let parsed: SkillManifest = serde_json::from_str(&json).unwrap();
assert_eq!(*mode, parsed.mode);
}
}
// === SkillContext ===
#[test]
fn skill_context_default() {
let ctx = SkillContext::default();
assert!(ctx.agent_id.is_empty());
assert!(ctx.session_id.is_empty());
assert!(ctx.working_dir.is_none());
assert_eq!(ctx.timeout_secs, 60);
assert!(!ctx.network_allowed);
assert!(!ctx.file_access_allowed);
assert!(ctx.llm.is_none());
}