Skip to main content

foch_test/
model.rs

1//! The test model shared by every stage.
2//!
3//! Validated values such as [`GameDate`] and [`Tag`] come from
4//! `foch-annotation`, which parses them for annotations and editors alike;
5//! later stages never re-validate them.
6
7use crate::source::{RelPath, SourceSpan};
8pub use foch_annotation::builtin::MAX_ADVANCE_DAYS;
9pub use foch_annotation::value::{Clause, EventId, GameDate, NativeBlock, Tag, identifier};
10use serde::{Deserialize, Serialize};
11use sha2::{Digest, Sha256};
12use std::collections::BTreeSet;
13use std::fmt;
14use std::str::FromStr;
15
16macro_rules! string_newtype {
17	($name:ident, $parse:path) => {
18		impl $name {
19			pub fn as_str(&self) -> &str {
20				&self.0
21			}
22		}
23
24		impl TryFrom<String> for $name {
25			type Error = String;
26
27			fn try_from(value: String) -> Result<Self, String> {
28				$parse(&value)
29			}
30		}
31
32		impl From<$name> for String {
33			fn from(value: $name) -> Self {
34				value.0
35			}
36		}
37
38		impl fmt::Display for $name {
39			fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
40				formatter.write_str(&self.0)
41			}
42		}
43	};
44}
45
46/// Name of a fixture: an ASCII identifier.
47#[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize)]
48#[serde(try_from = "String", into = "String")]
49pub struct FixtureName(String);
50
51impl FixtureName {
52	pub fn parse(value: &str) -> Result<Self, String> {
53		if identifier(value) && value.len() <= 128 {
54			Ok(Self(value.into()))
55		} else {
56			Err(format!(
57				"invalid fixture name {value:?}; expected an identifier"
58			))
59		}
60	}
61}
62string_newtype!(FixtureName, FixtureName::parse);
63
64/// Identifies one execution of the test command. It appears in generated
65/// script and log markers, so it is restricted to a safe alphabet.
66#[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize)]
67#[serde(try_from = "String", into = "String")]
68pub struct RunId(String);
69
70impl RunId {
71	pub fn parse(value: &str) -> Result<Self, String> {
72		if !value.is_empty()
73			&& value.len() <= 64
74			&& value
75				.bytes()
76				.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-'))
77		{
78			Ok(Self(value.into()))
79		} else {
80			Err("run ID must contain 1..=64 ASCII letters, digits, underscores or hyphens".into())
81		}
82	}
83}
84string_newtype!(RunId, RunId::parse);
85
86pub(crate) fn valid_case_name(name: &str) -> Result<(), String> {
87	if name.trim().is_empty()
88		|| name.chars().count() > 128
89		|| name.chars().any(char::is_control)
90		|| name.contains("::")
91		|| name.contains(['[', ']'])
92		|| name.starts_with('#')
93	{
94		Err("test name must contain 1..=128 characters, without control characters, \"::\", brackets or a leading #".into())
95	} else {
96		Ok(())
97	}
98}
99
100pub(crate) fn valid_mod_name(name: &str) -> Result<(), String> {
101	if name.trim().is_empty() || name.contains("::") || name.chars().any(char::is_control) {
102		Err(format!(
103			"invalid mod name {name:?}; it must be nonempty without \"::\" or control characters"
104		))
105	} else {
106		Ok(())
107	}
108}
109
110/// A case's name within its target. Unnamed inline cases use their position
111/// among the annotations on the same event, which shifts when annotations are
112/// added or removed.
113#[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize)]
114#[serde(rename_all = "snake_case")]
115pub enum CaseName {
116	Named(String),
117	Index(u32),
118}
119
120impl fmt::Display for CaseName {
121	fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
122		match self {
123			Self::Named(name) => formatter.write_str(name),
124			Self::Index(index) => write!(formatter, "#{index}"),
125		}
126	}
127}
128
129/// Human-readable address of a case, modeled on pytest node IDs:
130/// `my-mod::events/reforms.txt::reforms.1::grants_flag[tag=DAN]`.
131#[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize)]
132pub struct NodeId {
133	pub mod_name: String,
134	pub path: RelPath,
135	/// The event an inline annotation is attached to; `None` for `tests/` cases.
136	pub target: Option<EventId>,
137	pub name: CaseName,
138	pub params: Vec<(String, String)>,
139}
140
141impl NodeId {
142	/// The address without the mod prefix, as typed inside a single mod.
143	pub fn local(&self) -> String {
144		let mut text = self.path.to_string();
145		if let Some(target) = &self.target {
146			text.push_str("::");
147			text.push_str(target.as_str());
148		}
149		text.push_str("::");
150		text.push_str(&self.name.to_string());
151		if !self.params.is_empty() {
152			let params: Vec<_> = self
153				.params
154				.iter()
155				.map(|(key, value)| format!("{key}={value}"))
156				.collect();
157			text.push('[');
158			text.push_str(&params.join(","));
159			text.push(']');
160		}
161		text
162	}
163}
164
165impl fmt::Display for NodeId {
166	fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
167		write!(formatter, "{}::{}", self.mod_name, self.local())
168	}
169}
170
171impl FromStr for NodeId {
172	type Err = String;
173
174	fn from_str(value: &str) -> Result<Self, String> {
175		let (body, params) = match value.strip_suffix(']') {
176			Some(rest) => {
177				let (body, params) = rest
178					.rsplit_once('[')
179					.ok_or("unbalanced parameter brackets")?;
180				let params = params
181					.split(',')
182					.map(|pair| {
183						pair.split_once('=')
184							.map(|(key, value)| (key.to_string(), value.to_string()))
185							.ok_or_else(|| format!("invalid parameter {pair:?}"))
186					})
187					.collect::<Result<Vec<_>, _>>()?;
188				(body, params)
189			}
190			None => (value, Vec::new()),
191		};
192		let segments: Vec<_> = body.split("::").collect();
193		let (mod_name, path, target, name) = match segments.as_slice() {
194			[mod_name, path, target, name] => (mod_name, path, Some(EventId::parse(target)?), name),
195			[mod_name, path, name] => (mod_name, path, None, name),
196			_ => {
197				return Err(format!(
198					"node ID {value:?} must be mod::path[::event]::name"
199				));
200			}
201		};
202		valid_mod_name(mod_name)?;
203		let name = match name.strip_prefix('#') {
204			Some(index) => CaseName::Index(
205				index
206					.parse()
207					.map_err(|_| format!("invalid case index {name:?}"))?,
208			),
209			None => {
210				valid_case_name(name)?;
211				CaseName::Named((*name).into())
212			}
213		};
214		Ok(Self {
215			mod_name: (*mod_name).into(),
216			path: RelPath::new(path)?,
217			target,
218			name,
219			params,
220		})
221	}
222}
223
224/// Stable identity of a case's executable content: changes when its start,
225/// steps, fixtures or AI mode change, but not when lines merely move or marks
226/// such as `#skip` are added.
227#[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize)]
228#[serde(transparent)]
229pub struct ContentId(pub(crate) String);
230
231impl ContentId {
232	pub fn as_str(&self) -> &str {
233		&self.0
234	}
235}
236
237/// Position of a case in a plan.
238#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize)]
239#[serde(transparent)]
240pub struct CaseIndex(pub usize);
241
242#[derive(
243	Clone, Copy, Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize,
244)]
245#[serde(rename_all = "snake_case")]
246pub enum AiMode {
247	/// Computer-controlled countries act. Only on explicit request, because
248	/// their decisions make results vary between runs.
249	On,
250	/// The default, for deterministic results: computer-controlled countries
251	/// do not act. Requires a runner that declares this capability; what
252	/// remains active (random events, monthly updates) depends on the
253	/// verified runner mechanism.
254	#[default]
255	Off,
256}
257
258#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
259#[serde(rename_all = "snake_case")]
260pub enum SessionRequest {
261	/// May share one game session with compatible cases.
262	#[default]
263	Auto,
264	/// Must run in its own game session.
265	Alone,
266}
267
268#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
269pub struct Start {
270	pub date: GameDate,
271	pub tag: Tag,
272}
273
274#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
275#[serde(tag = "step", rename_all = "snake_case")]
276#[non_exhaustive]
277pub enum Step {
278	Effect { block: NativeBlock },
279	Fire { event: EventId, span: SourceSpan },
280	AdvanceDays { days: u32, span: SourceSpan },
281	Expect { clauses: Vec<Clause> },
282}
283
284#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
285pub struct Marker {
286	pub reason: Option<String>,
287}
288
289#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
290pub struct XFail {
291	pub reason: Option<String>,
292	/// An unexpected pass fails the run.
293	pub strict: bool,
294}
295
296#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
297pub struct Marks {
298	pub skip: Option<Marker>,
299	pub ignore: Option<Marker>,
300	pub xfail: Option<XFail>,
301	pub labels: BTreeSet<String>,
302	pub session: Option<SessionRequest>,
303	/// The case must control the player country, so it cannot share a session.
304	pub requires_player: bool,
305}
306
307#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
308pub struct TestCase {
309	pub node: NodeId,
310	pub content_id: ContentId,
311	pub origin: SourceSpan,
312	pub start: Start,
313	pub ai: AiMode,
314	pub uses: Vec<FixtureName>,
315	pub steps: Vec<Step>,
316	pub marks: Marks,
317}
318
319impl TestCase {
320	/// A case without any expectation only proves that its steps completed.
321	pub fn is_smoke(&self) -> bool {
322		!self
323			.steps
324			.iter()
325			.any(|step| matches!(step, Step::Expect { .. }))
326	}
327
328	pub(crate) fn compute_content_id(&mut self) {
329		#[derive(Serialize)]
330		struct Identity<'a> {
331			node: String,
332			start: &'a Start,
333			ai: AiMode,
334			uses: &'a [FixtureName],
335			steps: Vec<String>,
336		}
337		let steps = self
338			.steps
339			.iter()
340			.map(|step| match step {
341				Step::Effect { block } => format!("effect {}", block.text),
342				Step::Fire { event, .. } => format!("fire {event}"),
343				Step::AdvanceDays { days, .. } => format!("advance {days}"),
344				Step::Expect { clauses } => {
345					let clauses: Vec<_> =
346						clauses.iter().map(|clause| clause.text.as_str()).collect();
347					format!("expect {}", clauses.join("\n"))
348				}
349			})
350			.collect();
351		let identity = Identity {
352			node: self.node.to_string(),
353			start: &self.start,
354			ai: self.ai,
355			uses: &self.uses,
356			steps,
357		};
358		self.content_id = ContentId(digest(&identity));
359	}
360}
361
362/// A named, reusable preparation step, executed in the case's country scope
363/// before the case's own steps. There is no teardown: every session is
364/// discarded after it runs.
365#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
366pub struct Fixture {
367	pub name: FixtureName,
368	pub origin: SourceSpan,
369	pub uses: Vec<FixtureName>,
370	pub effect: Option<NativeBlock>,
371	/// Preconditions after `effect`; a false clause is a setup error, not a
372	/// failure of the tested behavior.
373	pub check: Vec<Clause>,
374}
375
376/// A top-level `country_event` seen while collecting, used to validate `fire`
377/// targets and to estimate what a case touches.
378#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
379pub struct EventInfo {
380	pub id: EventId,
381	pub origin: SourceSpan,
382	/// Only hidden, triggered-only country events can be invoked directly.
383	pub invocable: bool,
384	/// The event block without its outer braces.
385	pub body: String,
386	/// Defined below `tests/events/`, so it only exists in the test layer.
387	pub helper: bool,
388}
389
390/// A helper event file copied into the generated test layer.
391#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
392pub struct HelperFile {
393	pub path: RelPath,
394	pub text: String,
395}
396
397pub(crate) fn digest(value: &impl Serialize) -> String {
398	let bytes = serde_json::to_vec(value).expect("test model values serialize");
399	format!("{:x}", Sha256::digest(bytes))
400}
401
402#[cfg(test)]
403mod tests {
404	use super::*;
405
406	#[test]
407	fn run_ids_reject_script_injection() {
408		assert!(RunId::parse("run-1_a").is_ok());
409		for bad in ["", "bad run", "../x", "x\"", &"x".repeat(65)] {
410			assert!(RunId::parse(bad).is_err(), "{bad:?}");
411		}
412		assert!(FixtureName::parse("at_war").is_ok());
413		assert!(FixtureName::parse("1war").is_err());
414	}
415
416	#[test]
417	fn node_ids_round_trip() {
418		for text in [
419			"My Mod::events/reforms.txt::reforms.1::grants flag",
420			"m::events/a.txt::a.1::#2",
421			"m::tests/war.txt::declare_then_peace",
422			"m::events/a.txt::a.1::union[tag=DAN,time=1500.1.1]",
423		] {
424			let id: NodeId = text.parse().unwrap();
425			assert_eq!(id.to_string(), text);
426		}
427		assert!("m::a.txt".parse::<NodeId>().is_err());
428		assert!("m::a.txt::x::y::z".parse::<NodeId>().is_err());
429	}
430}