use std::path::Path;
use serde::{Deserialize, Serialize};
use skm_core::{SkillName, SkillRegistry};
use skm_select::{CascadeSelector, SelectionContext, SelectionStrategy};
use crate::error::LearnError;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum TestExpectation {
Single(SkillName),
AnyOf(Vec<SkillName>),
None,
}
impl TestExpectation {
pub fn matches(&self, result: Option<&SkillName>) -> bool {
match (self, result) {
(Self::None, None) => true,
(Self::None, Some(_)) => false,
(Self::Single(expected), Some(actual)) => expected == actual,
(Self::Single(_), None) => false,
(Self::AnyOf(expected), Some(actual)) => expected.contains(actual),
(Self::AnyOf(_), None) => false,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TestCase {
pub name: String,
pub input: String,
pub expected: TestExpectation,
#[serde(default)]
pub context: SelectionContext,
}
#[derive(Debug, Clone)]
pub struct TestCaseResult {
pub name: String,
pub passed: bool,
pub selected: Option<SkillName>,
pub expected: TestExpectation,
pub score: Option<f32>,
pub latency_ms: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TestSuite {
pub name: String,
pub cases: Vec<TestCase>,
}
impl TestSuite {
pub fn from_file(path: &Path) -> Result<Self, LearnError> {
let content = std::fs::read_to_string(path)?;
Ok(serde_yaml::from_str(&content)?)
}
pub fn new(name: impl Into<String>) -> Self {
Self {
name: name.into(),
cases: Vec::new(),
}
}
pub fn add_case(&mut self, case: TestCase) {
self.cases.push(case);
}
}
#[derive(Debug, Clone, Default)]
pub struct SkillTestReport {
pub total: usize,
pub correct: usize,
pub false_positives: usize,
pub false_negatives: usize,
}
impl SkillTestReport {
pub fn precision(&self) -> f32 {
let denom = self.correct + self.false_positives;
if denom == 0 {
0.0
} else {
self.correct as f32 / denom as f32
}
}
pub fn recall(&self) -> f32 {
let denom = self.correct + self.false_negatives;
if denom == 0 {
0.0
} else {
self.correct as f32 / denom as f32
}
}
pub fn f1(&self) -> f32 {
let p = self.precision();
let r = self.recall();
if p + r == 0.0 {
0.0
} else {
2.0 * p * r / (p + r)
}
}
}
#[derive(Debug, Clone)]
pub struct TestReport {
pub suite_name: String,
pub results: Vec<TestCaseResult>,
pub per_skill: std::collections::HashMap<SkillName, SkillTestReport>,
pub total: usize,
pub passed: usize,
pub avg_latency_ms: f32,
}
impl TestReport {
pub fn accuracy(&self) -> f32 {
if self.total == 0 {
0.0
} else {
self.passed as f32 / self.total as f32
}
}
}
pub struct TriggerTestHarness {
pub runs_per_case: usize,
}
impl Default for TriggerTestHarness {
fn default() -> Self {
Self::new()
}
}
impl TriggerTestHarness {
pub fn new() -> Self {
Self { runs_per_case: 1 }
}
pub fn with_runs(mut self, runs: usize) -> Self {
self.runs_per_case = runs;
self
}
pub async fn run(
&self,
suite: &TestSuite,
selector: &CascadeSelector,
registry: &SkillRegistry,
) -> Result<TestReport, LearnError> {
let mut results = Vec::new();
let mut per_skill: std::collections::HashMap<SkillName, SkillTestReport> =
std::collections::HashMap::new();
for case in &suite.cases {
let start = std::time::Instant::now();
let outcome = selector.select(&case.input, registry, &case.context).await?;
let latency = start.elapsed();
let selected = outcome.selected.first().map(|r| r.skill.clone());
let score = outcome.selected.first().map(|r| r.score);
let passed = case.expected.matches(selected.as_ref());
let expected_skills = match &case.expected {
TestExpectation::Single(s) => vec![s.clone()],
TestExpectation::AnyOf(list) => list.clone(),
TestExpectation::None => vec![],
};
for skill in &expected_skills {
let report = per_skill.entry(skill.clone()).or_default();
report.total += 1;
if selected.as_ref() == Some(skill) {
report.correct += 1;
} else {
report.false_negatives += 1;
}
}
if let Some(ref sel) = selected {
if !expected_skills.contains(sel) {
let report = per_skill.entry(sel.clone()).or_default();
report.false_positives += 1;
}
}
results.push(TestCaseResult {
name: case.name.clone(),
passed,
selected,
expected: case.expected.clone(),
score,
latency_ms: latency.as_millis() as u64,
});
}
let passed = results.iter().filter(|r| r.passed).count();
let total = results.len();
let avg_latency = if total > 0 {
results.iter().map(|r| r.latency_ms as f32).sum::<f32>() / total as f32
} else {
0.0
};
Ok(TestReport {
suite_name: suite.name.clone(),
results,
per_skill,
total,
passed,
avg_latency_ms: avg_latency,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_expectation_single_match() {
let exp = TestExpectation::Single(SkillName::new("test").unwrap());
assert!(exp.matches(Some(&SkillName::new("test").unwrap())));
assert!(!exp.matches(Some(&SkillName::new("other").unwrap())));
assert!(!exp.matches(None));
}
#[test]
fn test_expectation_any_of() {
let exp = TestExpectation::AnyOf(vec![
SkillName::new("a").unwrap(),
SkillName::new("b").unwrap(),
]);
assert!(exp.matches(Some(&SkillName::new("a").unwrap())));
assert!(exp.matches(Some(&SkillName::new("b").unwrap())));
assert!(!exp.matches(Some(&SkillName::new("c").unwrap())));
}
#[test]
fn test_expectation_none() {
let exp = TestExpectation::None;
assert!(exp.matches(None));
assert!(!exp.matches(Some(&SkillName::new("test").unwrap())));
}
#[test]
fn test_skill_report_metrics() {
let report = SkillTestReport {
total: 10,
correct: 8,
false_positives: 2,
false_negatives: 2,
};
assert!((report.precision() - 0.8).abs() < 1e-5);
assert!((report.recall() - 0.8).abs() < 1e-5);
assert!((report.f1() - 0.8).abs() < 1e-5);
}
#[test]
fn test_test_suite_new() {
let suite = TestSuite::new("my-suite");
assert_eq!(suite.name, "my-suite");
assert!(suite.cases.is_empty());
}
}