Add headless runtime tooling and Campaign.win analysis
This commit is contained in:
parent
57bf0666e0
commit
27172e3786
37 changed files with 11867 additions and 302 deletions
10
crates/rrt-fixtures/Cargo.toml
Normal file
10
crates/rrt-fixtures/Cargo.toml
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
[package]
|
||||
name = "rrt-fixtures"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
rrt-runtime = { path = "../rrt-runtime" }
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
82
crates/rrt-fixtures/src/diff.rs
Normal file
82
crates/rrt-fixtures/src/diff.rs
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
use std::collections::BTreeSet;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct JsonDiffEntry {
|
||||
pub path: String,
|
||||
pub left: Value,
|
||||
pub right: Value,
|
||||
}
|
||||
|
||||
pub fn diff_json_values(left: &Value, right: &Value) -> Vec<JsonDiffEntry> {
|
||||
let mut differences = Vec::new();
|
||||
collect_json_differences("$", left, right, &mut differences);
|
||||
differences
|
||||
}
|
||||
|
||||
fn collect_json_differences(
|
||||
path: &str,
|
||||
left: &Value,
|
||||
right: &Value,
|
||||
differences: &mut Vec<JsonDiffEntry>,
|
||||
) {
|
||||
match (left, right) {
|
||||
(Value::Object(left_map), Value::Object(right_map)) => {
|
||||
let mut keys = BTreeSet::new();
|
||||
keys.extend(left_map.keys().cloned());
|
||||
keys.extend(right_map.keys().cloned());
|
||||
|
||||
for key in keys {
|
||||
let next_path = format!("{path}.{key}");
|
||||
match (left_map.get(&key), right_map.get(&key)) {
|
||||
(Some(left_value), Some(right_value)) => {
|
||||
collect_json_differences(&next_path, left_value, right_value, differences);
|
||||
}
|
||||
(left_value, right_value) => differences.push(JsonDiffEntry {
|
||||
path: next_path,
|
||||
left: left_value.cloned().unwrap_or(Value::Null),
|
||||
right: right_value.cloned().unwrap_or(Value::Null),
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
(Value::Array(left_items), Value::Array(right_items)) => {
|
||||
let max_len = left_items.len().max(right_items.len());
|
||||
for index in 0..max_len {
|
||||
let next_path = format!("{path}[{index}]");
|
||||
match (left_items.get(index), right_items.get(index)) {
|
||||
(Some(left_value), Some(right_value)) => {
|
||||
collect_json_differences(&next_path, left_value, right_value, differences);
|
||||
}
|
||||
(left_value, right_value) => differences.push(JsonDiffEntry {
|
||||
path: next_path,
|
||||
left: left_value.cloned().unwrap_or(Value::Null),
|
||||
right: right_value.cloned().unwrap_or(Value::Null),
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
_ if left != right => differences.push(JsonDiffEntry {
|
||||
path: path.to_string(),
|
||||
left: left.clone(),
|
||||
right: right.clone(),
|
||||
}),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn diffs_nested_json_values() {
|
||||
let left = serde_json::json!({ "a": { "b": 1 } });
|
||||
let right = serde_json::json!({ "a": { "b": 2 } });
|
||||
let diff = diff_json_values(&left, &right);
|
||||
assert_eq!(diff.len(), 1);
|
||||
assert_eq!(diff[0].path, "$.a.b");
|
||||
}
|
||||
}
|
||||
12
crates/rrt-fixtures/src/lib.rs
Normal file
12
crates/rrt-fixtures/src/lib.rs
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
pub mod diff;
|
||||
pub mod load;
|
||||
pub mod normalize;
|
||||
pub mod schema;
|
||||
|
||||
pub use diff::{JsonDiffEntry, diff_json_values};
|
||||
pub use load::{load_fixture_document, load_fixture_document_from_str};
|
||||
pub use normalize::normalize_runtime_state;
|
||||
pub use schema::{
|
||||
ExpectedRuntimeSummary, FIXTURE_FORMAT_VERSION, FixtureDocument, FixtureSource,
|
||||
FixtureStateOrigin, FixtureValidationReport, RawFixtureDocument, validate_fixture_document,
|
||||
};
|
||||
162
crates/rrt-fixtures/src/load.rs
Normal file
162
crates/rrt-fixtures/src/load.rs
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
use std::path::{Path, PathBuf};
|
||||
|
||||
use rrt_runtime::{load_runtime_snapshot_document, validate_runtime_snapshot_document};
|
||||
|
||||
use crate::{FixtureDocument, FixtureStateOrigin, RawFixtureDocument};
|
||||
|
||||
pub fn load_fixture_document(path: &Path) -> Result<FixtureDocument, Box<dyn std::error::Error>> {
|
||||
let text = std::fs::read_to_string(path)?;
|
||||
let base_dir = path.parent().unwrap_or_else(|| Path::new("."));
|
||||
load_fixture_document_from_str_with_base(&text, base_dir)
|
||||
}
|
||||
|
||||
pub fn load_fixture_document_from_str(
|
||||
text: &str,
|
||||
) -> Result<FixtureDocument, Box<dyn std::error::Error>> {
|
||||
load_fixture_document_from_str_with_base(text, Path::new("."))
|
||||
}
|
||||
|
||||
pub fn load_fixture_document_from_str_with_base(
|
||||
text: &str,
|
||||
base_dir: &Path,
|
||||
) -> Result<FixtureDocument, Box<dyn std::error::Error>> {
|
||||
let raw: RawFixtureDocument = serde_json::from_str(text)?;
|
||||
resolve_raw_fixture_document(raw, base_dir)
|
||||
}
|
||||
|
||||
fn resolve_raw_fixture_document(
|
||||
raw: RawFixtureDocument,
|
||||
base_dir: &Path,
|
||||
) -> Result<FixtureDocument, Box<dyn std::error::Error>> {
|
||||
let state = match (&raw.state, &raw.state_snapshot_path) {
|
||||
(Some(_), Some(_)) => {
|
||||
return Err(
|
||||
"fixture must not specify both inline state and state_snapshot_path".into(),
|
||||
);
|
||||
}
|
||||
(None, None) => {
|
||||
return Err("fixture must specify either inline state or state_snapshot_path".into());
|
||||
}
|
||||
(Some(state), None) => state.clone(),
|
||||
(None, Some(snapshot_path)) => {
|
||||
let snapshot_path = resolve_snapshot_path(base_dir, snapshot_path);
|
||||
let snapshot = load_runtime_snapshot_document(&snapshot_path)?;
|
||||
validate_runtime_snapshot_document(&snapshot).map_err(|err| {
|
||||
format!(
|
||||
"invalid runtime snapshot {}: {err}",
|
||||
snapshot_path.display()
|
||||
)
|
||||
})?;
|
||||
snapshot.state
|
||||
}
|
||||
};
|
||||
|
||||
let state_origin = match raw.state_snapshot_path {
|
||||
Some(snapshot_path) => FixtureStateOrigin::SnapshotPath(snapshot_path),
|
||||
None => FixtureStateOrigin::Inline,
|
||||
};
|
||||
|
||||
Ok(FixtureDocument {
|
||||
format_version: raw.format_version,
|
||||
fixture_id: raw.fixture_id,
|
||||
source: raw.source,
|
||||
state,
|
||||
state_origin,
|
||||
commands: raw.commands,
|
||||
expected_summary: raw.expected_summary,
|
||||
})
|
||||
}
|
||||
|
||||
fn resolve_snapshot_path(base_dir: &Path, snapshot_path: &str) -> PathBuf {
|
||||
let candidate = PathBuf::from(snapshot_path);
|
||||
if candidate.is_absolute() {
|
||||
candidate
|
||||
} else {
|
||||
base_dir.join(candidate)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::FixtureStateOrigin;
|
||||
use rrt_runtime::{
|
||||
CalendarPoint, RuntimeServiceState, RuntimeSnapshotDocument, RuntimeSnapshotSource,
|
||||
RuntimeState, SNAPSHOT_FORMAT_VERSION, save_runtime_snapshot_document,
|
||||
};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
#[test]
|
||||
fn loads_fixture_from_relative_snapshot_path() {
|
||||
let nonce = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.expect("system time should be after epoch")
|
||||
.as_nanos();
|
||||
let fixture_dir = std::env::temp_dir().join(format!("rrt-fixture-load-{nonce}"));
|
||||
std::fs::create_dir_all(&fixture_dir).expect("fixture dir should be created");
|
||||
|
||||
let snapshot_path = fixture_dir.join("state.json");
|
||||
let snapshot = RuntimeSnapshotDocument {
|
||||
format_version: SNAPSHOT_FORMAT_VERSION,
|
||||
snapshot_id: "snapshot-backed-fixture-state".to_string(),
|
||||
source: RuntimeSnapshotSource {
|
||||
source_fixture_id: Some("snapshot-backed-fixture".to_string()),
|
||||
description: Some("test snapshot".to_string()),
|
||||
},
|
||||
state: RuntimeState {
|
||||
calendar: CalendarPoint {
|
||||
year: 1830,
|
||||
month_slot: 0,
|
||||
phase_slot: 0,
|
||||
tick_slot: 5,
|
||||
},
|
||||
world_flags: BTreeMap::new(),
|
||||
companies: Vec::new(),
|
||||
event_runtime_records: Vec::new(),
|
||||
service_state: RuntimeServiceState::default(),
|
||||
},
|
||||
};
|
||||
save_runtime_snapshot_document(&snapshot_path, &snapshot).expect("snapshot should save");
|
||||
|
||||
let fixture_json = r#"
|
||||
{
|
||||
"format_version": 1,
|
||||
"fixture_id": "snapshot-backed-fixture",
|
||||
"source": {
|
||||
"kind": "captured-runtime"
|
||||
},
|
||||
"state_snapshot_path": "state.json",
|
||||
"commands": [
|
||||
{
|
||||
"kind": "step_count",
|
||||
"steps": 1
|
||||
}
|
||||
],
|
||||
"expected_summary": {
|
||||
"calendar": {
|
||||
"year": 1830,
|
||||
"month_slot": 0,
|
||||
"phase_slot": 0,
|
||||
"tick_slot": 6
|
||||
},
|
||||
"world_flag_count": 0,
|
||||
"company_count": 0,
|
||||
"event_runtime_record_count": 0,
|
||||
"total_company_cash": 0
|
||||
}
|
||||
}
|
||||
"#;
|
||||
|
||||
let fixture = load_fixture_document_from_str_with_base(fixture_json, &fixture_dir)
|
||||
.expect("snapshot-backed fixture should load");
|
||||
|
||||
assert_eq!(
|
||||
fixture.state_origin,
|
||||
FixtureStateOrigin::SnapshotPath("state.json".to_string())
|
||||
);
|
||||
assert_eq!(fixture.state.calendar.tick_slot, 5);
|
||||
|
||||
let _ = std::fs::remove_file(snapshot_path);
|
||||
let _ = std::fs::remove_dir(fixture_dir);
|
||||
}
|
||||
}
|
||||
7
crates/rrt-fixtures/src/normalize.rs
Normal file
7
crates/rrt-fixtures/src/normalize.rs
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
use serde_json::Value;
|
||||
|
||||
use rrt_runtime::RuntimeState;
|
||||
|
||||
pub fn normalize_runtime_state(state: &RuntimeState) -> Result<Value, Box<dyn std::error::Error>> {
|
||||
Ok(serde_json::to_value(state)?)
|
||||
}
|
||||
277
crates/rrt-fixtures/src/schema.rs
Normal file
277
crates/rrt-fixtures/src/schema.rs
Normal file
|
|
@ -0,0 +1,277 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use rrt_runtime::{RuntimeState, RuntimeSummary, StepCommand};
|
||||
|
||||
pub const FIXTURE_FORMAT_VERSION: u32 = 1;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
pub struct FixtureSource {
|
||||
pub kind: String,
|
||||
#[serde(default)]
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
pub struct ExpectedRuntimeSummary {
|
||||
#[serde(default)]
|
||||
pub calendar: Option<rrt_runtime::CalendarPoint>,
|
||||
#[serde(default)]
|
||||
pub world_flag_count: Option<usize>,
|
||||
#[serde(default)]
|
||||
pub company_count: Option<usize>,
|
||||
#[serde(default)]
|
||||
pub event_runtime_record_count: Option<usize>,
|
||||
#[serde(default)]
|
||||
pub total_event_record_service_count: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub periodic_boundary_call_count: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub total_trigger_dispatch_count: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub dirty_rerun_count: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub total_company_cash: Option<i64>,
|
||||
}
|
||||
|
||||
impl ExpectedRuntimeSummary {
|
||||
pub fn compare(&self, actual: &RuntimeSummary) -> Vec<String> {
|
||||
let mut mismatches = Vec::new();
|
||||
|
||||
if let Some(calendar) = self.calendar {
|
||||
if actual.calendar != calendar {
|
||||
mismatches.push(format!(
|
||||
"calendar mismatch: expected {:?}, got {:?}",
|
||||
calendar, actual.calendar
|
||||
));
|
||||
}
|
||||
}
|
||||
if let Some(count) = self.world_flag_count {
|
||||
if actual.world_flag_count != count {
|
||||
mismatches.push(format!(
|
||||
"world_flag_count mismatch: expected {count}, got {}",
|
||||
actual.world_flag_count
|
||||
));
|
||||
}
|
||||
}
|
||||
if let Some(count) = self.company_count {
|
||||
if actual.company_count != count {
|
||||
mismatches.push(format!(
|
||||
"company_count mismatch: expected {count}, got {}",
|
||||
actual.company_count
|
||||
));
|
||||
}
|
||||
}
|
||||
if let Some(count) = self.event_runtime_record_count {
|
||||
if actual.event_runtime_record_count != count {
|
||||
mismatches.push(format!(
|
||||
"event_runtime_record_count mismatch: expected {count}, got {}",
|
||||
actual.event_runtime_record_count
|
||||
));
|
||||
}
|
||||
}
|
||||
if let Some(count) = self.total_event_record_service_count {
|
||||
if actual.total_event_record_service_count != count {
|
||||
mismatches.push(format!(
|
||||
"total_event_record_service_count mismatch: expected {count}, got {}",
|
||||
actual.total_event_record_service_count
|
||||
));
|
||||
}
|
||||
}
|
||||
if let Some(count) = self.periodic_boundary_call_count {
|
||||
if actual.periodic_boundary_call_count != count {
|
||||
mismatches.push(format!(
|
||||
"periodic_boundary_call_count mismatch: expected {count}, got {}",
|
||||
actual.periodic_boundary_call_count
|
||||
));
|
||||
}
|
||||
}
|
||||
if let Some(count) = self.total_trigger_dispatch_count {
|
||||
if actual.total_trigger_dispatch_count != count {
|
||||
mismatches.push(format!(
|
||||
"total_trigger_dispatch_count mismatch: expected {count}, got {}",
|
||||
actual.total_trigger_dispatch_count
|
||||
));
|
||||
}
|
||||
}
|
||||
if let Some(count) = self.dirty_rerun_count {
|
||||
if actual.dirty_rerun_count != count {
|
||||
mismatches.push(format!(
|
||||
"dirty_rerun_count mismatch: expected {count}, got {}",
|
||||
actual.dirty_rerun_count
|
||||
));
|
||||
}
|
||||
}
|
||||
if let Some(total) = self.total_company_cash {
|
||||
if actual.total_company_cash != total {
|
||||
mismatches.push(format!(
|
||||
"total_company_cash mismatch: expected {total}, got {}",
|
||||
actual.total_company_cash
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
mismatches
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct FixtureDocument {
|
||||
pub format_version: u32,
|
||||
pub fixture_id: String,
|
||||
#[serde(default)]
|
||||
pub source: FixtureSource,
|
||||
pub state: RuntimeState,
|
||||
pub state_origin: FixtureStateOrigin,
|
||||
#[serde(default)]
|
||||
pub commands: Vec<StepCommand>,
|
||||
#[serde(default)]
|
||||
pub expected_summary: ExpectedRuntimeSummary,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum FixtureStateOrigin {
|
||||
Inline,
|
||||
SnapshotPath(String),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct RawFixtureDocument {
|
||||
pub format_version: u32,
|
||||
pub fixture_id: String,
|
||||
#[serde(default)]
|
||||
pub source: FixtureSource,
|
||||
#[serde(default)]
|
||||
pub state: Option<RuntimeState>,
|
||||
#[serde(default)]
|
||||
pub state_snapshot_path: Option<String>,
|
||||
#[serde(default)]
|
||||
pub commands: Vec<StepCommand>,
|
||||
#[serde(default)]
|
||||
pub expected_summary: ExpectedRuntimeSummary,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct FixtureValidationReport {
|
||||
pub fixture_id: String,
|
||||
pub valid: bool,
|
||||
pub issue_count: usize,
|
||||
pub issues: Vec<String>,
|
||||
}
|
||||
|
||||
pub fn validate_fixture_document(document: &FixtureDocument) -> FixtureValidationReport {
|
||||
let mut issues = Vec::new();
|
||||
|
||||
if document.format_version != FIXTURE_FORMAT_VERSION {
|
||||
issues.push(format!(
|
||||
"unsupported format_version {} (expected {})",
|
||||
document.format_version, FIXTURE_FORMAT_VERSION
|
||||
));
|
||||
}
|
||||
if document.fixture_id.trim().is_empty() {
|
||||
issues.push("fixture_id must not be empty".to_string());
|
||||
}
|
||||
if document.source.kind.trim().is_empty() {
|
||||
issues.push("source.kind must not be empty".to_string());
|
||||
}
|
||||
if document.commands.is_empty() {
|
||||
issues.push("fixture must contain at least one command".to_string());
|
||||
}
|
||||
if let Err(err) = document.state.validate() {
|
||||
issues.push(format!("invalid runtime state: {err}"));
|
||||
}
|
||||
|
||||
for (index, command) in document.commands.iter().enumerate() {
|
||||
if let Err(err) = command.validate() {
|
||||
issues.push(format!("invalid command at index {index}: {err}"));
|
||||
}
|
||||
}
|
||||
|
||||
FixtureValidationReport {
|
||||
fixture_id: document.fixture_id.clone(),
|
||||
valid: issues.is_empty(),
|
||||
issue_count: issues.len(),
|
||||
issues,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::load_fixture_document_from_str;
|
||||
|
||||
const FIXTURE_JSON: &str = r#"
|
||||
{
|
||||
"format_version": 1,
|
||||
"fixture_id": "minimal-world-step-smoke",
|
||||
"source": {
|
||||
"kind": "synthetic",
|
||||
"description": "basic milestone parser smoke fixture"
|
||||
},
|
||||
"state": {
|
||||
"calendar": {
|
||||
"year": 1830,
|
||||
"month_slot": 0,
|
||||
"phase_slot": 0,
|
||||
"tick_slot": 0
|
||||
},
|
||||
"world_flags": {
|
||||
"sandbox": false
|
||||
},
|
||||
"companies": [
|
||||
{
|
||||
"company_id": 1,
|
||||
"current_cash": 250000,
|
||||
"debt": 0
|
||||
}
|
||||
],
|
||||
"event_runtime_records": [],
|
||||
"service_state": {
|
||||
"periodic_boundary_calls": 0,
|
||||
"trigger_dispatch_counts": {},
|
||||
"total_event_record_services": 0,
|
||||
"dirty_rerun_count": 0
|
||||
}
|
||||
},
|
||||
"commands": [
|
||||
{
|
||||
"kind": "advance_to",
|
||||
"calendar": {
|
||||
"year": 1830,
|
||||
"month_slot": 0,
|
||||
"phase_slot": 0,
|
||||
"tick_slot": 2
|
||||
}
|
||||
}
|
||||
],
|
||||
"expected_summary": {
|
||||
"calendar": {
|
||||
"year": 1830,
|
||||
"month_slot": 0,
|
||||
"phase_slot": 0,
|
||||
"tick_slot": 2
|
||||
},
|
||||
"world_flag_count": 1,
|
||||
"company_count": 1,
|
||||
"event_runtime_record_count": 0,
|
||||
"total_company_cash": 250000
|
||||
}
|
||||
}
|
||||
"#;
|
||||
|
||||
#[test]
|
||||
fn parses_and_validates_fixture() {
|
||||
let fixture = load_fixture_document_from_str(FIXTURE_JSON).expect("fixture should parse");
|
||||
let report = validate_fixture_document(&fixture);
|
||||
assert!(report.valid, "report should be valid: {:?}", report.issues);
|
||||
assert_eq!(fixture.state_origin, FixtureStateOrigin::Inline);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compares_expected_summary() {
|
||||
let fixture = load_fixture_document_from_str(FIXTURE_JSON).expect("fixture should parse");
|
||||
let summary = RuntimeSummary::from_state(&fixture.state);
|
||||
let mismatches = fixture.expected_summary.compare(&summary);
|
||||
assert_eq!(mismatches.len(), 1);
|
||||
assert!(mismatches[0].contains("calendar mismatch"));
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue