1use crate::compile::{Bundle, CheckKind, PlannedCheck, RunContext, compile};
8use crate::model::{CaseIndex, ContentId, GameDate, NodeId, Start};
9use crate::plan::{Isolation, Plan};
10use crate::source::SourceSpan;
11use serde::{Deserialize, Serialize};
12use std::collections::BTreeMap;
13
14#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
15#[serde(tag = "exit", rename_all = "snake_case")]
16pub enum RunnerExit {
17 Success,
18 Failed { code: Option<i32> },
19 TimedOut { seconds: u64 },
20 LaunchFailed { message: String },
21 Cancelled,
22}
23
24#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
27pub struct Timing {
28 pub wall_ms: u64,
29 pub launch_ms: Option<u64>,
30 pub in_game_ms: Option<u64>,
31 pub teardown_ms: Option<u64>,
32}
33
34#[derive(Clone, Debug)]
36pub struct RunArtifacts<'a> {
37 pub game_log: Option<&'a str>,
38 pub error_log: Option<&'a str>,
39 pub exit: RunnerExit,
40 pub timing: Timing,
41 pub profile_untouched: bool,
43}
44
45#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
46#[serde(tag = "status", rename_all = "snake_case")]
47#[non_exhaustive]
48pub enum Status {
49 Passed,
50 Smoke,
52 XFailed,
53 XPassed {
54 strict: bool,
55 },
56 Skipped,
57 Ignored,
58 Failed,
59 SetupError,
61 Incomplete,
62 ProtocolError,
63 RuntimeError,
64}
65
66impl Status {
67 pub fn is_success(&self) -> bool {
68 matches!(
69 self,
70 Self::Passed
71 | Self::Smoke
72 | Self::XFailed
73 | Self::XPassed { strict: false }
74 | Self::Skipped
75 | Self::Ignored
76 )
77 }
78}
79
80#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
81pub struct CheckFailure {
82 pub check: String,
83 pub span: Option<SourceSpan>,
84 pub message: String,
85}
86
87#[derive(Clone, Debug, Serialize, Deserialize)]
88pub struct CaseResult {
89 pub index: CaseIndex,
90 pub node: NodeId,
91 pub content_id: ContentId,
92 pub origin: SourceSpan,
93 pub start: Start,
95 #[serde(flatten)]
96 pub status: Status,
97 pub failures: Vec<CheckFailure>,
98 pub passed_checks: Vec<String>,
99 pub missing_checks: Vec<String>,
100 pub session: String,
102 pub timing: Timing,
104 pub isolation: Isolation,
105 pub notes: Vec<String>,
106}
107
108pub fn judge(bundle: &Bundle, plan: &Plan, artifacts: &RunArtifacts) -> Vec<CaseResult> {
109 let session = plan.sessions.get(bundle.session.0);
110 let isolation = session.map_or(Isolation::Dedicated, |session| session.isolation);
111 let mut runtime = Vec::new();
112 match &artifacts.exit {
113 RunnerExit::Success => {}
114 RunnerExit::Failed { code } => {
115 runtime.push(format!("runner exited unsuccessfully ({code:?})"))
116 }
117 RunnerExit::TimedOut { seconds } => {
118 runtime.push(format!("runner timed out after {seconds} seconds"))
119 }
120 RunnerExit::LaunchFailed { message } => {
121 runtime.push(format!("cannot launch runner: {message}"))
122 }
123 RunnerExit::Cancelled => runtime.push("run was cancelled".into()),
124 }
125 let mut info = Vec::new();
130 match artifacts.error_log {
131 None => runtime.push("runner must produce an isolated error.log".into()),
132 Some(log) => {
133 let analysis = analyze_error_log(&bundle.namespace, &bundle.test_mod_name, log);
134 if !analysis.attributed.is_empty() {
135 runtime.push(analysis.attributed_summary());
136 }
137 if analysis.ambient > 0 {
138 info.push(analysis.ambient_summary());
139 }
140 }
141 }
142 if artifacts.game_log.is_none() {
143 runtime.push("runner must produce game.log".into());
144 }
145 if !artifacts.profile_untouched {
146 runtime.push("the real game profile changed during the run; isolation was violated".into());
147 }
148
149 let context = RunContext {
152 run_id: bundle.run_id.clone(),
153 mod_name: bundle.mod_name.clone(),
154 };
155 let manifest = match compile(plan, bundle.session, &context) {
156 Ok(expected) if &expected == bundle => Ok(()),
157 Ok(_) => Err("bundle differs from the plan; unsupported or altered manifest".to_string()),
158 Err(error) => Err(format!("plan no longer compiles: {error}")),
159 };
160 let records = match (&manifest, artifacts.game_log) {
161 (Err(message), _) => Err(message.clone()),
162 (Ok(()), Some(log)) => parse_log(bundle, log),
163 (Ok(()), None) => Ok(BTreeMap::new()),
164 };
165
166 bundle
167 .cases
168 .iter()
169 .map(|&index| {
170 let planned = plan.case(index);
171 let case = &planned.case;
172 let mut result = CaseResult {
173 index,
174 node: case.node.clone(),
175 content_id: case.content_id.clone(),
176 origin: case.origin.clone(),
177 start: case.start.clone(),
178 status: Status::RuntimeError,
179 failures: Vec::new(),
180 passed_checks: Vec::new(),
181 missing_checks: Vec::new(),
182 session: bundle.marker_prefix.clone(),
183 timing: artifacts.timing,
184 isolation,
185 notes: runtime.clone(),
186 };
187 result.notes.extend(info.iter().cloned());
188 let records = match &records {
189 Ok(records) => records.get(&index).cloned().unwrap_or_default(),
190 Err(message) => {
191 result.status = if runtime.is_empty() {
192 Status::ProtocolError
193 } else {
194 Status::RuntimeError
195 };
196 result.notes.push(message.clone());
197 return result;
198 }
199 };
200 let checks: Vec<&PlannedCheck> = bundle
201 .checks
202 .iter()
203 .filter(|check| check.case == index)
204 .collect();
205 let window = bundle.windows.iter().find(|window| window.case == index);
206 let verdict = window
207 .ok_or_else(|| "case has no window in the bundle".to_string())
208 .and_then(|window| evaluate(&checks, window.begin, window.end, &records));
209 if !runtime.is_empty() {
210 result.status = Status::RuntimeError;
211 if let Ok(verdict) = verdict {
212 result.passed_checks = verdict.passed;
213 result.failures = verdict.failed;
214 }
215 return result;
216 }
217 let verdict = match verdict {
218 Ok(verdict) => verdict,
219 Err(message) => {
220 result.status = Status::ProtocolError;
221 result.notes.push(message);
222 return result;
223 }
224 };
225 let setup_failed = verdict
226 .failed
227 .iter()
228 .any(|failure| !failure.check.starts_with("expect."));
229 let expect_failed = verdict
230 .failed
231 .iter()
232 .any(|failure| failure.check.starts_with("expect."));
233 let complete = verdict.missing.is_empty();
234 let xfail = case.marks.xfail.as_ref();
235 result.status = if setup_failed {
236 Status::SetupError
237 } else if expect_failed {
238 match (xfail, complete) {
239 (Some(_), true) => Status::XFailed,
240 (Some(_), false) => Status::Incomplete,
241 (None, _) => Status::Failed,
242 }
243 } else if !complete {
244 Status::Incomplete
245 } else if let Some(xfail) = xfail {
246 Status::XPassed {
247 strict: xfail.strict,
248 }
249 } else if case.is_smoke() {
250 Status::Smoke
251 } else {
252 Status::Passed
253 };
254 result.passed_checks = verdict.passed;
255 result.failures = verdict.failed;
256 result.missing_checks = verdict.missing;
257 result
258 })
259 .collect()
260}
261
262pub fn reconcile(shared: &CaseResult, isolated: CaseResult) -> CaseResult {
266 let mut result = isolated;
267 if result.status.is_success() && !shared.status.is_success() {
268 result.notes.push(format!(
269 "failed as {:?} in a shared session but passed alone; cases interfered, so the grouping analysis is wrong for this case",
270 shared.status
271 ));
272 } else {
273 result
274 .notes
275 .push("failure in a shared session confirmed by an isolated re-run".into());
276 }
277 result
278}
279
280#[derive(Clone, Debug, Default)]
283struct ErrorAnalysis {
284 attributed: Vec<String>,
286 ambient: usize,
288 ambient_sample: Vec<String>,
290}
291
292impl ErrorAnalysis {
293 fn attributed_summary(&self) -> String {
294 format!(
295 "engine error.log reports {} error(s) in the generated test layer:\n\t{}",
296 self.attributed.len(),
297 self.attributed.join("\n\t")
298 )
299 }
300
301 fn ambient_summary(&self) -> String {
302 let plural = if self.ambient == 1 {
303 "entry"
304 } else {
305 "entries"
306 };
307 let mut summary = format!(
308 "engine error.log has {} {plural} unrelated to the test layer; not failing this run",
309 self.ambient
310 );
311 if !self.ambient_sample.is_empty() {
312 summary.push_str(":\n\t");
313 summary.push_str(&self.ambient_sample.join("\n\t"));
314 if self.ambient > self.ambient_sample.len() {
315 summary.push_str("\n\t...");
316 }
317 }
318 summary
319 }
320}
321
322fn analyze_error_log(namespace: &str, test_mod_name: &str, log: &str) -> ErrorAnalysis {
327 let mut analysis = ErrorAnalysis::default();
328 for entry in error_entries(log) {
329 if entry.contains(namespace) || entry.contains(test_mod_name) {
330 analysis.attributed.push(entry);
331 } else {
332 analysis.ambient += 1;
333 if analysis.ambient_sample.len() < 3 {
334 analysis.ambient_sample.push(entry);
335 }
336 }
337 }
338 analysis
339}
340
341fn error_entries(log: &str) -> Vec<String> {
345 let mut entries: Vec<String> = Vec::new();
346 for line in log.lines() {
347 let line = line.trim();
348 if line.is_empty() {
349 continue;
350 }
351 if line.starts_with('[') || entries.is_empty() {
352 entries.push(line.to_string());
353 } else {
354 let entry = entries.last_mut().expect("entries is non-empty");
355 entry.push(' ');
356 entry.push_str(line);
357 }
358 }
359 entries
360}
361
362#[derive(Clone, Debug)]
363struct Record {
364 date: GameDate,
365 kind: RecordKind,
366}
367
368#[derive(Clone, Debug, PartialEq)]
369enum RecordKind {
370 Begin,
371 End,
372 Pass(String),
373 Fail(String),
374}
375
376fn parse_log(bundle: &Bundle, log: &str) -> Result<BTreeMap<CaseIndex, Vec<Record>>, String> {
379 let mut records: BTreeMap<CaseIndex, Vec<Record>> = BTreeMap::new();
380 for (number, line) in log.lines().enumerate() {
381 if !line.contains("FOCH_TEST_") {
382 continue;
383 }
384 let context = |message: &str| format!("game.log line {}: {message}", number + 1);
385 let (_, event) = line
386 .split_once("EVENT [")
387 .ok_or_else(|| context("test marker is not an engine EVENT record"))?;
388 let (date, payload) = event
389 .split_once("]:")
390 .ok_or_else(|| context("malformed EVENT date"))?;
391 let date = GameDate::parse(date).map_err(|_| context("invalid EVENT date"))?;
392 let rest = payload
393 .trim()
394 .strip_prefix(bundle.marker_prefix.as_str())
395 .and_then(|rest| rest.strip_prefix(" c"))
396 .ok_or_else(|| context("foreign run identity or unsupported protocol"))?;
397 let (case, record) = rest
398 .split_once(' ')
399 .ok_or_else(|| context("malformed record"))?;
400 let case = CaseIndex(case.parse().map_err(|_| context("malformed case index"))?);
401 if !bundle.cases.contains(&case) {
402 return Err(context("record for a case outside this session"));
403 }
404 let kind = match record
405 .split_ascii_whitespace()
406 .collect::<Vec<_>>()
407 .as_slice()
408 {
409 ["BEGIN"] => RecordKind::Begin,
410 ["END"] => RecordKind::End,
411 ["PASS", check] => RecordKind::Pass((*check).into()),
412 ["FAIL", check] => RecordKind::Fail((*check).into()),
413 _ => return Err(context("malformed or unknown test record")),
414 };
415 records.entry(case).or_default().push(Record { date, kind });
416 }
417 Ok(records)
418}
419
420struct Verdict {
421 passed: Vec<String>,
422 failed: Vec<CheckFailure>,
423 missing: Vec<String>,
424}
425
426fn evaluate(
429 checks: &[&PlannedCheck],
430 begin: GameDate,
431 end: GameDate,
432 records: &[Record],
433) -> Result<Verdict, String> {
434 let mut began = false;
435 let mut ended = false;
436 let mut seen: BTreeMap<String, bool> = BTreeMap::new();
437 let mut last = None;
438 for record in records {
439 if ended {
440 return Err("record appears after END".into());
441 }
442 match &record.kind {
443 RecordKind::Begin => {
444 if began {
445 return Err("duplicate BEGIN".into());
446 }
447 expect_date(record.date, begin, "BEGIN")?;
448 began = true;
449 }
450 RecordKind::End => {
451 if !began {
452 return Err("END before BEGIN".into());
453 }
454 expect_date(record.date, end, "END")?;
455 ended = true;
456 }
457 RecordKind::Pass(label) | RecordKind::Fail(label) => {
458 if !began {
459 return Err("check before BEGIN".into());
460 }
461 let position = checks
462 .iter()
463 .position(|check| &check.kind.label() == label)
464 .ok_or_else(|| format!("unknown check {label}"))?;
465 if seen.contains_key(label) {
466 return Err(format!("duplicate result for {label}"));
467 }
468 if last.is_some_and(|previous| position < previous) {
469 return Err(format!("out-of-order check {label}"));
470 }
471 expect_date(record.date, checks[position].date, label)?;
472 last = Some(position);
473 seen.insert(label.clone(), matches!(record.kind, RecordKind::Pass(_)));
474 }
475 }
476 }
477 let mut verdict = Verdict {
478 passed: Vec::new(),
479 failed: Vec::new(),
480 missing: Vec::new(),
481 };
482 if !began {
483 verdict.missing.push("BEGIN".into());
484 }
485 for check in checks {
486 let label = check.kind.label();
487 match seen.get(&label) {
488 Some(true) => verdict.passed.push(label),
489 Some(false) => verdict.failed.push(CheckFailure {
490 message: match &check.kind {
491 CheckKind::Scope => "the case did not run in its declared country".into(),
492 CheckKind::Fixture { fixture, .. } => {
493 format!("fixture {fixture} precondition is false")
494 }
495 CheckKind::Expect { .. } => "condition is false".into(),
496 CheckKind::Invoked { .. } => "event invocation failed".into(),
497 },
498 check: label,
499 span: Some(check.span.clone()),
500 }),
501 None => verdict.missing.push(label),
502 }
503 }
504 if !ended {
505 verdict.missing.push("END".into());
506 }
507 Ok(verdict)
508}
509
510fn expect_date(actual: GameDate, expected: GameDate, stage: &str) -> Result<(), String> {
511 if actual == expected {
512 Ok(())
513 } else {
514 Err(format!(
515 "{stage} engine date is {actual}; expected {expected}"
516 ))
517 }
518}
519
520#[cfg(test)]
521mod tests {
522 use super::*;
523
524 #[test]
525 fn error_entries_group_continuation_lines() {
526 let log = "\
527[gui.cpp:1]: missing sprite\n\
528[events.cpp:2]: unexpected token\n\tin foch_test_mod_abc.1\n\
529\n\
530[save.cpp:3]: warning";
531 assert_eq!(
532 error_entries(log),
533 [
534 "[gui.cpp:1]: missing sprite",
535 "[events.cpp:2]: unexpected token in foch_test_mod_abc.1",
536 "[save.cpp:3]: warning",
537 ]
538 );
539 }
540
541 #[test]
542 fn only_test_layer_entries_are_attributed() {
543 let namespace = "foch_test_mymod_abc123";
544 let test_mod = "My Mod tests [abc123]";
545 let log = format!(
546 "[pdx_d3d9]: device lost\n\
547 [localization.cpp:9]: missing key KEY_X\n\
548 [events.cpp:2]: invalid effect in {namespace}.txt\n\
549 [mods.cpp:4]: could not load mod \"{test_mod}\"\n\
550 [map.cpp:7]: province 1 has no owner",
551 );
552 let analysis = analyze_error_log(namespace, test_mod, &log);
553 assert_eq!(analysis.attributed.len(), 2);
554 assert!(analysis.attributed[0].contains(namespace));
555 assert_eq!(analysis.ambient, 3);
556 assert_eq!(analysis.ambient_sample.len(), 3);
557 assert!(
558 analysis
559 .attributed_summary()
560 .contains("generated test layer")
561 );
562 assert!(analysis.ambient_summary().contains("not failing this run"));
563 }
564
565 #[test]
566 fn ambient_only_log_attributes_nothing() {
567 let analysis = analyze_error_log(
568 "foch_test_x_000000",
569 "X tests [000000]",
570 "[a.cpp:1]: one\n[b.cpp:2]: two",
571 );
572 assert!(analysis.attributed.is_empty());
573 assert_eq!(analysis.ambient, 2);
574 }
575}