Skip to main content

foch_test/
report.rs

1//! Summaries and machine-readable output of a completed run.
2
3use crate::judge::{CaseResult, Status};
4use crate::model::RunId;
5use crate::plan::{NotRun, NotRunReason};
6use serde::{Deserialize, Serialize};
7use std::collections::BTreeSet;
8use std::fmt::Write as _;
9
10/// What the run used, recorded so a result can be reproduced.
11#[derive(Clone, Debug, Default, Serialize, Deserialize)]
12pub struct EnvironmentRecord {
13	pub game_version: Option<String>,
14	pub dlc: Vec<String>,
15	pub mods: Vec<String>,
16	pub runner: Option<String>,
17}
18
19#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
20pub struct Summary {
21	pub passed: usize,
22	pub smoke: usize,
23	pub xfailed: usize,
24	pub xpassed: usize,
25	pub failed: usize,
26	pub setup_error: usize,
27	pub incomplete: usize,
28	pub protocol_error: usize,
29	pub runtime_error: usize,
30	pub skipped: usize,
31	pub ignored: usize,
32	pub deselected: usize,
33	/// Sum of distinct session wall times.
34	pub wall_ms: u64,
35}
36
37#[derive(Clone, Debug, Serialize, Deserialize)]
38pub struct Report {
39	pub run_id: RunId,
40	pub results: Vec<CaseResult>,
41	pub not_run: Vec<NotRun>,
42	pub environment: EnvironmentRecord,
43}
44
45impl Report {
46	pub fn new(
47		run_id: RunId,
48		results: Vec<CaseResult>,
49		not_run: Vec<NotRun>,
50		environment: EnvironmentRecord,
51	) -> Self {
52		Self {
53			run_id,
54			results,
55			not_run,
56			environment,
57		}
58	}
59
60	pub fn summary(&self) -> Summary {
61		let mut summary = Summary::default();
62		for result in &self.results {
63			let counter = match result.status {
64				Status::Passed => &mut summary.passed,
65				Status::Smoke => &mut summary.smoke,
66				Status::XFailed => &mut summary.xfailed,
67				Status::XPassed { .. } => &mut summary.xpassed,
68				Status::Failed => &mut summary.failed,
69				Status::SetupError => &mut summary.setup_error,
70				Status::Incomplete => &mut summary.incomplete,
71				Status::ProtocolError => &mut summary.protocol_error,
72				Status::RuntimeError => &mut summary.runtime_error,
73				Status::Skipped => &mut summary.skipped,
74				Status::Ignored => &mut summary.ignored,
75			};
76			*counter += 1;
77		}
78		for not_run in &self.not_run {
79			match not_run.reason {
80				NotRunReason::Deselected => summary.deselected += 1,
81				NotRunReason::Skipped { .. } => summary.skipped += 1,
82				NotRunReason::Ignored { .. } => summary.ignored += 1,
83			}
84		}
85		// Cases of one shared session report the same session timing once.
86		let mut sessions = BTreeSet::new();
87		for result in &self.results {
88			if sessions.insert(result.session.as_str()) {
89				summary.wall_ms += result.timing.wall_ms;
90			}
91		}
92		summary
93	}
94
95	pub fn slowest(&self, count: usize) -> Vec<&CaseResult> {
96		let mut results: Vec<_> = self.results.iter().collect();
97		results.sort_by_key(|result| std::cmp::Reverse(result.timing.wall_ms));
98		results.truncate(count);
99		results
100	}
101
102	/// Node IDs to select with `--last-failed` next time.
103	pub fn failed_nodes(&self) -> BTreeSet<String> {
104		self.results
105			.iter()
106			.filter(|result| !result.status.is_success())
107			.map(|result| result.node.to_string())
108			.collect()
109	}
110
111	/// 0 when something ran or was explicitly skipped and nothing failed.
112	pub fn exit_code(&self) -> i32 {
113		let summary = self.summary();
114		let anything = !self.results.is_empty() || summary.skipped + summary.ignored > 0;
115		i32::from(
116			!anything
117				|| self
118					.results
119					.iter()
120					.any(|result| !result.status.is_success()),
121		)
122	}
123
124	pub fn to_json(&self) -> String {
125		serde_json::to_string_pretty(self).expect("reports serialize")
126	}
127
128	/// JUnit XML as understood by common CI services.
129	pub fn to_junit(&self) -> String {
130		let summary = self.summary();
131		let failures = summary.failed
132			+ self
133				.results
134				.iter()
135				.filter(|result| result.status == Status::XPassed { strict: true })
136				.count();
137		let errors = summary.setup_error
138			+ summary.incomplete
139			+ summary.protocol_error
140			+ summary.runtime_error;
141		let skipped = summary.skipped + summary.ignored;
142		let tests = self.results.len() + skipped;
143		let mut xml = String::from("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
144		let _ = writeln!(
145			xml,
146			"<testsuites><testsuite name=\"foch\" tests=\"{tests}\" failures=\"{failures}\" errors=\"{errors}\" skipped=\"{skipped}\" time=\"{:.3}\">",
147			summary.wall_ms as f64 / 1000.0
148		);
149		for result in &self.results {
150			let _ = write!(
151				xml,
152				"<testcase classname=\"{}\" name=\"{}\" time=\"{:.3}\">",
153				escape(result.node.path.as_str()),
154				escape(&result.node.local()),
155				result.timing.wall_ms as f64 / 1000.0
156			);
157			let detail: Vec<String> = result
158				.failures
159				.iter()
160				.map(|failure| match &failure.span {
161					Some(span) => format!("{span} {}: {}", failure.check, failure.message),
162					None => format!("{}: {}", failure.check, failure.message),
163				})
164				.chain(result.notes.iter().cloned())
165				.collect();
166			let detail = escape(&detail.join("\n"));
167			match result.status {
168				Status::Failed | Status::XPassed { strict: true } => {
169					let _ = write!(
170						xml,
171						"<failure message=\"{:?}\">{detail}</failure>",
172						result.status
173					);
174				}
175				Status::SetupError
176				| Status::Incomplete
177				| Status::ProtocolError
178				| Status::RuntimeError => {
179					let _ = write!(
180						xml,
181						"<error message=\"{:?}\">{detail}</error>",
182						result.status
183					);
184				}
185				_ => {}
186			}
187			xml.push_str("</testcase>\n");
188		}
189		for not_run in &self.not_run {
190			let message = match &not_run.reason {
191				NotRunReason::Deselected => continue,
192				NotRunReason::Skipped { message } | NotRunReason::Ignored { message } => {
193					message.clone().unwrap_or_default()
194				}
195			};
196			let _ = writeln!(
197				xml,
198				"<testcase classname=\"{}\" name=\"{}\"><skipped message=\"{}\"/></testcase>",
199				escape(not_run.node.path.as_str()),
200				escape(&not_run.node.local()),
201				escape(&message)
202			);
203		}
204		xml.push_str("</testsuite></testsuites>\n");
205		xml
206	}
207}
208
209fn escape(text: &str) -> String {
210	text.replace('&', "&amp;")
211		.replace('<', "&lt;")
212		.replace('>', "&gt;")
213		.replace('"', "&quot;")
214}