1mod annotation;
10mod tests_dir;
11
12use crate::diagnostic::{Diagnostic, DiagnosticCode};
13use crate::model::{
14 AiMode, EventInfo, Fixture, FixtureName, GameDate, HelperFile, MAX_ADVANCE_DAYS,
15 SessionRequest, Start, Step, Tag, TestCase, valid_case_name, valid_mod_name,
16};
17use crate::source::{SourceFile, SourceKind, SourceSpan};
18use foch::game::eu4::script::parser::{
19 AstStatement, AstValue, ScalarValue, parse_clausewitz_content,
20};
21use foch_annotation::builtin::TEST_PARAMS;
22use foch_annotation::value::{EventId, Value, ValueType, parse_value, scalar};
23use serde::{Deserialize, Serialize};
24use std::collections::HashSet;
25
26pub(super) const MAX_PARAMETRIZE_CASES: usize = 256;
30
31#[derive(Clone, Debug, Default, Serialize, Deserialize)]
32pub struct Collection {
33 pub cases: Vec<TestCase>,
34 pub fixtures: Vec<Fixture>,
35 pub events: Vec<EventInfo>,
36 pub helpers: Vec<HelperFile>,
37 pub diagnostics: Vec<Diagnostic>,
38}
39
40impl Collection {
41 pub fn has_errors(&self) -> bool {
42 self.diagnostics
43 .iter()
44 .any(|diagnostic| diagnostic.severity == crate::diagnostic::Severity::Error)
45 }
46}
47
48pub fn collect(files: &[SourceFile]) -> Collection {
49 let mut collection = Collection::default();
50 for file in files {
51 if let Err(message) = valid_mod_name(&file.mod_name) {
52 collection.diagnostics.push(Diagnostic::error(
53 DiagnosticCode::InvalidMod,
54 None,
55 message,
56 ));
57 continue;
58 }
59 if !file.path.as_str().ends_with(".txt") {
60 collection.diagnostics.push(Diagnostic::error(
61 DiagnosticCode::Annotation,
62 None,
63 format!("{}: test sources must be EU4 .txt scripts", file.path),
64 ));
65 continue;
66 }
67 let parsed = parse_clausewitz_content(crate::game_path(file.path.as_str()), &file.text);
68 if !parsed.diagnostics.is_empty() {
69 for diagnostic in parsed.diagnostics {
70 collection.diagnostics.push(Diagnostic::error(
71 DiagnosticCode::Parse,
72 Some(SourceSpan::from_parser(&file.path, &diagnostic.span)),
73 diagnostic.message,
74 ));
75 }
76 continue;
77 }
78 let statements = &parsed.ast.statements;
79 match file.kind {
80 SourceKind::Inline => annotation::collect(file, statements, &mut collection),
81 SourceKind::TestsDir if file.path.is_below("tests/events") => {
82 for statement in statements {
83 if let Some(event) = event_info(file, statement, true) {
84 collection.events.push(event);
85 }
86 }
87 collection.helpers.push(HelperFile {
88 path: file.path.clone(),
89 text: file.text.clone(),
90 });
91 }
92 SourceKind::TestsDir => tests_dir::collect(file, statements, &mut collection),
93 }
94 }
95
96 let mut nodes = HashSet::new();
97 let mut cases = Vec::new();
98 for case in collection.cases.drain(..) {
99 if nodes.insert(case.node.to_string()) {
100 cases.push(case);
101 } else {
102 collection.diagnostics.push(Diagnostic::error(
103 DiagnosticCode::DuplicateCase,
104 Some(case.origin.clone()),
105 format!(
106 "duplicate test {}; give each case a distinct name",
107 case.node
108 ),
109 ));
110 }
111 }
112 collection.cases = cases;
113
114 let mut names = HashSet::new();
115 let mut fixtures = Vec::new();
116 for fixture in collection.fixtures.drain(..) {
117 if names.insert(fixture.name.clone()) {
118 fixtures.push(fixture);
119 } else {
120 collection.diagnostics.push(Diagnostic::error(
121 DiagnosticCode::DuplicateFixture,
122 Some(fixture.origin.clone()),
123 format!("duplicate fixture {}", fixture.name),
124 ));
125 }
126 }
127 collection.fixtures = fixtures;
128 collection
129}
130
131fn event_info(file: &SourceFile, statement: &AstStatement, helper: bool) -> Option<EventInfo> {
133 let AstStatement::Assignment {
134 key,
135 value: AstValue::Block { items, span },
136 ..
137 } = statement
138 else {
139 return None;
140 };
141 if key != "country_event" {
142 return None;
143 }
144 let id = single_field(items, "id")
145 .and_then(|value| scalar(value).ok())
146 .and_then(|id| EventId::parse(&id).ok())?;
147 let yes = |name| {
148 matches!(
149 single_field(items, name),
150 Some(AstValue::Scalar {
151 value: ScalarValue::Bool(true),
152 ..
153 })
154 )
155 };
156 Some(EventInfo {
157 id,
158 origin: SourceSpan::from_parser(&file.path, span),
159 invocable: yes("hidden") && yes("is_triggered_only"),
160 body: file.text[span.start.offset + 1..span.end.offset - 1].into(),
161 helper,
162 })
163}
164
165fn single_field<'a>(items: &'a [AstStatement], name: &str) -> Option<&'a AstValue> {
166 let mut values = items.iter().filter_map(|item| match item {
167 AstStatement::Assignment { key, value, .. } if key == name => Some(value),
168 _ => None,
169 });
170 let value = values.next()?;
171 values.next().is_none().then_some(value)
172}
173
174fn typed(file: &SourceFile, key: &str, value: &AstValue) -> Result<Value, String> {
176 let param = TEST_PARAMS
177 .iter()
178 .find(|param| param.name == key)
179 .ok_or_else(|| format!("unknown test key {key}"))?;
180 parse_value(&file.path, &file.text, value, param.value_type)
181 .map_err(|message| format!("{key}: {message}"))
182}
183
184#[derive(Default)]
186struct CaseFields {
187 time: Option<GameDate>,
188 tag: Option<Tag>,
189 name: Option<String>,
190 uses: Option<Vec<FixtureName>>,
191 ai: Option<AiMode>,
192 session: Option<SessionRequest>,
193 player: Option<bool>,
194}
195
196const METADATA: &[&str] = &["time", "tag", "name", "use", "ai", "session", "player"];
197
198fn set_once<T>(slot: &mut Option<T>, key: &str, value: T) -> Result<(), String> {
199 if slot.is_some() {
200 return Err(format!("duplicate test parameter {key}"));
201 }
202 *slot = Some(value);
203 Ok(())
204}
205
206impl CaseFields {
207 fn set(&mut self, key: &str, value: &Value) -> Result<bool, String> {
209 match (key, value) {
210 ("time", Value::Date(date)) => set_once(&mut self.time, key, *date)?,
211 ("tag", Value::Tag(tag)) => set_once(&mut self.tag, key, tag.clone())?,
212 ("name", Value::Text(name)) => {
213 valid_case_name(name)?;
214 set_once(&mut self.name, key, name.clone())?;
215 }
216 ("use", Value::Identifiers(names)) => set_once(
217 &mut self.uses,
218 key,
219 names
220 .iter()
221 .map(|name| FixtureName::parse(name))
222 .collect::<Result<_, _>>()?,
223 )?,
224 ("ai", Value::Choice(mode)) => set_once(
225 &mut self.ai,
226 key,
227 if mode == "on" {
228 AiMode::On
229 } else {
230 AiMode::Off
231 },
232 )?,
233 ("session", Value::Choice(request)) => set_once(
234 &mut self.session,
235 key,
236 if request == "alone" {
237 SessionRequest::Alone
238 } else {
239 SessionRequest::Auto
240 },
241 )?,
242 ("player", Value::Bool(player)) => set_once(&mut self.player, key, *player)?,
243 _ => return Ok(false),
244 }
245 Ok(true)
246 }
247
248 fn apply(&mut self, file: &SourceFile, key: &str, value: &AstValue) -> Result<bool, String> {
250 if !METADATA.contains(&key) {
251 return Ok(false);
252 }
253 self.set(key, &typed(file, key, value)?)
254 }
255}
256
257fn step(file: &SourceFile, key: &str, value: &AstValue) -> Result<Option<Step>, String> {
259 let span = || SourceSpan::from_parser(&file.path, value.span());
260 Ok(Some(match (key, typed_step(file, key, value)?) {
261 (_, None) => return Ok(None),
262 ("effect", Some(Value::Effects(block))) => Step::Effect { block },
263 ("expect", Some(Value::Conditions(clauses))) => Step::Expect { clauses },
264 ("advance_days", Some(Value::Integer(days))) => Step::AdvanceDays { days, span: span() },
265 ("fire", Some(Value::EventId(event))) => Step::Fire {
266 event,
267 span: span(),
268 },
269 _ => unreachable!("step types are fixed by the schema"),
270 }))
271}
272
273fn typed_step(file: &SourceFile, key: &str, value: &AstValue) -> Result<Option<Value>, String> {
274 match key {
275 "effect" | "expect" | "advance_days" => typed(file, key, value).map(Some),
276 "fire" => parse_value(&file.path, &file.text, value, ValueType::EventId)
277 .map(Some)
278 .map_err(|message| format!("fire: {message}")),
279 _ => Ok(None),
280 }
281}
282
283#[derive(Default)]
285pub(super) struct Axes {
286 pub tags: Option<Vec<Tag>>,
287 pub times: Option<Vec<GameDate>>,
288}
289
290pub(super) struct Dimension<T> {
293 pub values: Vec<T>,
294 pub varies: bool,
295}
296
297pub(super) fn dimension<T>(
301 direct: Option<T>,
302 axis: Option<Vec<T>>,
303 name: &str,
304) -> Result<Dimension<T>, String> {
305 match (direct, axis) {
306 (Some(_), Some(_)) => Err(format!(
307 "{name} is given by both the test and parametrize; give it in only one"
308 )),
309 (None, Some(values)) => Ok(Dimension {
310 values,
311 varies: true,
312 }),
313 (Some(value), None) => Ok(Dimension {
314 values: vec![value],
315 varies: false,
316 }),
317 (None, None) => Err(format!("missing required test parameter {name}")),
318 }
319}
320
321pub(super) fn expand_cases(
326 base: TestCase,
327 tag: Dimension<Tag>,
328 time: Dimension<GameDate>,
329) -> Result<Vec<TestCase>, String> {
330 let (tags, tag_varies) = (tag.values, tag.varies);
331 let (times, time_varies) = (time.values, time.varies);
332 if let Some(duplicate) = first_duplicate(tags.iter().map(Tag::as_str)) {
333 return Err(format!("parametrize lists tag {duplicate} twice"));
334 }
335 if let Some(duplicate) = first_duplicate(times.iter().map(|time| time.to_string())) {
336 return Err(format!("parametrize lists time {duplicate} twice"));
337 }
338 let product = tags.len() * times.len();
339 if product > MAX_PARAMETRIZE_CASES {
340 return Err(format!(
341 "parametrize expands to {product} cases, more than the limit of {MAX_PARAMETRIZE_CASES}; shorten the lists"
342 ));
343 }
344 let mut cases = Vec::with_capacity(product);
345 for date in × {
346 for tag in &tags {
347 let mut case = base.clone();
348 case.start = Start {
349 date: *date,
350 tag: tag.clone(),
351 };
352 case.node.params = Vec::new();
353 if tag_varies {
354 case.node.params.push(("tag".into(), tag.to_string()));
355 }
356 if time_varies {
357 case.node.params.push(("time".into(), date.to_string()));
358 }
359 case.compute_content_id();
360 cases.push(case);
361 }
362 }
363 Ok(cases)
364}
365
366fn first_duplicate<T: AsRef<str>>(values: impl Iterator<Item = T>) -> Option<String> {
367 let mut seen = HashSet::new();
368 for value in values {
369 if !seen.insert(value.as_ref().to_string()) {
370 return Some(value.as_ref().to_string());
371 }
372 }
373 None
374}
375
376fn total_days(steps: &[Step]) -> Result<(), String> {
377 let total: u64 = steps
378 .iter()
379 .map(|step| match step {
380 Step::AdvanceDays { days, .. } => u64::from(*days),
381 _ => 0,
382 })
383 .sum();
384 if total > u64::from(MAX_ADVANCE_DAYS) {
385 return Err(format!(
386 "a case may advance at most {MAX_ADVANCE_DAYS} days in total"
387 ));
388 }
389 Ok(())
390}