1use 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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
17pub struct SimulatedUser {
18 pub persona: String,
20 #[serde(default)]
24 pub done_when: Option<String>,
25 #[serde(default)]
27 pub max_turns: Option<u32>,
28}
29
30#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
32#[serde(deny_unknown_fields)]
33pub struct TestCase {
34 #[serde(default)]
36 pub name: String,
37 pub skill: PathBuf,
39 pub input: String,
41 #[serde(default)]
43 pub user: Option<SimulatedUser>,
44 #[serde(default)]
48 pub mocks: Vec<MockDecl>,
49 #[serde(default)]
53 pub spy: bool,
54 pub evals: Vec<Eval>,
56}
57
58impl TestCase {
59 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 #[must_use]
92 pub fn is_multi_turn(&self) -> bool {
93 self.user.is_some()
94 }
95
96 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
138pub 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 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 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 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 std::fs::write(dir.join("notes.txt"), "ignore me").unwrap();
362 let found = discover_cases(&dir).unwrap();
363 assert_eq!(found.len(), 2);
364 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}