Skip to main content

skilltest_core/
testcase.rs

1//! Test cases: the YAML a user writes to describe one test of a skill — the
2//! initial data to hand the skill, an optional simulated user for multi-turn
3//! runs, and the evals that decide pass/fail.
4
5use std::path::{Path, PathBuf};
6
7use serde::{Deserialize, Serialize};
8
9use crate::error::{Error, Result};
10use crate::eval::Eval;
11use crate::mock::MockDecl;
12
13/// The simulated-user block that turns a single-turn case into a multi-turn one.
14/// When present, after each assistant turn the runner asks the provider to play
15/// the user (guided by `persona`) until `done_when` holds or `max_turns` is hit.
16#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
17pub struct SimulatedUser {
18    /// Instructions describing how the simulated user should behave.
19    pub persona: String,
20    /// A plain-English condition; when the judge decides it holds, the
21    /// conversation ends. Optional — without it the run ends at `max_turns` or
22    /// when the skill reports itself done.
23    #[serde(default)]
24    pub done_when: Option<String>,
25    /// Per-case override of the global assistant-turn cap.
26    #[serde(default)]
27    pub max_turns: Option<u32>,
28}
29
30/// One test case.
31#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
32#[serde(deny_unknown_fields)]
33pub struct TestCase {
34    /// Human-readable name (defaults to the file stem when loaded from a file).
35    #[serde(default)]
36    pub name: String,
37    /// Path to the skill directory under test, relative to the test-case file.
38    pub skill: PathBuf,
39    /// The initial data/prompt handed to the skill as the first user message.
40    pub input: String,
41    /// Present for multi-turn cases; absent for single-turn.
42    #[serde(default)]
43    pub user: Option<SimulatedUser>,
44    /// Mock/spy declarations for this case: a declaration with a `stub`/`deny`/
45    /// `rewrite` action intercepts matching tool calls; one without observes
46    /// only. `called`/`not_called` evals reference these by `name`.
47    #[serde(default)]
48    pub mocks: Vec<MockDecl>,
49    /// Record every tool call through the mock/spy channel even with no
50    /// `mocks` declared, so code-level consumers (the SDKs' spies) get records.
51    /// Implied whenever `mocks` is non-empty.
52    #[serde(default)]
53    pub spy: bool,
54    /// The evals that decide whether this case passes. Must be non-empty.
55    pub evals: Vec<Eval>,
56}
57
58impl TestCase {
59    /// Load a test case from a YAML file. The `name` defaults to the file stem
60    /// and `skill` is resolved relative to the file's directory.
61    ///
62    /// # Errors
63    /// [`Error::Io`] if the file cannot be read, [`Error::Yaml`] on parse
64    /// failure, and [`Error::Invalid`] if the case is internally inconsistent.
65    pub fn load(path: &Path) -> Result<Self> {
66        let text = std::fs::read_to_string(path).map_err(|source| Error::Io {
67            path: path.to_path_buf(),
68            source,
69        })?;
70        let mut case: TestCase = serde_yaml::from_str(&text).map_err(|source| Error::Yaml {
71            path: path.to_path_buf(),
72            source,
73        })?;
74        if case.name.is_empty() {
75            case.name = path
76                .file_stem()
77                .and_then(|s| s.to_str())
78                .unwrap_or("case")
79                .to_string();
80        }
81        if let Some(parent) = path.parent() {
82            if case.skill.is_relative() {
83                case.skill = parent.join(&case.skill);
84            }
85        }
86        case.validate()?;
87        Ok(case)
88    }
89
90    /// Whether this is a multi-turn case (has a simulated user).
91    #[must_use]
92    pub fn is_multi_turn(&self) -> bool {
93        self.user.is_some()
94    }
95
96    /// Validate the case's structure and every eval.
97    ///
98    /// # Errors
99    /// [`Error::Invalid`] when input/evals are empty or an eval is malformed.
100    pub fn validate(&self) -> Result<()> {
101        if self.input.trim().is_empty() {
102            return Err(Error::Invalid(format!(
103                "test case `{}` has an empty `input`",
104                self.name
105            )));
106        }
107        if self.evals.is_empty() {
108            return Err(Error::Invalid(format!(
109                "test case `{}` defines no `evals`",
110                self.name
111            )));
112        }
113        for eval in &self.evals {
114            eval.validate()?;
115        }
116        for (i, decl) in self.mocks.iter().enumerate() {
117            let label = decl.name.clone().unwrap_or_else(|| format!("#{i}"));
118            decl.validate(&format!("test case `{}`, mock `{label}`", self.name))?;
119        }
120        if let Some(user) = &self.user {
121            if user.persona.trim().is_empty() {
122                return Err(Error::Invalid(format!(
123                    "test case `{}` has a `user` block with an empty `persona`",
124                    self.name
125                )));
126            }
127            if user.max_turns == Some(0) {
128                return Err(Error::Invalid(format!(
129                    "test case `{}` sets `user.max_turns` to 0",
130                    self.name
131                )));
132            }
133        }
134        Ok(())
135    }
136}
137
138/// Discover test-case files: either a single `.yaml`/`.yml` file or every such
139/// file directly inside a directory (sorted for deterministic ordering).
140///
141/// # Errors
142/// [`Error::Io`] if a directory cannot be read, [`Error::Invalid`] if the path
143/// matches nothing usable.
144pub fn discover_cases(path: &Path) -> Result<Vec<PathBuf>> {
145    if path.is_file() {
146        return Ok(vec![path.to_path_buf()]);
147    }
148    if path.is_dir() {
149        let entries = std::fs::read_dir(path).map_err(|source| Error::Io {
150            path: path.to_path_buf(),
151            source,
152        })?;
153        let mut files: Vec<PathBuf> = entries
154            .filter_map(std::result::Result::ok)
155            .map(|e| e.path())
156            .filter(|p| {
157                p.is_file()
158                    && matches!(p.extension().and_then(|s| s.to_str()), Some("yaml" | "yml"))
159            })
160            .collect();
161        files.sort();
162        if files.is_empty() {
163            return Err(Error::Invalid(format!(
164                "no .yaml test cases found in {}",
165                path.display()
166            )));
167        }
168        return Ok(files);
169    }
170    Err(Error::Invalid(format!(
171        "path does not exist: {}",
172        path.display()
173    )))
174}
175
176#[cfg(test)]
177mod tests {
178    use super::*;
179    use crate::eval::Eval;
180
181    #[test]
182    fn parses_single_turn_case() {
183        let yaml = r#"
184skill: ./greeter
185input: "Greet Dr. Smith"
186evals:
187  - type: boolean
188    criterion: "greets Dr. Smith by name"
189"#;
190        let case: TestCase = serde_yaml::from_str(yaml).unwrap();
191        assert!(!case.is_multi_turn());
192        assert_eq!(case.evals.len(), 1);
193        assert!(matches!(case.evals[0], Eval::Boolean { .. }));
194    }
195
196    #[test]
197    fn parses_multi_turn_case() {
198        let yaml = r#"
199name: booking
200skill: ./booker
201input: "I want to book an appointment"
202user:
203  persona: "You are a terse patient."
204  done_when: "the assistant has confirmed a booking"
205  max_turns: 5
206evals:
207  - type: numeric
208    criterion: "how clearly was the appointment confirmed"
209    min: 0
210    max: 10
211    threshold: 7
212"#;
213        let case: TestCase = serde_yaml::from_str(yaml).unwrap();
214        assert!(case.is_multi_turn());
215        assert_eq!(case.user.as_ref().unwrap().max_turns, Some(5));
216        case.validate().unwrap();
217    }
218
219    #[test]
220    fn empty_evals_is_invalid() {
221        let yaml = "skill: ./x\ninput: hi\nevals: []\n";
222        let case: TestCase = serde_yaml::from_str(yaml).unwrap();
223        assert!(case.validate().is_err());
224    }
225
226    #[test]
227    fn unknown_field_is_rejected() {
228        let yaml = "skill: ./x\ninput: hi\nbogus: 1\nevals: []\n";
229        assert!(serde_yaml::from_str::<TestCase>(yaml).is_err());
230    }
231
232    /// Write `yaml` into a unique temp dir as `name`, returning the file path.
233    fn case_file(tag: &str, name: &str, yaml: &str) -> PathBuf {
234        use std::sync::atomic::{AtomicU64, Ordering};
235        static N: AtomicU64 = AtomicU64::new(0);
236        let dir = std::env::temp_dir().join(format!(
237            "skilltest-case-{}-{tag}-{}",
238            std::process::id(),
239            N.fetch_add(1, Ordering::Relaxed)
240        ));
241        std::fs::create_dir_all(&dir).unwrap();
242        let path = dir.join(name);
243        std::fs::write(&path, yaml).unwrap();
244        path
245    }
246
247    #[test]
248    fn load_defaults_name_from_stem_and_resolves_skill_path() {
249        let path = case_file(
250            "load",
251            "greet_pass.yaml",
252            "skill: ./greeter\ninput: \"hi\"\nevals:\n  - type: boolean\n    criterion: \"greets\"\n",
253        );
254        let case = TestCase::load(&path).unwrap();
255        assert_eq!(case.name, "greet_pass");
256        // The relative skill path is resolved against the case file's directory.
257        assert!(case.skill.is_absolute());
258        assert!(case.skill.ends_with("greeter"));
259    }
260
261    #[test]
262    fn load_keeps_explicit_name() {
263        let path = case_file(
264            "named",
265            "x.yaml",
266            "name: custom\nskill: ./s\ninput: hi\nevals:\n  - type: boolean\n    criterion: c\n",
267        );
268        assert_eq!(TestCase::load(&path).unwrap().name, "custom");
269    }
270
271    #[test]
272    fn load_missing_file_is_io_error() {
273        let path =
274            std::env::temp_dir().join(format!("skilltest-nocase-{}.yaml", std::process::id()));
275        assert!(matches!(TestCase::load(&path), Err(Error::Io { .. })));
276    }
277
278    #[test]
279    fn load_malformed_yaml_is_yaml_error() {
280        let path = case_file("bad", "bad.yaml", "input: [unterminated\n");
281        assert!(matches!(TestCase::load(&path), Err(Error::Yaml { .. })));
282    }
283
284    #[test]
285    fn load_inconsistent_case_is_invalid_error() {
286        // Parses, but an empty input fails validation.
287        let path = case_file(
288            "blank",
289            "blank.yaml",
290            "skill: ./s\ninput: \"\"\nevals:\n  - type: boolean\n    criterion: c\n",
291        );
292        assert!(matches!(TestCase::load(&path), Err(Error::Invalid(_))));
293    }
294
295    #[test]
296    fn validate_rejects_empty_input_and_evals() {
297        let mut case = TestCase {
298            name: "t".into(),
299            skill: PathBuf::from("./s"),
300            input: "   ".into(),
301            user: None,
302            mocks: Vec::new(),
303            spy: false,
304            evals: vec![Eval::Boolean {
305                criterion: "c".into(),
306                expected: true,
307                name: None,
308            }],
309        };
310        assert!(case.validate().is_err(), "blank input");
311        case.input = "ok".into();
312        case.evals.clear();
313        assert!(case.validate().is_err(), "no evals");
314    }
315
316    #[test]
317    fn validate_rejects_blank_persona_and_zero_user_max_turns() {
318        let base = TestCase {
319            name: "t".into(),
320            skill: PathBuf::from("./s"),
321            input: "ok".into(),
322            user: None,
323            mocks: Vec::new(),
324            spy: false,
325            evals: vec![Eval::Boolean {
326                criterion: "c".into(),
327                expected: true,
328                name: None,
329            }],
330        };
331        let mut blank_persona = base.clone();
332        blank_persona.user = Some(SimulatedUser {
333            persona: "  ".into(),
334            done_when: None,
335            max_turns: None,
336        });
337        assert!(blank_persona.validate().is_err());
338
339        let mut zero_turns = base;
340        zero_turns.user = Some(SimulatedUser {
341            persona: "a patient".into(),
342            done_when: None,
343            max_turns: Some(0),
344        });
345        assert!(zero_turns.validate().is_err());
346    }
347
348    #[test]
349    fn discover_cases_returns_single_file() {
350        let path = case_file("one", "only.yaml", "skill: ./s\ninput: hi\nevals: []\n");
351        let found = discover_cases(&path).unwrap();
352        assert_eq!(found, vec![path]);
353    }
354
355    #[test]
356    fn discover_cases_lists_yaml_in_a_directory_sorted() {
357        let first = case_file("dir", "b.yaml", "skill: ./s\ninput: hi\nevals: []\n");
358        let dir = first.parent().unwrap().to_path_buf();
359        std::fs::write(dir.join("a.yml"), "skill: ./s\ninput: hi\nevals: []\n").unwrap();
360        // A non-YAML file is ignored.
361        std::fs::write(dir.join("notes.txt"), "ignore me").unwrap();
362        let found = discover_cases(&dir).unwrap();
363        assert_eq!(found.len(), 2);
364        // Sorted: a.yml before b.yaml.
365        assert!(found[0].ends_with("a.yml"));
366        assert!(found[1].ends_with("b.yaml"));
367    }
368
369    #[test]
370    fn discover_cases_empty_directory_is_invalid() {
371        let dir = std::env::temp_dir().join(format!("skilltest-emptycases-{}", std::process::id()));
372        std::fs::create_dir_all(&dir).unwrap();
373        assert!(matches!(discover_cases(&dir), Err(Error::Invalid(_))));
374    }
375
376    #[test]
377    fn discover_cases_missing_path_is_invalid() {
378        let path = std::env::temp_dir().join(format!("skilltest-nopath-{}", std::process::id()));
379        assert!(matches!(discover_cases(&path), Err(Error::Invalid(_))));
380    }
381}