foch-test 0.0.2

Collection, planning, compilation and judging of in-game EU4 mod tests
Documentation
//! `test = { ... }` and `fixture = { ... }` blocks below the mod's `tests/`.
//!
//! The game does not load this directory, so these are plain script blocks.
//! Inside a test block, `effect`, `fire`, `advance_days` and `expect` are
//! steps executed in source order; the remaining keys are metadata typed by
//! the same schema as `#test(...)`.

use super::{Axes, CaseFields, Collection, dimension, expand_cases, step, total_days};
use crate::diagnostic::{Diagnostic, DiagnosticCode};
use crate::model::{
	CaseName, ContentId, Fixture, FixtureName, GameDate, Marker, Marks, NodeId, Start, Tag,
	TestCase, XFail,
};
use crate::source::{SourceFile, SourceSpan};
use foch::game::eu4::script::parser::{AstStatement, AstValue};
use foch_annotation::value::{Value, ValueType, boolean, parse_value, scalar};

pub(super) fn collect(file: &SourceFile, statements: &[AstStatement], out: &mut Collection) {
	for statement in statements {
		let (key, value, span) = match statement {
			AstStatement::Comment { .. } => continue,
			AstStatement::Item { span, .. } => {
				out.diagnostics.push(error(
					SourceSpan::from_parser(&file.path, span),
					"expected test = { ... } or fixture = { ... }",
				));
				continue;
			}
			AstStatement::Assignment {
				key, value, span, ..
			} => (key, value, span),
		};
		let origin = SourceSpan::from_parser(&file.path, span);
		let AstValue::Block { items, .. } = value else {
			out.diagnostics
				.push(error(origin, format!("{key} must be a block")));
			continue;
		};
		let result = match key.as_str() {
			"test" => parse_test(file, items, origin.clone()).map(|cases| out.cases.extend(cases)),
			"fixture" => {
				parse_fixture(file, items, origin.clone()).map(|fixture| out.fixtures.push(fixture))
			}
			other => Err(format!(
				"unexpected {other}; files below tests/ contain test and fixture blocks, helper events belong below tests/events/"
			)),
		};
		if let Err(message) = result {
			out.diagnostics.push(error(origin, message));
		}
	}
}

fn error(span: SourceSpan, message: impl Into<String>) -> Diagnostic {
	Diagnostic::error(DiagnosticCode::Annotation, Some(span), message)
}

/// `skip = yes` or `skip = "reason"`.
/// `skip = yes` or `skip = "reason"`. `skip = no` is rejected rather than
/// silently skipping with the reason "no"; omit the key to not skip.
fn marker(value: &AstValue) -> Result<Marker, String> {
	match boolean(value) {
		Ok(true) => Ok(Marker { reason: None }),
		Ok(false) => Err("use yes or a reason string; omit the key to not skip".into()),
		Err(_) => Ok(Marker {
			reason: Some(scalar(value)?),
		}),
	}
}

fn xfail(value: &AstValue) -> Result<XFail, String> {
	let AstValue::Block { items, .. } = value else {
		return Ok(XFail {
			reason: marker(value)?.reason,
			strict: false,
		});
	};
	let mut xfail = XFail::default();
	for item in items {
		match item {
			AstStatement::Comment { .. } => {}
			AstStatement::Assignment { key, value, .. } if key == "reason" => {
				xfail.reason = Some(scalar(value)?);
			}
			AstStatement::Assignment { key, value, .. } if key == "strict" => {
				xfail.strict = boolean(value)?;
			}
			_ => return Err("xfail accepts reason and strict".into()),
		}
	}
	Ok(xfail)
}

fn parse_test(
	file: &SourceFile,
	items: &[AstStatement],
	origin: SourceSpan,
) -> Result<Vec<TestCase>, String> {
	let mut fields = CaseFields::default();
	let mut marks = Marks::default();
	let mut steps = Vec::new();
	let mut axes = Axes::default();
	for item in items {
		let (key, value) = match item {
			AstStatement::Comment { .. } => continue,
			AstStatement::Item { .. } => {
				return Err("test blocks contain key = value entries".into());
			}
			AstStatement::Assignment { key, value, .. } => (key.as_str(), value),
		};
		if fields.apply(file, key, value)? {
			continue;
		}
		if let Some(step) = step(file, key, value)? {
			steps.push(step);
			continue;
		}
		let duplicate = || format!("duplicate test key {key}");
		match key {
			"mark" => match parse_value(&file.path, &file.text, value, ValueType::Identifiers) {
				Ok(Value::Identifiers(labels)) => marks.labels.extend(labels),
				Ok(_) => unreachable!("identifiers parse as identifiers"),
				Err(message) => return Err(format!("mark: {message}")),
			},
			"skip" => {
				if marks.skip.replace(marker(value)?).is_some() {
					return Err(duplicate());
				}
			}
			"ignore" => {
				if marks.ignore.replace(marker(value)?).is_some() {
					return Err(duplicate());
				}
			}
			"xfail" => {
				if marks.xfail.replace(xfail(value)?).is_some() {
					return Err(duplicate());
				}
			}
			"parametrize" => {
				if axes.tags.is_some() || axes.times.is_some() {
					return Err(duplicate());
				}
				axes = parametrize(file, value)?;
			}
			other => return Err(format!("unknown test key {other}")),
		}
	}
	if marks.skip.is_some() && marks.ignore.is_some() {
		return Err("a test cannot be both skip and ignore".into());
	}
	if steps.is_empty() {
		return Err("a test needs at least one effect, fire, advance_days or expect step".into());
	}
	total_days(&steps)?;
	marks.session = fields.session;
	marks.requires_player = fields.player.unwrap_or(false);
	let tag = dimension(fields.tag, axes.tags, "tag")?;
	let time = dimension(fields.time, axes.times, "time")?;
	let base = TestCase {
		node: NodeId {
			mod_name: file.mod_name.clone(),
			path: file.path.clone(),
			target: None,
			name: CaseName::Named(fields.name.ok_or("a test block requires name")?),
			params: Vec::new(),
		},
		content_id: ContentId(String::new()),
		origin,
		start: Start {
			date: time.values[0],
			tag: tag.values[0].clone(),
		},
		ai: fields.ai.unwrap_or_default(),
		uses: fields.uses.unwrap_or_default(),
		steps,
		marks,
	};
	if base.marks.xfail.is_some() && base.is_smoke() {
		return Err("xfail requires an expect step; a smoke test has nothing to fail".into());
	}
	expand_cases(base, tag, time)
}

