1use 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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
18#[serde(deny_unknown_fields)]
19pub struct SimulatedUser {
20 pub persona: String,
22 #[serde(default, skip_serializing_if = "Option::is_none")]
26 pub done_when: Option<String>,
27 #[serde(default, skip_serializing_if = "Option::is_none")]
29 pub max_turns: Option<u32>,
30}
31
32#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
41#[serde(deny_unknown_fields)]
42pub struct TestCase {
43 #[serde(default, skip_serializing_if = "String::is_empty")]
45 pub name: String,
46 pub skill: PathBuf,
48 pub input: String,
50 #[serde(default, skip_serializing_if = "Option::is_none")]
52 pub user: Option<SimulatedUser>,
53 #[serde(default, skip_serializing_if = "Vec::is_empty")]
57 pub mocks: Vec<MockDecl>,
58 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
62 pub spy: bool,
63 pub evals: Vec<Eval>,
65}
66
67impl TestCase {
68 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 pub fn from_json(json: &str, base_dir: &Path) -> Result<Vec<Self>> {
103 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 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 #[must_use]
143 pub fn is_multi_turn(&self) -> bool {
144 self.user.is_some()
145 }
146
147 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
189pub 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 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 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 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 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 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 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 assert_eq!(cases[0].skill, PathBuf::from("/abs/a"));
449 }
450
451 #[test]
452 fn from_json_rejects_malformed_and_inconsistent_cases() {
453 assert!(matches!(
455 TestCase::from_json("{not json", Path::new(".")),
456 Err(Error::Invalid(_))
457 ));
458 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 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 std::fs::write(dir.join("notes.txt"), "ignore me").unwrap();
486 let found = discover_cases(&dir).unwrap();
487 assert_eq!(found.len(), 2);
488 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}