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 schemars::JsonSchema;
8use serde::{Deserialize, Serialize};
9
10use crate::error::{Error, Result};
11use crate::eval::Eval;
12use crate::mock::MockDecl;
13
14/// The simulated-user block that turns a single-turn case into a multi-turn one.
15/// When present, after each assistant turn the runner asks the provider to play
16/// the user (guided by `persona`) until `done_when` holds or `max_turns` is hit.
17#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
18#[serde(deny_unknown_fields)]
19pub struct SimulatedUser {
20    /// Instructions describing how the simulated user should behave.
21    pub persona: String,
22    /// A plain-English condition; when the judge decides it holds, the
23    /// conversation ends. Optional — without it the run ends at `max_turns` or
24    /// when the skill reports itself done.
25    #[serde(default, skip_serializing_if = "Option::is_none")]
26    pub done_when: Option<String>,
27    /// Per-case override of the global assistant-turn cap.
28    #[serde(default, skip_serializing_if = "Option::is_none")]
29    pub max_turns: Option<u32>,
30}
31
32/// One test case.
33///
34/// This type is the source of truth for the **input contract**
35/// (`schemas/case.schema.json`): the shape a `--case-json` payload — and the
36/// SDKs' generated case models — must have. Serialization skips
37/// absent/default fields so the canonical JSON form is minimal; the SDK case
38/// builders emit that same form (pinned by the kitchen-sink golden in
39/// `tests/fixtures/contract/`).
40#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
41#[serde(deny_unknown_fields)]
42pub struct TestCase {
43    /// Human-readable name (defaults to the file stem when loaded from a file).
44    #[serde(default, skip_serializing_if = "String::is_empty")]
45    pub name: String,
46    /// Path to the skill directory under test, relative to the test-case file.
47    pub skill: PathBuf,
48    /// The initial data/prompt handed to the skill as the first user message.
49    pub input: String,
50    /// Present for multi-turn cases; absent for single-turn.
51    #[serde(default, skip_serializing_if = "Option::is_none")]
52    pub user: Option<SimulatedUser>,
53    /// Mock/spy declarations for this case: a declaration with a `stub`/`deny`/
54    /// `rewrite` action intercepts matching tool calls; one without observes
55    /// only. `called`/`not_called` evals reference these by `name`.
56    #[serde(default, skip_serializing_if = "Vec::is_empty")]
57    pub mocks: Vec<MockDecl>,
58    /// Record every tool call through the mock/spy channel even with no
59    /// `mocks` declared, so code-level consumers (the SDKs' spies) get records.
60    /// Implied whenever `mocks` is non-empty.
61    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
62    pub spy: bool,
63    /// The evals that decide whether this case passes. Must be non-empty.
64    pub evals: Vec<Eval>,
65}
66
67impl TestCase {
68    /// Load a test case from a YAML file. The `name` defaults to the file stem
69    /// and `skill` is resolved relative to the file's directory.
70    ///
71    /// # Errors
72    /// [`Error::Io`] if the file cannot be read, [`Error::Yaml`] on parse
73    /// failure, and [`Error::Invalid`] if the case is internally inconsistent.
74    pub fn load(path: &Path) -> Result<Self> {
75        let text = std::fs::read_to_string(path).map_err(|source| Error::Io {
76            path: path.to_path_buf(),
77            source,
78        })?;
79        let mut case: TestCase = serde_yaml::from_str(&text).map_err(|source| Error::Yaml {
80            path: path.to_path_buf(),
81            source,
82        })?;
83        let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("case");
84        let base = path.parent().unwrap_or_else(|| Path::new(""));
85        case.finalize(base, stem)?;
86        Ok(case)
87    }
88
89    /// Parse one or more fully-specified test cases from JSON — a single case
90    /// object or an array — the shape the language SDKs emit when a case is
91    /// built in code rather than written as YAML (`skilltest run --case-json`).
92    ///
93    /// Unlike [`load`](Self::load) these cases have no source file, so each is
94    /// finalized against `base_dir` (typically the working directory): a
95    /// relative `skill` resolves there, and an unnamed case defaults to `case`
96    /// (suffixed with its index when several are unnamed, keeping report keys
97    /// distinct).
98    ///
99    /// # Errors
100    /// [`Error::Invalid`] if the JSON does not parse into test cases, or if any
101    /// case is internally inconsistent.
102    pub fn from_json(json: &str, base_dir: &Path) -> Result<Vec<Self>> {
103        // Accept either a bare object (one case) or an array of them, so a
104        // single code-defined case need not be wrapped.
105        let value: serde_json::Value = serde_json::from_str(json)
106            .map_err(|e| Error::Invalid(format!("invalid --case-json: {e}")))?;
107        let mut cases: Vec<TestCase> = match value {
108            serde_json::Value::Array(_) => serde_json::from_value(value),
109            _ => serde_json::from_value(value).map(|c| vec![c]),
110        }
111        .map_err(|e| Error::Invalid(format!("invalid --case-json: {e}")))?;
112        for (index, case) in cases.iter_mut().enumerate() {
113            let default_name = if index == 0 {
114                "case".to_string()
115            } else {
116                format!("case-{}", index + 1)
117            };
118            case.finalize(base_dir, &default_name)?;
119        }
120        Ok(cases)
121    }
122
123    /// Finalize an in-memory case: default `name` to `default_name` when unset,
124    /// resolve a relative `skill` against `base_dir`, and validate. Shared by
125    /// [`load`](Self::load) (anchored at the file's directory) and
126    /// [`from_json`](Self::from_json) (anchored at the working directory, since
127    /// a code-defined case has no file).
128    ///
129    /// # Errors
130    /// [`Error::Invalid`] if the case is internally inconsistent.
131    pub fn finalize(&mut self, base_dir: &Path, default_name: &str) -> Result<()> {
132        if self.name.is_empty() {
133            self.name = default_name.to_string();
134        }
135        if self.skill.is_relative() {
136            self.skill = base_dir.join(&self.skill);
137        }
138        self.validate()
139    }
140
141    /// Whether this is a multi-turn case (has a simulated user).
142    #[must_use]
143    pub fn is_multi_turn(&self) -> bool {
144        self.user.is_some()
145    }
146
147    /// Validate the case's structure and every eval.
148    ///
149    /// # Errors
150    /// [`Error::Invalid`] when input/evals are empty or an eval is malformed.
151    pub fn validate(&self) -> Result<()> {
152        if self.input.trim().is_empty() {
153            return Err(Error::Invalid(format!(
154                "test case `{}` has an empty `input`",
155                self.name
156            )));
157        }
158        if self.evals.is_empty() {
159            return Err(Error::Invalid(format!(
160                "test case `{}` defines no `evals`",
161                self.name
162            )));
163        }
164        for eval in &self.evals {
165            eval.validate()?;
166        }
167        for (i, decl) in self.mocks.iter().enumerate() {
168            let label = decl.name.clone().unwrap_or_else(|| format!("#{i}"));
169            decl.validate(&format!("test case `{}`, mock `{label}`", self.name))?;
170        }
171        if let Some(user) = &self.user {
172            if user.persona.trim().is_empty() {
173                return Err(Error::Invalid(format!(
174                    "test case `{}` has a `user` block with an empty `persona`",
175                    self.name
176                )));
177            }
178            if user.max_turns == Some(0) {
179                return Err(Error::Invalid(format!(
180                    "test case `{}` sets `user.max_turns` to 0",
181                    self.name
182                )));
183            }
184        }
185        Ok(())
186    }
187}
188
189/// Discover test-case files: either a single `.yaml`/`.yml` file or every such
190/// file directly inside a directory (sorted for deterministic ordering).
191///
192/// # Errors
193/// [`Error::Io`] if a directory cannot be read, [`Error::Invalid`] if the path
194/// matches nothing usable.
195pub fn discover_cases(path: &Path) -> Result<Vec<PathBuf>> {
196    if path.is_file() {
197        return Ok(vec![path.to_path_buf()]);
198    }
199    if path.is_dir() {
200        let entries = std::fs::read_dir(path).map_err(|source| Error::Io {
201            path: path.to_path_buf(),
202            source,
203        })?;
204        let mut files: Vec<PathBuf> = entries
205            .filter_map(std::result::Result::ok)
206            .map(|e| e.path())
207            .filter(|p| {
208                p.is_file()
209                    && matches!(p.extension().and_then(|s| s.to_str()), Some("yaml" | "yml"))
210            })
211            .collect();
212        files.sort();
213        if files.is_empty() {
214            return Err(Error::Invalid(format!(
215                "no .yaml test cases found in {}",
216                path.display()
217            )));
218        }
219        return Ok(files);
220    }
221    Err(Error::Invalid(format!(
222        "path does not exist: {}",
223        path.display()
224    )))
225}
226
227#[cfg(test)]
228mod tests {
229    use super::*;
230    use crate::eval::{BooleanEval, Eval};
231
232    #[test]
233    fn parses_single_turn_case() {
234        let yaml = r#"
235skill: ./greeter
236input: "Greet Dr. Smith"
237evals:
238  - type: boolean
239    criterion: "greets Dr. Smith by name"
240"#;
241        let case: TestCase = serde_yaml::from_str(yaml).unwrap();
242        assert!(!case.is_multi_turn());
243        assert_eq!(case.evals.len(), 1);
244        assert!(matches!(case.evals[0], Eval::Boolean(BooleanEval { .. })));
245    }
246
247    #[test]
248    fn parses_multi_turn_case() {
249        let yaml = r#"
250name: booking
251skill: ./booker
252input: "I want to book an appointment"
253user:
254  persona: "You are a terse patient."
255  done_when: "the assistant has confirmed a booking"
256  max_turns: 5
257evals:
258  - type: numeric
259    criterion: "how clearly was the appointment confirmed"
260    min: 0
261    max: 10
262    threshold: 7
263"#;
264        let case: TestCase = serde_yaml::from_str(yaml).unwrap();
265        assert!(case.is_multi_turn());
266        assert_eq!(case.user.as_ref().unwrap().max_turns, Some(5));
267        case.validate().unwrap();
268    }
269
270    #[test]
271    fn empty_evals_is_invalid() {
272        let yaml = "skill: ./x\ninput: hi\nevals: []\n";
273        let case: TestCase = serde_yaml::from_str(yaml).unwrap();
274        assert!(case.validate().is_err());
275    }
276
277    #[test]
278    fn unknown_field_is_rejected() {
279        let yaml = "skill: ./x\ninput: hi\nbogus: 1\nevals: []\n";
280        assert!(serde_yaml::from_str::<TestCase>(yaml).is_err());
281    }
282
283    #[test]
284    fn unknown_eval_field_is_rejected_not_silently_ignored() {
285        // A typo'd eval key (`expcted`) must be a loud parse error naming the
286        // field — never a silently-applied default, which could invert the
287        // eval's intent into a vacuous pass.
288        let yaml = "skill: ./x\ninput: hi\nevals:\n  - type: boolean\n    criterion: c\n    expcted: false\n";
289        let err = serde_yaml::from_str::<TestCase>(yaml).unwrap_err();
290        assert!(
291            err.to_string().contains("expcted"),
292            "error names the unknown field: {err}"
293        );
294        // Same strictness through the JSON path the SDKs use.
295        let json =
296            r#"{"skill":"./x","input":"hi","evals":[{"type":"called","mock":"m","tmies":1}]}"#;
297        let err = TestCase::from_json(json, Path::new(".")).unwrap_err();
298        assert!(err.to_string().contains("tmies"), "{err}");
299    }
300
301    #[test]
302    fn unknown_user_field_is_rejected() {
303        let yaml = "skill: ./x\ninput: hi\nuser:\n  persona: p\n  don_when: x\nevals:\n  - type: boolean\n    criterion: c\n";
304        let err = serde_yaml::from_str::<TestCase>(yaml).unwrap_err();
305        assert!(err.to_string().contains("don_when"), "{err}");
306    }
307
308    /// Write `yaml` into a unique temp dir as `name`, returning the file path.
309    fn case_file(tag: &str, name: &str, yaml: &str) -> PathBuf {
310        use std::sync::atomic::{AtomicU64, Ordering};
311        static N: AtomicU64 = AtomicU64::new(0);
312        let dir = std::env::temp_dir().join(format!(
313            "skilltest-case-{}-{tag}-{}",
314            std::process::id(),
315            N.fetch_add(1, Ordering::Relaxed)
316        ));
317        std::fs::create_dir_all(&dir).unwrap();
318        let path = dir.join(name);
319        std::fs::write(&path, yaml).unwrap();
320        path
321    }
322
323    #[test]
324    fn load_defaults_name_from_stem_and_resolves_skill_path() {
325        let path = case_file(
326            "load",
327            "greet_pass.yaml",
328            "skill: ./greeter\ninput: \"hi\"\nevals:\n  - type: boolean\n    criterion: \"greets\"\n",
329        );
330        let case = TestCase::load(&path).unwrap();
331        assert_eq!(case.name, "greet_pass");
332        // The relative skill path is resolved against the case file's directory.
333        assert!(case.skill.is_absolute());
334        assert!(case.skill.ends_with("greeter"));
335    }
336
337    #[test]
338    fn load_keeps_explicit_name() {
339        let path = case_file(
340            "named",
341            "x.yaml",
342            "name: custom\nskill: ./s\ninput: hi\nevals:\n  - type: boolean\n    criterion: c\n",
343        );
344        assert_eq!(TestCase::load(&path).unwrap().name, "custom");
345    }
346
347    #[test]
348    fn load_missing_file_is_io_error() {
349        let path =
350            std::env::temp_dir().join(format!("skilltest-nocase-{}.yaml", std::process::id()));
351        assert!(matches!(TestCase::load(&path), Err(Error::Io { .. })));
352    }
353
354    #[test]
355    fn load_malformed_yaml_is_yaml_error() {
356        let path = case_file("bad", "bad.yaml", "input: [unterminated\n");
357        assert!(matches!(TestCase::load(&path), Err(Error::Yaml { .. })));
358    }
359
360    #[test]
361    fn load_inconsistent_case_is_invalid_error() {
362        // Parses, but an empty input fails validation.
363        let path = case_file(
364            "blank",
365            "blank.yaml",
366            "skill: ./s\ninput: \"\"\nevals:\n  - type: boolean\n    criterion: c\n",
367        );
368        assert!(matches!(TestCase::load(&path), Err(Error::Invalid(_))));
369    }
370
371    #[test]
372    fn validate_rejects_empty_input_and_evals() {
373        let mut case = TestCase {
374            name: "t".into(),
375            skill: PathBuf::from("./s"),
376            input: "   ".into(),
377            user: None,
378            mocks: Vec::new(),
379            spy: false,
380            evals: vec![Eval::Boolean(BooleanEval {
381                criterion: "c".into(),
382                expected: true,
383                name: None,
384            })],
385        };
386        assert!(case.validate().is_err(), "blank input");
387        case.input = "ok".into();
388        case.evals.clear();
389        assert!(case.validate().is_err(), "no evals");
390    }
391
392    #[test]
393    fn validate_rejects_blank_persona_and_zero_user_max_turns() {
394        let base = TestCase {
395            name: "t".into(),
396            skill: PathBuf::from("./s"),
397            input: "ok".into(),
398            user: None,
399            mocks: Vec::new(),
400            spy: false,
401            evals: vec![Eval::Boolean(BooleanEval {
402                criterion: "c".into(),
403                expected: true,
404                name: None,
405            })],
406        };
407        let mut blank_persona = base.clone();
408        blank_persona.user = Some(SimulatedUser {
409            persona: "  ".into(),
410            done_when: None,
411            max_turns: None,
412        });
413        assert!(blank_persona.validate().is_err());
414
415        let mut zero_turns = base;
416        zero_turns.user = Some(SimulatedUser {
417            persona: "a patient".into(),
418            done_when: None,
419            max_turns: Some(0),
420        });
421        assert!(zero_turns.validate().is_err());
422    }
423
424    #[test]
425    fn from_json_parses_a_single_object_and_resolves_skill() {
426        let json = r#"{"skill":"./greeter","input":"hi","evals":[{"type":"boolean","criterion":"greets"}]}"#;
427        let base = Path::new("/work/cases");
428        let cases = TestCase::from_json(json, base).unwrap();
429        assert_eq!(cases.len(), 1);
430        assert_eq!(cases[0].name, "case");
431        // A relative skill resolves against the supplied base directory.
432        assert_eq!(cases[0].skill, PathBuf::from("/work/cases/greeter"));
433    }
434
435    #[test]
436    fn from_json_parses_an_array_and_indexes_unnamed_cases() {
437        let json = r#"[
438            {"skill":"/abs/a","input":"hi","evals":[{"type":"boolean","criterion":"c"}]},
439            {"name":"named","skill":"/abs/b","input":"hi","evals":[{"type":"boolean","criterion":"c"}]},
440            {"skill":"/abs/c","input":"hi","evals":[{"type":"boolean","criterion":"c"}]}
441        ]"#;
442        let cases = TestCase::from_json(json, Path::new("/work")).unwrap();
443        assert_eq!(
444            cases.iter().map(|c| c.name.as_str()).collect::<Vec<_>>(),
445            ["case", "named", "case-3"]
446        );
447        // An absolute skill is left untouched.
448        assert_eq!(cases[0].skill, PathBuf::from("/abs/a"));
449    }
450
451    #[test]
452    fn from_json_rejects_malformed_and_inconsistent_cases() {
453        // Not JSON at all.
454        assert!(matches!(
455            TestCase::from_json("{not json", Path::new(".")),
456            Err(Error::Invalid(_))
457        ));
458        // An unknown field is rejected (the case type denies them).
459        let unknown = r#"{"skill":"./x","input":"hi","bogus":1,"evals":[{"type":"boolean","criterion":"c"}]}"#;
460        assert!(matches!(
461            TestCase::from_json(unknown, Path::new(".")),
462            Err(Error::Invalid(_))
463        ));
464        // Parses, but validation fails (no evals).
465        let empty_evals = r#"{"skill":"./x","input":"hi","evals":[]}"#;
466        assert!(matches!(
467            TestCase::from_json(empty_evals, Path::new(".")),
468            Err(Error::Invalid(_))
469        ));
470    }
471
472    #[test]
473    fn discover_cases_returns_single_file() {
474        let path = case_file("one", "only.yaml", "skill: ./s\ninput: hi\nevals: []\n");
475        let found = discover_cases(&path).unwrap();
476        assert_eq!(found, vec![path]);
477    }
478
479    #[test]
480    fn discover_cases_lists_yaml_in_a_directory_sorted() {
481        let first = case_file("dir", "b.yaml", "skill: ./s\ninput: hi\nevals: []\n");
482        let dir = first.parent().unwrap().to_path_buf();
483        std::fs::write(dir.join("a.yml"), "skill: ./s\ninput: hi\nevals: []\n").unwrap();
484        // A non-YAML file is ignored.
485        std::fs::write(dir.join("notes.txt"), "ignore me").unwrap();
486        let found = discover_cases(&dir).unwrap();
487        assert_eq!(found.len(), 2);
488        // Sorted: a.yml before b.yaml.
489        assert!(found[0].ends_with("a.yml"));
490        assert!(found[1].ends_with("b.yaml"));
491    }
492
493    #[test]
494    fn discover_cases_empty_directory_is_invalid() {
495        let dir = std::env::temp_dir().join(format!("skilltest-emptycases-{}", std::process::id()));
496        std::fs::create_dir_all(&dir).unwrap();
497        assert!(matches!(discover_cases(&dir), Err(Error::Invalid(_))));
498    }
499
500    #[test]
501    fn discover_cases_missing_path_is_invalid() {
502        let path = std::env::temp_dir().join(format!("skilltest-nopath-{}", std::process::id()));
503        assert!(matches!(discover_cases(&path), Err(Error::Invalid(_))));
504    }
505}