pub mod pikmin;
use std::collections::BTreeMap;
use std::fs;
use std::path::PathBuf;
use serde::Deserialize;
use crate::error::SetupError;
use crate::types::{
BatchConfig, BatchRunTemplate, CaptureConfig, EdgeId, EdgeSpec, EndConditionSpec,
ExecutionMode, MetricKey, NodeId, NodeKind, NodeSpec, RunConfig, ScenarioId, ScenarioSpec,
TransferSpec,
};
use crate::validation::{compile_scenario, CompiledScenario};
pub const FIXTURE_RUN_SEED: u64 = 42;
pub const FIXTURE_RUN_MAX_STEPS: u64 = 10;
pub const FIXTURE_BATCH_RUNS: u64 = 6;
pub const FIXTURE_BATCH_BASE_SEED: u64 = 0x000A_11CE_55ED_u64;
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
pub struct ParityCatalog {
pub version: u32,
pub rules: Vec<ParityRuleDeclaration>,
pub fixtures: Vec<ParityFixtureDeclaration>,
}
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
pub struct ParityRuleDeclaration {
pub rule_id: String,
pub title: String,
pub source_section: String,
}
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
pub struct ParityFixtureDeclaration {
pub fixture_id: String,
pub rule_id: String,
pub expected: ParityExpectedDeclaration,
}
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
pub struct ParityExpectedDeclaration {
pub descriptor: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ParityFixtureCase {
pub fixture_id: String,
pub rule_id: String,
pub title: String,
pub source_section: String,
pub descriptor: String,
}
pub fn fixture_scenario() -> ScenarioSpec {
let source = NodeId::fixture("source");
let sink = NodeId::fixture("sink");
let sink_metric = MetricKey::fixture("sink");
let mut scenario = ScenarioSpec::new(ScenarioId::fixture("scenario-testkit"))
.with_node(NodeSpec::new(source.clone(), NodeKind::Source).with_initial_value(1.0))
.with_node(NodeSpec::new(sink.clone(), NodeKind::Sink))
.with_edge(EdgeSpec::new(
EdgeId::fixture("edge-source-sink"),
source,
sink,
TransferSpec::Fixed { amount: 1.0 },
));
scenario.end_conditions = vec![EndConditionSpec::MaxSteps { steps: 3 }];
scenario.tracked_metrics.insert(sink_metric);
scenario
}
pub fn fixture_compiled_scenario() -> Result<CompiledScenario, SetupError> {
compile_scenario(&fixture_scenario())
}
pub fn fixture_run_config(seed: u64, max_steps: u64) -> RunConfig {
RunConfig { seed, max_steps, capture: CaptureConfig::default() }
}
pub fn deterministic_run_config() -> RunConfig {
fixture_run_config(FIXTURE_RUN_SEED, FIXTURE_RUN_MAX_STEPS)
}
pub fn fixture_batch_config(
runs: u64,
base_seed: u64,
execution_mode: ExecutionMode,
) -> BatchConfig {
BatchConfig {
runs,
base_seed,
execution_mode,
run_template: BatchRunTemplate {
max_steps: FIXTURE_RUN_MAX_STEPS,
capture: CaptureConfig::default(),
},
}
}
pub fn deterministic_batch_config() -> BatchConfig {
fixture_batch_config(FIXTURE_BATCH_RUNS, FIXTURE_BATCH_BASE_SEED, ExecutionMode::SingleThread)
}
pub fn parity_catalog_path() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/parity/catalog.json")
}
fn invalid_parity_catalog(reason: impl Into<String>) -> SetupError {
SetupError::InvalidParameter {
name: "tests.fixtures.parity.catalog".to_string(),
reason: reason.into(),
}
}
fn parity_is_non_empty(value: &str) -> bool {
!value.trim().is_empty()
}
pub fn load_parity_catalog() -> Result<ParityCatalog, SetupError> {
let path = parity_catalog_path();
let raw = fs::read_to_string(&path).map_err(|error| {
invalid_parity_catalog(format!("failed to read {}: {error}", path.display()))
})?;
let catalog: ParityCatalog = serde_json::from_str(&raw).map_err(|error| {
invalid_parity_catalog(format!("failed to parse {}: {error}", path.display()))
})?;
if catalog.version != 1 {
return Err(invalid_parity_catalog(format!(
"unsupported catalog version {}; expected 1",
catalog.version
)));
}
Ok(catalog)
}
pub fn parity_fixture_cases() -> Result<Vec<ParityFixtureCase>, SetupError> {
let catalog = load_parity_catalog()?;
let rule_index =
catalog.rules.iter().map(|rule| (rule.rule_id.clone(), rule)).collect::<BTreeMap<_, _>>();
let mut cases = Vec::with_capacity(catalog.fixtures.len());
for fixture in &catalog.fixtures {
if !parity_is_non_empty(&fixture.fixture_id) {
return Err(invalid_parity_catalog("fixture_id must be non-empty"));
}
if !parity_is_non_empty(&fixture.rule_id) {
return Err(invalid_parity_catalog(format!(
"fixture {} has empty rule_id",
fixture.fixture_id
)));
}
if !parity_is_non_empty(&fixture.expected.descriptor) {
return Err(invalid_parity_catalog(format!(
"fixture {} has empty expected.descriptor",
fixture.fixture_id
)));
}
let Some(rule) = rule_index.get(&fixture.rule_id) else {
return Err(invalid_parity_catalog(format!(
"fixture {} references unknown rule_id {}",
fixture.fixture_id, fixture.rule_id
)));
};
if !parity_is_non_empty(&rule.title) || !parity_is_non_empty(&rule.source_section) {
return Err(invalid_parity_catalog(format!(
"rule {} is missing title/source_section",
rule.rule_id
)));
}
cases.push(ParityFixtureCase {
fixture_id: fixture.fixture_id.clone(),
rule_id: fixture.rule_id.clone(),
title: rule.title.clone(),
source_section: rule.source_section.clone(),
descriptor: fixture.expected.descriptor.clone(),
});
}
cases.sort_by(|left, right| left.fixture_id.cmp(&right.fixture_id));
Ok(cases)
}
pub fn parity_fixture_case(fixture_id: &str) -> Result<ParityFixtureCase, SetupError> {
parity_fixture_cases()?
.into_iter()
.find(|case| case.fixture_id == fixture_id)
.ok_or_else(|| invalid_parity_catalog(format!("unknown fixture_id {fixture_id}")))
}
pub fn format_parity_failure(
case: &ParityFixtureCase,
detail: &str,
evidence: &BTreeMap<String, String>,
) -> String {
format!(
"parity differential failure: fixture_id={} rule_id={} title={} expected={} detail={} evidence={:?}",
case.fixture_id, case.rule_id, case.title, case.descriptor, detail, evidence
)
}
#[cfg_attr(test, rstest::fixture)]
pub fn scenario_fixture() -> ScenarioSpec {
fixture_scenario()
}
#[cfg_attr(test, rstest::fixture)]
pub fn compiled_scenario_fixture() -> CompiledScenario {
fixture_compiled_scenario().expect("fixture scenario should compile")
}
#[cfg_attr(test, rstest::fixture)]
pub fn run_config_fixture() -> RunConfig {
deterministic_run_config()
}
#[cfg_attr(test, rstest::fixture)]
pub fn batch_config_fixture() -> BatchConfig {
deterministic_batch_config()
}
#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
use std::path::Path;
use super::{
batch_config_fixture, compiled_scenario_fixture, deterministic_batch_config,
deterministic_run_config, fixture_batch_config, fixture_compiled_scenario,
fixture_scenario, invalid_parity_catalog, parity_catalog_path, parity_fixture_case,
parity_fixture_cases, parity_is_non_empty, run_config_fixture, scenario_fixture,
FIXTURE_BATCH_BASE_SEED, FIXTURE_BATCH_RUNS, FIXTURE_RUN_MAX_STEPS, FIXTURE_RUN_SEED,
};
use crate::error::SetupError;
use crate::types::{ExecutionMode, MetricKey};
#[test]
fn deterministic_config_fixtures_use_documented_defaults() {
let run = deterministic_run_config();
assert_eq!(run.seed, FIXTURE_RUN_SEED);
assert_eq!(run.max_steps, FIXTURE_RUN_MAX_STEPS);
assert_eq!(run.capture.every_n_steps, 1);
assert!(run.capture.include_step_zero);
assert!(run.capture.include_final_state);
let batch = deterministic_batch_config();
assert_eq!(batch.runs, FIXTURE_BATCH_RUNS);
assert_eq!(batch.base_seed, FIXTURE_BATCH_BASE_SEED);
assert_eq!(batch.execution_mode, ExecutionMode::SingleThread);
assert_eq!(batch.run_template.max_steps, run.max_steps);
assert_eq!(batch.run_template.capture, run.capture);
}
#[test]
fn fixture_batch_config_uses_requested_inputs() {
let batch = fixture_batch_config(9, 123, ExecutionMode::Rayon);
assert_eq!(batch.runs, 9);
assert_eq!(batch.base_seed, 123);
assert_eq!(batch.execution_mode, ExecutionMode::Rayon);
assert_eq!(batch.run_template.max_steps, FIXTURE_RUN_MAX_STEPS);
}
#[test]
fn fixture_scenario_has_expected_nodes_edges_and_end_condition() {
let scenario = fixture_scenario();
assert_eq!(scenario.nodes.len(), 2);
assert_eq!(scenario.edges.len(), 1);
assert_eq!(scenario.end_conditions.len(), 1);
assert_eq!(
scenario.end_conditions[0],
crate::types::EndConditionSpec::MaxSteps { steps: 3 }
);
assert!(scenario.tracked_metrics.contains(&MetricKey::fixture("sink")));
}
#[test]
fn parity_fixture_cases_are_sorted_and_non_empty() {
let cases = parity_fixture_cases().expect("catalog should parse");
assert!(!cases.is_empty());
assert!(cases.windows(2).all(|pair| pair[0].fixture_id <= pair[1].fixture_id));
}
#[test]
fn parity_fixture_case_resolves_known_fixture() {
let case = parity_fixture_case("fx-001-pool-default-zero").expect("fixture must exist");
assert_eq!(case.rule_id, "PRB-001");
assert!(case.title.contains("Pool"));
assert!(case.descriptor.contains("zero"));
}
#[test]
fn parity_fixture_case_rejects_unknown_fixture_id() {
let err = parity_fixture_case("fx-999-missing").expect_err("missing fixture should fail");
match err {
SetupError::InvalidParameter { name, reason } => {
assert_eq!(name, "tests.fixtures.parity.catalog");
assert!(reason.contains("unknown fixture_id fx-999-missing"));
}
other => panic!("expected InvalidParameter, got {other:?}"),
}
}
#[test]
fn format_parity_failure_contains_fixture_metadata_and_evidence() {
let case = parity_fixture_case("fx-002-pool-negative-via-state-only")
.expect("fixture must exist for formatting test");
let evidence = BTreeMap::from([
("actual".to_string(), "allowed".to_string()),
("step".to_string(), "3".to_string()),
]);
let message = super::format_parity_failure(&case, "behavior mismatch", &evidence);
assert!(message.contains("fixture_id=fx-002-pool-negative-via-state-only"));
assert!(message.contains("rule_id=PRB-002"));
assert!(message.contains("detail=behavior mismatch"));
assert!(message.contains("evidence={"));
}
#[test]
fn parity_helpers_validate_empty_and_error_shape() {
assert!(parity_is_non_empty("x"));
assert!(!parity_is_non_empty(""));
assert!(!parity_is_non_empty(" "));
let err = invalid_parity_catalog("boom");
assert!(matches!(
err,
SetupError::InvalidParameter { name, reason }
if name == "tests.fixtures.parity.catalog" && reason == "boom"
));
}
#[test]
fn parity_catalog_path_points_to_fixture_file() {
let path = parity_catalog_path();
assert!(path.ends_with(Path::new("tests/fixtures/parity/catalog.json")));
assert!(path.is_file());
}
#[test]
fn fixture_and_wrapper_entrypoints_are_consistent() {
let direct_compiled = fixture_compiled_scenario().expect("fixture should compile");
let wrapped_compiled = compiled_scenario_fixture();
assert_eq!(direct_compiled, wrapped_compiled);
assert_eq!(scenario_fixture(), fixture_scenario());
assert_eq!(run_config_fixture(), deterministic_run_config());
assert_eq!(batch_config_fixture(), deterministic_batch_config());
}
}