Skip to main content

foch_test/
plan.rs

1//! From collected cases to game sessions.
2//!
3//! [`expand`] selects cases and resolves their fixtures. After [`crate::lint`]
4//! has estimated what each case touches, [`group`] decides which cases share
5//! a game session. Sharing is only an optimization: a case's verdict must not
6//! depend on it, so any doubt keeps the case alone.
7
8use crate::collect::Collection;
9use crate::diagnostic::{Diagnostic, DiagnosticCode};
10use crate::lint::LintResult;
11use crate::model::{
12	AiMode, CaseIndex, EventInfo, Fixture, FixtureName, GameDate, HelperFile, NodeId,
13	SessionRequest, Tag, TestCase,
14};
15use crate::select::Selector;
16use crate::source::SourceSpan;
17use serde::{Deserialize, Serialize};
18use std::collections::{BTreeMap, BTreeSet};
19
20#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
21#[serde(rename_all = "snake_case")]
22pub enum IgnoredMode {
23	/// Rust's default: `#ignore` cases do not run.
24	#[default]
25	Exclude,
26	/// `--include-ignored`.
27	Include,
28	/// `--ignored`: run only `#ignore` cases.
29	Only,
30}
31
32#[derive(Clone, Debug, Serialize, Deserialize)]
33pub struct ExpandOptions {
34	pub selector: Selector,
35	pub ignored: IgnoredMode,
36	/// Upper bound of cases that would run; each costs a game launch or more.
37	pub max_cases: usize,
38}
39
40impl Default for ExpandOptions {
41	fn default() -> Self {
42		Self {
43			selector: Selector::default(),
44			ignored: IgnoredMode::default(),
45			max_cases: 200,
46		}
47	}
48}
49
50#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
51#[serde(tag = "reason", rename_all = "snake_case")]
52pub enum NotRunReason {
53	Deselected,
54	Skipped { message: Option<String> },
55	Ignored { message: Option<String> },
56}
57
58#[derive(Clone, Debug, Serialize, Deserialize)]
59pub struct NotRun {
60	pub node: NodeId,
61	pub origin: SourceSpan,
62	pub reason: NotRunReason,
63}
64
65/// A selected case with its fixtures in execution order: dependencies first,
66/// each fixture once.
67#[derive(Clone, Debug, Serialize, Deserialize)]
68pub struct PlannedCase {
69	pub index: CaseIndex,
70	pub case: TestCase,
71	pub fixtures: Vec<Fixture>,
72}
73
74#[derive(Clone, Debug, Serialize, Deserialize)]
75pub struct Expanded {
76	pub cases: Vec<PlannedCase>,
77	pub not_run: Vec<NotRun>,
78	pub events: Vec<EventInfo>,
79	pub helpers: Vec<HelperFile>,
80}
81
82pub fn expand(
83	collection: &Collection,
84	options: &ExpandOptions,
85) -> Result<Expanded, Vec<Diagnostic>> {
86	let fixtures: BTreeMap<&FixtureName, &Fixture> = collection
87		.fixtures
88		.iter()
89		.map(|fixture| (&fixture.name, fixture))
90		.collect();
91	let mut diagnostics = Vec::new();
92	for fixture in &collection.fixtures {
93		if let Err(diagnostic) = resolve(
94			&fixture.uses,
95			&fixtures,
96			&[&fixture.name],
97			Some(&fixture.origin),
98		) {
99			diagnostics.push(diagnostic);
100		}
101	}
102	let mut cases = Vec::new();
103	let mut not_run = Vec::new();
104	for case in &collection.cases {
105		let ignored = case.marks.ignore.is_some();
106		let reason = if !options.selector.matches(case)
107			|| (options.ignored == IgnoredMode::Only && !ignored)
108		{
109			Some(NotRunReason::Deselected)
110		} else if let Some(skip) = &case.marks.skip {
111			Some(NotRunReason::Skipped {
112				message: skip.reason.clone(),
113			})
114		} else if ignored && options.ignored == IgnoredMode::Exclude {
115			Some(NotRunReason::Ignored {
116				message: case
117					.marks
118					.ignore
119					.as_ref()
120					.and_then(|marker| marker.reason.clone()),
121			})
122		} else {
123			None
124		};
125		if let Some(reason) = reason {
126			not_run.push(NotRun {
127				node: case.node.clone(),
128				origin: case.origin.clone(),
129				reason,
130			});
131			continue;
132		}
133		match resolve(&case.uses, &fixtures, &[], Some(&case.origin)) {
134			Ok(resolved) => cases.push(PlannedCase {
135				index: CaseIndex(cases.len()),
136				case: case.clone(),
137				fixtures: resolved,
138			}),
139			Err(diagnostic) => diagnostics.push(diagnostic),
140		}
141	}
142	if cases.len() > options.max_cases {
143		diagnostics.push(Diagnostic::error(
144			DiagnosticCode::TooManyCases,
145			None,
146			format!(
147				"{} cases selected, more than the budget of {}; narrow the selection or raise the budget",
148				cases.len(),
149				options.max_cases
150			),
151		));
152	}
153	if !diagnostics.is_empty() {
154		return Err(diagnostics);
155	}
156	Ok(Expanded {
157		cases,
158		not_run,
159		events: collection.events.clone(),
160		helpers: collection.helpers.clone(),
161	})
162}
163
164/// Depth-first resolution: dependencies before dependents, each once.
165fn resolve(
166	uses: &[FixtureName],
167	fixtures: &BTreeMap<&FixtureName, &Fixture>,
168	stack: &[&FixtureName],
169	origin: Option<&SourceSpan>,
170) -> Result<Vec<Fixture>, Diagnostic> {
171	fn visit<'a>(
172		name: &'a FixtureName,
173		fixtures: &BTreeMap<&'a FixtureName, &'a Fixture>,
174		stack: &mut Vec<&'a FixtureName>,
175		done: &mut Vec<Fixture>,
176		origin: Option<&SourceSpan>,
177	) -> Result<(), Diagnostic> {
178		if done.iter().any(|fixture| &fixture.name == name) {
179			return Ok(());
180		}
181		if stack.contains(&name) {
182			let cycle: Vec<_> = stack
183				.iter()
184				.map(ToString::to_string)
185				.chain([name.to_string()])
186				.collect();
187			return Err(Diagnostic::error(
188				DiagnosticCode::FixtureCycle,
189				origin.cloned(),
190				format!("fixture dependency cycle: {}", cycle.join(" -> ")),
191			));
192		}
193		let fixture = fixtures.get(name).ok_or_else(|| {
194			Diagnostic::error(
195				DiagnosticCode::UnknownFixture,
196				origin.cloned(),
197				format!("unknown fixture {name}"),
198			)
199		})?;
200		stack.push(name);
201		for dependency in &fixture.uses {
202			visit(dependency, fixtures, stack, done, Some(&fixture.origin))?;
203		}
204		stack.pop();
205		done.push((*fixture).clone());
206		Ok(())
207	}
208	let mut stack = stack.to_vec();
209	let mut done = Vec::new();
210	for name in uses {
211		visit(name, fixtures, &mut stack, &mut done, origin)?;
212	}
213	Ok(done)
214}
215
216#[derive(Clone, Debug, Serialize, Deserialize)]
217pub struct GroupOptions {
218	/// Project default for cases that do not declare `session`.
219	pub default_session: SessionRequest,
220	/// `--isolate`: every case runs alone.
221	pub force_isolate: bool,
222	/// Whether the runner declares support for several cases in one session.
223	pub runner_shared_sessions: bool,
224	pub max_session_cases: usize,
225}
226
227impl Default for GroupOptions {
228	fn default() -> Self {
229		Self {
230			default_session: SessionRequest::Auto,
231			force_isolate: false,
232			runner_shared_sessions: false,
233			max_session_cases: 32,
234		}
235	}
236}
237
238#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize)]
239#[serde(transparent)]
240pub struct SessionId(pub usize);
241
242#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
243#[serde(rename_all = "snake_case")]
244pub enum Isolation {
245	Dedicated,
246	Shared,
247}
248
249/// One game launch.
250#[derive(Clone, Debug, Serialize, Deserialize)]
251pub struct Session {
252	pub id: SessionId,
253	pub date: GameDate,
254	pub ai: AiMode,
255	/// The country the game starts as. Other cases in a shared session run in
256	/// computer-controlled countries.
257	pub player: Tag,
258	pub cases: Vec<CaseIndex>,
259	pub isolation: Isolation,
260}
261
262/// Why a case did not share a session.
263#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
264#[serde(tag = "reason", rename_all = "snake_case")]
265pub enum AloneReason {
266	UserDeclared,
267	ProjectDefault,
268	ForcedByCli,
269	RunnerUnsupported,
270	AiEnabled,
271	RequiresPlayer,
272	/// Touches state Foch cannot bound, such as scope changes.
273	UnknownEffect {
274		detail: String,
275	},
276	/// Touches global state such as global flags.
277	GlobalState,
278	SameTag {
279		other: CaseIndex,
280	},
281	Overlap {
282		other: CaseIndex,
283	},
284	/// No other case could share with it.
285	OnlyCandidate,
286	/// Re-run alone after failing in a shared session.
287	IsolatedRerun,
288}
289
290#[derive(Clone, Debug, Serialize, Deserialize)]
291pub struct Plan {
292	pub cases: Vec<PlannedCase>,
293	pub not_run: Vec<NotRun>,
294	pub sessions: Vec<Session>,
295	pub alone_reasons: BTreeMap<CaseIndex, AloneReason>,
296	pub events: Vec<EventInfo>,
297	pub helpers: Vec<HelperFile>,
298}
299
300impl Plan {
301	pub fn case(&self, index: CaseIndex) -> &PlannedCase {
302		&self.cases[index.0]
303	}
304
305	pub fn session(&self, id: SessionId) -> &Session {
306		&self.sessions[id.0]
307	}
308
309	/// A plan that runs the given cases again, each in its own session. Used
310	/// to confirm a failure seen in a shared session.
311	pub fn isolated_rerun(&self, cases: &[CaseIndex]) -> Plan {
312		let mut sessions = Vec::new();
313		let mut alone_reasons = BTreeMap::new();
314		for &index in cases {
315			let case = &self.case(index).case;
316			alone_reasons.insert(index, AloneReason::IsolatedRerun);
317			sessions.push(Session {
318				id: SessionId(sessions.len()),
319				date: case.start.date,
320				ai: case.ai,
321				player: case.start.tag.clone(),
322				cases: vec![index],
323				isolation: Isolation::Dedicated,
324			});
325		}
326		Plan {
327			cases: self.cases.clone(),
328			not_run: Vec::new(),
329			sessions,
330			alone_reasons,
331			events: self.events.clone(),
332			helpers: self.helpers.clone(),
333		}
334	}
335}
336
337pub fn group(expanded: Expanded, lint: &LintResult, options: &GroupOptions) -> Plan {
338	let mut alone_reasons = BTreeMap::new();
339	// Candidate shared groups keyed by what one launch fixes.
340	let mut groups: Vec<(GameDate, Vec<CaseIndex>, BTreeSet<Tag>)> = Vec::new();
341	let mut conflicts: BTreeMap<CaseIndex, AloneReason> = BTreeMap::new();
342	for planned in &expanded.cases {
343		let case = &planned.case;
344		let index = planned.index;
345		let footprint = lint.footprints.get(&index);
346		let reason = if case.marks.session == Some(SessionRequest::Alone) {
347			Some(AloneReason::UserDeclared)
348		} else if options.force_isolate {
349			Some(AloneReason::ForcedByCli)
350		} else if options.default_session == SessionRequest::Alone && case.marks.session.is_none() {
351			Some(AloneReason::ProjectDefault)
352		} else if !options.runner_shared_sessions {
353			Some(AloneReason::RunnerUnsupported)
354		} else if case.ai == AiMode::On {
355			Some(AloneReason::AiEnabled)
356		} else if case.marks.requires_player {
357			Some(AloneReason::RequiresPlayer)
358		} else {
359			match footprint {
360				None => Some(AloneReason::UnknownEffect {
361					detail: "no static analysis result".into(),
362				}),
363				Some(footprint) if footprint.opaque.is_some() => Some(AloneReason::UnknownEffect {
364					detail: footprint.opaque.clone().unwrap_or_default(),
365				}),
366				Some(footprint) if footprint.global => Some(AloneReason::GlobalState),
367				Some(_) => None,
368			}
369		};
370		if let Some(reason) = reason {
371			alone_reasons.insert(index, reason);
372			continue;
373		}
374		let tags = &footprint.expect("checked above").tags;
375		let mut placed = false;
376		for (date, members, used) in &mut groups {
377			if *date != case.start.date || members.len() >= options.max_session_cases {
378				continue;
379			}
380			if let Some(&other) = members
381				.iter()
382				.find(|&&other| expanded.cases[other.0].case.start.tag == case.start.tag)
383			{
384				conflicts
385					.entry(index)
386					.or_insert(AloneReason::SameTag { other });
387				continue;
388			}
389			if !used.is_disjoint(tags) {
390				let other = members
391					.iter()
392					.copied()
393					.find(|other| !lint.footprints[other].tags.is_disjoint(tags))
394					.unwrap_or(members[0]);
395				conflicts
396					.entry(index)
397					.or_insert(AloneReason::Overlap { other });
398				continue;
399			}
400			members.push(index);
401			used.extend(tags.iter().cloned());
402			placed = true;
403			break;
404		}
405		if !placed {
406			groups.push((case.start.date, vec![index], tags.clone()));
407		}
408	}
409
410	let mut sessions = Vec::new();
411	let mut shared_members = BTreeSet::new();
412	for (date, members, _) in &groups {
413		if members.len() > 1 {
414			shared_members.extend(members.iter().copied());
415			sessions.push(Session {
416				id: SessionId(sessions.len()),
417				date: *date,
418				ai: AiMode::Off,
419				player: expanded.cases[members[0].0].case.start.tag.clone(),
420				cases: members.clone(),
421				isolation: Isolation::Shared,
422			});
423		}
424	}
425	for planned in &expanded.cases {
426		let index = planned.index;
427		if shared_members.contains(&index) {
428			continue;
429		}
430		alone_reasons.entry(index).or_insert_with(|| {
431			conflicts
432				.remove(&index)
433				.unwrap_or(AloneReason::OnlyCandidate)
434		});
435		sessions.push(Session {
436			id: SessionId(sessions.len()),
437			date: planned.case.start.date,
438			ai: planned.case.ai,
439			player: planned.case.start.tag.clone(),
440			cases: vec![index],
441			isolation: Isolation::Dedicated,
442		});
443	}
444	Plan {
445		cases: expanded.cases,
446		not_run: expanded.not_run,
447		sessions,
448		alone_reasons,
449		events: expanded.events,
450		helpers: expanded.helpers,
451	}
452}