1use std::path::Path;
4
5use serde::{Deserialize, Serialize};
6
7use skm_core::{SkillName, SkillRegistry};
8use skm_select::{CascadeSelector, SelectionContext, SelectionStrategy};
9
10use crate::error::LearnError;
11
12#[derive(Debug, Clone, Serialize, Deserialize)]
14#[serde(untagged)]
15pub enum TestExpectation {
16 Single(SkillName),
18
19 AnyOf(Vec<SkillName>),
21
22 None,
24}
25
26impl TestExpectation {
27 pub fn matches(&self, result: Option<&SkillName>) -> bool {
29 match (self, result) {
30 (Self::None, None) => true,
31 (Self::None, Some(_)) => false,
32 (Self::Single(expected), Some(actual)) => expected == actual,
33 (Self::Single(_), None) => false,
34 (Self::AnyOf(expected), Some(actual)) => expected.contains(actual),
35 (Self::AnyOf(_), None) => false,
36 }
37 }
38}
39
40#[derive(Debug, Clone, Serialize, Deserialize)]
42pub struct TestCase {
43 pub name: String,
45
46 pub input: String,
48
49 pub expected: TestExpectation,
51
52 #[serde(default)]
54 pub context: SelectionContext,
55}
56
57#[derive(Debug, Clone)]
59pub struct TestCaseResult {
60 pub name: String,
62
63 pub passed: bool,
65
66 pub selected: Option<SkillName>,
68
69 pub expected: TestExpectation,
71
72 pub score: Option<f32>,
74
75 pub latency_ms: u64,
77}
78
79#[derive(Debug, Clone, Serialize, Deserialize)]
81pub struct TestSuite {
82 pub name: String,
84
85 pub cases: Vec<TestCase>,
87}
88
89impl TestSuite {
90 pub fn from_file(path: &Path) -> Result<Self, LearnError> {
92 let content = std::fs::read_to_string(path)?;
93 Ok(serde_yaml::from_str(&content)?)
94 }
95
96 pub fn new(name: impl Into<String>) -> Self {
98 Self {
99 name: name.into(),
100 cases: Vec::new(),
101 }
102 }
103
104 pub fn add_case(&mut self, case: TestCase) {
106 self.cases.push(case);
107 }
108}
109
110#[derive(Debug, Clone, Default)]
112pub struct SkillTestReport {
113 pub total: usize,
115
116 pub correct: usize,
118
119 pub false_positives: usize,
121
122 pub false_negatives: usize,
124}
125
126impl SkillTestReport {
127 pub fn precision(&self) -> f32 {
129 let denom = self.correct + self.false_positives;
130 if denom == 0 {
131 0.0
132 } else {
133 self.correct as f32 / denom as f32
134 }
135 }
136
137 pub fn recall(&self) -> f32 {
139 let denom = self.correct + self.false_negatives;
140 if denom == 0 {
141 0.0
142 } else {
143 self.correct as f32 / denom as f32
144 }
145 }
146
147 pub fn f1(&self) -> f32 {
149 let p = self.precision();
150 let r = self.recall();
151 if p + r == 0.0 {
152 0.0
153 } else {
154 2.0 * p * r / (p + r)
155 }
156 }
157}
158
159#[derive(Debug, Clone)]
161pub struct TestReport {
162 pub suite_name: String,
164
165 pub results: Vec<TestCaseResult>,
167
168 pub per_skill: std::collections::HashMap<SkillName, SkillTestReport>,
170
171 pub total: usize,
173
174 pub passed: usize,
176
177 pub avg_latency_ms: f32,
179}
180
181impl TestReport {
182 pub fn accuracy(&self) -> f32 {
184 if self.total == 0 {
185 0.0
186 } else {
187 self.passed as f32 / self.total as f32
188 }
189 }
190}
191
192pub struct TriggerTestHarness {
194 pub runs_per_case: usize,
196}
197
198impl Default for TriggerTestHarness {
199 fn default() -> Self {
200 Self::new()
201 }
202}
203
204impl TriggerTestHarness {
205 pub fn new() -> Self {
207 Self { runs_per_case: 1 }
208 }
209
210 pub fn with_runs(mut self, runs: usize) -> Self {
212 self.runs_per_case = runs;
213 self
214 }
215
216 pub async fn run(
218 &self,
219 suite: &TestSuite,
220 selector: &CascadeSelector,
221 registry: &SkillRegistry,
222 ) -> Result<TestReport, LearnError> {
223 let mut results = Vec::new();
224 let mut per_skill: std::collections::HashMap<SkillName, SkillTestReport> =
225 std::collections::HashMap::new();
226
227 for case in &suite.cases {
228 let start = std::time::Instant::now();
229
230 let outcome = selector.select(&case.input, registry, &case.context).await?;
232
233 let latency = start.elapsed();
234 let selected = outcome.selected.first().map(|r| r.skill.clone());
235 let score = outcome.selected.first().map(|r| r.score);
236
237 let passed = case.expected.matches(selected.as_ref());
238
239 let expected_skills = match &case.expected {
241 TestExpectation::Single(s) => vec![s.clone()],
242 TestExpectation::AnyOf(list) => list.clone(),
243 TestExpectation::None => vec![],
244 };
245
246 for skill in &expected_skills {
247 let report = per_skill.entry(skill.clone()).or_default();
248 report.total += 1;
249
250 if selected.as_ref() == Some(skill) {
251 report.correct += 1;
252 } else {
253 report.false_negatives += 1;
254 }
255 }
256
257 if let Some(ref sel) = selected {
258 if !expected_skills.contains(sel) {
259 let report = per_skill.entry(sel.clone()).or_default();
260 report.false_positives += 1;
261 }
262 }
263
264 results.push(TestCaseResult {
265 name: case.name.clone(),
266 passed,
267 selected,
268 expected: case.expected.clone(),
269 score,
270 latency_ms: latency.as_millis() as u64,
271 });
272 }
273
274 let passed = results.iter().filter(|r| r.passed).count();
275 let total = results.len();
276 let avg_latency = if total > 0 {
277 results.iter().map(|r| r.latency_ms as f32).sum::<f32>() / total as f32
278 } else {
279 0.0
280 };
281
282 Ok(TestReport {
283 suite_name: suite.name.clone(),
284 results,
285 per_skill,
286 total,
287 passed,
288 avg_latency_ms: avg_latency,
289 })
290 }
291}
292
293#[cfg(test)]
294mod tests {
295 use super::*;
296
297 #[test]
298 fn test_expectation_single_match() {
299 let exp = TestExpectation::Single(SkillName::new("test").unwrap());
300 assert!(exp.matches(Some(&SkillName::new("test").unwrap())));
301 assert!(!exp.matches(Some(&SkillName::new("other").unwrap())));
302 assert!(!exp.matches(None));
303 }
304
305 #[test]
306 fn test_expectation_any_of() {
307 let exp = TestExpectation::AnyOf(vec![
308 SkillName::new("a").unwrap(),
309 SkillName::new("b").unwrap(),
310 ]);
311 assert!(exp.matches(Some(&SkillName::new("a").unwrap())));
312 assert!(exp.matches(Some(&SkillName::new("b").unwrap())));
313 assert!(!exp.matches(Some(&SkillName::new("c").unwrap())));
314 }
315
316 #[test]
317 fn test_expectation_none() {
318 let exp = TestExpectation::None;
319 assert!(exp.matches(None));
320 assert!(!exp.matches(Some(&SkillName::new("test").unwrap())));
321 }
322
323 #[test]
324 fn test_skill_report_metrics() {
325 let report = SkillTestReport {
326 total: 10,
327 correct: 8,
328 false_positives: 2,
329 false_negatives: 2,
330 };
331
332 assert!((report.precision() - 0.8).abs() < 1e-5);
334
335 assert!((report.recall() - 0.8).abs() < 1e-5);
337
338 assert!((report.f1() - 0.8).abs() < 1e-5);
340 }
341
342 #[test]
343 fn test_test_suite_new() {
344 let suite = TestSuite::new("my-suite");
345 assert_eq!(suite.name, "my-suite");
346 assert!(suite.cases.is_empty());
347 }
348}