/// `parametrize = { tag = { SWE DAN } time = { 1444.11.11 1500.1.1 } }`.
fn parametrize(file: &SourceFile, value: &AstValue) -> Result<Axes, String> {
	let AstValue::Block { items, .. } = value else {
		return Err("parametrize must be a block of tag and time lists".into());
	};
	let mut axes = Axes::default();
	for item in items {
		let (key, value) = match item {
			AstStatement::Comment { .. } => continue,
			AstStatement::Assignment { key, value, .. } => (key.as_str(), value),
			AstStatement::Item { .. } => {
				return Err("parametrize contains tag and time lists".into());
			}
		};
		let fresh = match key {
			"tag" => axes
				.tags
				.replace(
					parse_value(&file.path, &file.text, value, list(&ValueType::Tag))
						.map_err(|message| format!("parametrize tag: {message}"))
						.map(tags)?,
				)
				.is_none(),
			"time" => axes
				.times
				.replace(
					parse_value(&file.path, &file.text, value, list(&ValueType::Date))
						.map_err(|message| format!("parametrize time: {message}"))
						.map(times)?,
				)
				.is_none(),
			other => return Err(format!("parametrize has no {other} dimension")),
		};
		if !fresh {
			return Err(format!("duplicate parametrize dimension {key}"));
		}
	}
	Ok(axes)
}

const fn list(element: &'static ValueType) -> ValueType {
	ValueType::List { element }
}

fn tags(value: Value) -> Vec<Tag> {
	match value {
		Value::List(values) => values
			.into_iter()
			.map(|value| match value {
				Value::Tag(tag) => tag,
				_ => unreachable!("a tag list holds tags"),
			})
			.collect(),
		_ => unreachable!("a list parses as a list"),
	}
}

fn times(value: Value) -> Vec<GameDate> {
	match value {
		Value::List(values) => values
			.into_iter()
			.map(|value| match value {
				Value::Date(date) => date,
				_ => unreachable!("a date list holds dates"),
			})
			.collect(),
		_ => unreachable!("a list parses as a list"),
	}
}

fn parse_fixture(
	file: &SourceFile,
	items: &[AstStatement],
	origin: SourceSpan,
) -> Result<Fixture, String> {
	let (mut name, mut uses, mut effect, mut check) = (None, None, None, None);
	for item in items {
		let (key, value) = match item {
			AstStatement::Comment { .. } => continue,
			AstStatement::Item { .. } => {
				return Err("fixture blocks contain key = value entries".into());
			}
			AstStatement::Assignment { key, value, .. } => (key.as_str(), value),
		};
		let value_type = match key {
			"name" => ValueType::Text,
			"use" => ValueType::Identifiers,
			"effect" => ValueType::Effects,
			"check" => ValueType::Conditions,
			other => return Err(format!("unknown fixture key {other}")),
		};
		let parsed = parse_value(&file.path, &file.text, value, value_type)
			.map_err(|message| format!("{key}: {message}"))?;
		let fresh = match parsed {
			Value::Text(text) => name.replace(FixtureName::parse(&text)?).is_none(),
			Value::Identifiers(names) => uses
				.replace(
					names
						.iter()
						.map(|name| FixtureName::parse(name))
						.collect::<Result<Vec<_>, _>>()?,
				)
				.is_none(),
			Value::Effects(block) => effect.replace(block).is_none(),
			Value::Conditions(clauses) => check.replace(clauses).is_none(),
			_ => unreachable!("fixture keys have fixed types"),
		};
		if !fresh {
			return Err(format!("duplicate fixture key {key}"));
		}
	}
	if effect.is_none() && check.is_none() {
		return Err("a fixture needs an effect, a check, or both".into());
	}
	Ok(Fixture {
		name: name.ok_or("a fixture requires name")?,
		origin,
		uses: uses.unwrap_or_default(),
		effect,
		check: check.unwrap_or_default(),
	})
}