Skip to main content

foch_test/
judge.rs

1//! Turn what a runner produced into one status per case.
2//!
3//! This is the only place that decides whether a case passed. It combines
4//! the engine log with the process outcome, so rules such as "`xfail` only
5//! absorbs assertion failures, never crashes" hold in one place.
6
7use 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/// Wall-clock cost of a session, in milliseconds. The phases are present
25/// only when the runner reports them.
26#[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/// Material the caller collected from one runner execution.
35#[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	/// The user's real game profile was unchanged by the run.
42	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	/// No expectation; only proves that the steps completed.
51	Smoke,
52	XFailed,
53	XPassed {
54		strict: bool,
55	},
56	Skipped,
57	Ignored,
58	Failed,
59	/// A fixture check or the scope check was false.
60	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	/// Start date and country, kept so a report is readable on its own.
94	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	/// Identity of the game session that ran the case.
101	pub session: String,
102	/// Session cost; shared by every case of a shared session.
103	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	// EU4 writes ambient entries to error.log on every launch (missing
126	// localisation, other mods, vanilla warnings), so a non-empty log is not
127	// itself a failure. Only entries attributable to the generated test layer
128	// make the run untrustworthy; the rest are reported but do not fail it.
129	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	// A manifest that differs from what the plan compiles to cannot be
150	// trusted to describe the required evidence.
151	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
262/// Combine a shared-session failure with its isolated re-run. The isolated
263/// result decides the case; a disagreement is reported because it means the
264/// grouping analysis let cases interfere.
265pub 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/// A reading of the engine `error.log`, splitting entries that the generated
281/// test layer caused from the ambient entries every launch produces.
282#[derive(Clone, Debug, Default)]
283struct ErrorAnalysis {
284	/// Entries that name the generated test layer, so the run cannot be trusted.
285	attributed: Vec<String>,
286	/// How many entries were unrelated to the test layer.
287	ambient: usize,
288	/// A few ambient entries, for a readable note.
289	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
322/// Attribute `error.log` entries to the generated test layer or to ambient
323/// engine noise. The namespace prefixes every generated event id and file
324/// name, and the test mod descriptor name is equally unique, so an entry that
325/// quotes either is one the test layer caused.
326fn 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
341/// Split the engine `error.log` into entries. EU4 begins each with a
342/// `[time][source]:` header line; lines that do not start with `[` continue
343/// the entry above them. Blank lines are dropped.
344fn 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
376/// Records by case. Any marker that does not belong to this session's
377/// identity is a protocol error for the whole session.
378fn 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
426/// Strictly checks one case's records: BEGIN first, END last, each check at
427/// most once, in plan order, on its planned engine date.
428fn 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}