use std::path::{Path, PathBuf}; use rrt_runtime::{ load_runtime_save_slice_document, load_runtime_snapshot_document, load_runtime_state_import, project_save_slice_to_runtime_state_import, validate_runtime_save_slice_document, validate_runtime_snapshot_document, }; use crate::{FixtureDocument, FixtureStateOrigin, RawFixtureDocument}; pub fn load_fixture_document(path: &Path) -> Result> { 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> { 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> { 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> { let specified_state_inputs = usize::from(raw.state.is_some()) + usize::from(raw.state_snapshot_path.is_some()) + usize::from(raw.state_save_slice_path.is_some()) + usize::from(raw.state_import_path.is_some()); if specified_state_inputs != 1 { return Err( "fixture must specify exactly one of inline state, state_snapshot_path, state_save_slice_path, or state_import_path" .into(), ); } let state = match ( &raw.state, &raw.state_snapshot_path, &raw.state_save_slice_path, &raw.state_import_path, ) { (Some(state), None, None, None) => state.clone(), (None, Some(snapshot_path), None, None) => { 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 } (None, None, Some(save_slice_path), None) => { let save_slice_path = resolve_snapshot_path(base_dir, save_slice_path); let document = load_runtime_save_slice_document(&save_slice_path)?; validate_runtime_save_slice_document(&document).map_err(|err| { format!( "invalid runtime save slice document {}: {err}", save_slice_path.display() ) })?; project_save_slice_to_runtime_state_import( &document.save_slice, &document.save_slice_id, document.source.description.clone(), ) .map_err(|err| { format!( "failed to project runtime save slice document {}: {err}", save_slice_path.display() ) })? .state } (None, None, None, Some(import_path)) => { let import_path = resolve_snapshot_path(base_dir, import_path); load_runtime_state_import(&import_path) .map_err(|err| { format!( "failed to load runtime import {}: {err}", import_path.display() ) })? .state } _ => unreachable!("state input exclusivity checked above"), }; let state_origin = match ( raw.state_snapshot_path, raw.state_save_slice_path, raw.state_import_path, ) { (Some(snapshot_path), None, None) => FixtureStateOrigin::SnapshotPath(snapshot_path), (None, Some(save_slice_path), None) => FixtureStateOrigin::SaveSlicePath(save_slice_path), (None, None, Some(import_path)) => FixtureStateOrigin::ImportPath(import_path), _ => 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, expected_state_fragment: raw.expected_state_fragment, }) } 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, OVERLAY_IMPORT_DOCUMENT_FORMAT_VERSION, RuntimeOverlayImportDocument, RuntimeOverlayImportDocumentSource, RuntimeSaveProfileState, RuntimeSaveSliceDocument, RuntimeSaveSliceDocumentSource, RuntimeServiceState, RuntimeSnapshotDocument, RuntimeSnapshotSource, RuntimeState, RuntimeTrackPieceCounts, RuntimeWorldRestoreState, SAVE_SLICE_DOCUMENT_FORMAT_VERSION, SNAPSHOT_FORMAT_VERSION, save_runtime_overlay_import_document, save_runtime_save_slice_document, 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(), save_profile: RuntimeSaveProfileState::default(), world_restore: RuntimeWorldRestoreState::default(), metadata: BTreeMap::new(), companies: Vec::new(), selected_company_id: None, players: Vec::new(), selected_player_id: None, chairman_profiles: Vec::new(), selected_chairman_profile_id: None, trains: Vec::new(), locomotive_catalog: Vec::new(), cargo_catalog: Vec::new(), territories: Vec::new(), company_territory_track_piece_counts: Vec::new(), company_territory_access: Vec::new(), packed_event_collection: None, event_runtime_records: Vec::new(), candidate_availability: BTreeMap::new(), named_locomotive_availability: BTreeMap::new(), named_locomotive_cost: BTreeMap::new(), all_cargo_price_override: None, named_cargo_price_overrides: BTreeMap::new(), all_cargo_production_override: None, factory_cargo_production_override: None, farm_mine_cargo_production_override: None, named_cargo_production_overrides: BTreeMap::new(), cargo_production_overrides: BTreeMap::new(), world_runtime_variables: BTreeMap::new(), company_runtime_variables: BTreeMap::new(), player_runtime_variables: BTreeMap::new(), territory_runtime_variables: BTreeMap::new(), world_scalar_overrides: BTreeMap::new(), special_conditions: BTreeMap::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); } #[test] fn loads_fixture_from_relative_save_slice_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-save-slice-{nonce}")); std::fs::create_dir_all(&fixture_dir).expect("fixture dir should be created"); let save_slice_path = fixture_dir.join("state-save-slice.json"); let save_slice = RuntimeSaveSliceDocument { format_version: SAVE_SLICE_DOCUMENT_FORMAT_VERSION, save_slice_id: "save-slice-backed-fixture-state".to_string(), source: RuntimeSaveSliceDocumentSource { description: Some("test save slice".to_string()), original_save_filename: Some("fixture.gms".to_string()), original_save_sha256: None, notes: vec![], }, save_slice: rrt_runtime::SmpLoadedSaveSlice { file_extension_hint: Some("gms".to_string()), container_profile_family: Some("rt3-classic-save-container-v1".to_string()), mechanism_family: "classic-save-rehydrate-v1".to_string(), mechanism_confidence: "grounded".to_string(), trailer_family: None, bridge_family: None, profile: None, candidate_availability_table: None, named_locomotive_availability_table: None, locomotive_catalog: None, cargo_catalog: None, world_issue_37_state: None, world_economic_tuning_state: None, world_finance_neighborhood_state: None, world_locomotive_policy_state: None, company_roster: None, chairman_profile_table: None, special_conditions_table: None, event_runtime_collection: None, notes: vec![], }, }; save_runtime_save_slice_document(&save_slice_path, &save_slice) .expect("save slice should save"); let fixture_json = r#" { "format_version": 1, "fixture_id": "save-slice-backed-fixture", "source": { "kind": "captured-runtime" }, "state_save_slice_path": "state-save-slice.json", "commands": [], "expected_summary": { "calendar_projection_is_placeholder": true, "packed_event_collection_present": false } } "#; let fixture = load_fixture_document_from_str_with_base(fixture_json, &fixture_dir) .expect("save-slice-backed fixture should load"); assert_eq!( fixture.state_origin, FixtureStateOrigin::SaveSlicePath("state-save-slice.json".to_string()) ); assert_eq!( fixture .state .metadata .get("save_slice.import_projection") .map(String::as_str), Some("partial-runtime-restore-v1") ); let _ = std::fs::remove_file(save_slice_path); let _ = std::fs::remove_dir(fixture_dir); } #[test] fn loads_fixture_from_relative_import_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-import-{nonce}")); std::fs::create_dir_all(&fixture_dir).expect("fixture dir should be created"); let snapshot_path = fixture_dir.join("base.json"); let save_slice_path = fixture_dir.join("slice.json"); let import_path = fixture_dir.join("overlay.json"); let snapshot = RuntimeSnapshotDocument { format_version: SNAPSHOT_FORMAT_VERSION, snapshot_id: "base".to_string(), source: RuntimeSnapshotSource::default(), state: RuntimeState { calendar: CalendarPoint { year: 1830, month_slot: 0, phase_slot: 0, tick_slot: 5, }, world_flags: BTreeMap::new(), save_profile: RuntimeSaveProfileState::default(), world_restore: RuntimeWorldRestoreState::default(), metadata: BTreeMap::new(), companies: vec![rrt_runtime::RuntimeCompany { company_id: 42, controller_kind: rrt_runtime::RuntimeCompanyControllerKind::Human, current_cash: 100, debt: 0, credit_rating_score: None, prime_rate: None, track_piece_counts: RuntimeTrackPieceCounts::default(), active: true, available_track_laying_capacity: None, linked_chairman_profile_id: None, book_value_per_share: 0, investor_confidence: 0, management_attitude: 0, takeover_cooldown_year: None, merger_cooldown_year: None, }], selected_company_id: Some(42), players: Vec::new(), selected_player_id: None, chairman_profiles: Vec::new(), selected_chairman_profile_id: None, trains: Vec::new(), locomotive_catalog: Vec::new(), cargo_catalog: Vec::new(), territories: Vec::new(), company_territory_track_piece_counts: Vec::new(), company_territory_access: Vec::new(), packed_event_collection: None, event_runtime_records: Vec::new(), candidate_availability: BTreeMap::new(), named_locomotive_availability: BTreeMap::new(), named_locomotive_cost: BTreeMap::new(), all_cargo_price_override: None, named_cargo_price_overrides: BTreeMap::new(), all_cargo_production_override: None, factory_cargo_production_override: None, farm_mine_cargo_production_override: None, named_cargo_production_overrides: BTreeMap::new(), cargo_production_overrides: BTreeMap::new(), world_runtime_variables: BTreeMap::new(), company_runtime_variables: BTreeMap::new(), player_runtime_variables: BTreeMap::new(), territory_runtime_variables: BTreeMap::new(), world_scalar_overrides: BTreeMap::new(), special_conditions: BTreeMap::new(), service_state: RuntimeServiceState::default(), }, }; save_runtime_snapshot_document(&snapshot_path, &snapshot).expect("snapshot should save"); let save_slice = RuntimeSaveSliceDocument { format_version: SAVE_SLICE_DOCUMENT_FORMAT_VERSION, save_slice_id: "slice".to_string(), source: RuntimeSaveSliceDocumentSource::default(), save_slice: rrt_runtime::SmpLoadedSaveSlice { file_extension_hint: Some("gms".to_string()), container_profile_family: Some("rt3-classic-save-container-v1".to_string()), mechanism_family: "classic-save-rehydrate-v1".to_string(), mechanism_confidence: "grounded".to_string(), trailer_family: None, bridge_family: None, profile: None, candidate_availability_table: None, named_locomotive_availability_table: None, locomotive_catalog: None, cargo_catalog: None, world_issue_37_state: None, world_economic_tuning_state: None, world_finance_neighborhood_state: None, world_locomotive_policy_state: None, company_roster: None, chairman_profile_table: None, special_conditions_table: None, event_runtime_collection: Some( rrt_runtime::SmpLoadedEventRuntimeCollectionSummary { source_kind: "packed-event-runtime-collection".to_string(), mechanism_family: "classic-save-rehydrate-v1".to_string(), mechanism_confidence: "grounded".to_string(), container_profile_family: Some("rt3-classic-save-container-v1".to_string()), metadata_tag_offset: 0x7100, records_tag_offset: 0x7200, close_tag_offset: 0x7600, packed_state_version: 0x3e9, packed_state_version_hex: "0x000003e9".to_string(), live_id_bound: 7, live_record_count: 1, live_entry_ids: vec![7], decoded_record_count: 1, imported_runtime_record_count: 0, records: vec![rrt_runtime::SmpLoadedPackedEventRecordSummary { record_index: 0, live_entry_id: 7, payload_offset: Some(0x7202), payload_len: Some(48), decode_status: "parity_only".to_string(), payload_family: "synthetic_harness".to_string(), trigger_kind: Some(7), active: Some(true), marks_collection_dirty: Some(false), one_shot: Some(false), compact_control: None, text_bands: vec![], standalone_condition_row_count: 0, standalone_condition_rows: vec![], negative_sentinel_scope: None, grouped_effect_row_counts: vec![0, 0, 0, 0], grouped_effect_rows: vec![], decoded_conditions: Vec::new(), decoded_actions: vec![rrt_runtime::RuntimeEffect::AdjustCompanyCash { target: rrt_runtime::RuntimeCompanyTarget::Ids { ids: vec![42] }, delta: 25, }], executable_import_ready: false, notes: vec![], }], }, ), notes: vec![], }, }; save_runtime_save_slice_document(&save_slice_path, &save_slice) .expect("save slice should save"); let overlay = RuntimeOverlayImportDocument { format_version: OVERLAY_IMPORT_DOCUMENT_FORMAT_VERSION, import_id: "overlay-backed-fixture-state".to_string(), source: RuntimeOverlayImportDocumentSource::default(), base_snapshot_path: "base.json".to_string(), save_slice_path: "slice.json".to_string(), }; save_runtime_overlay_import_document(&import_path, &overlay) .expect("overlay import should save"); let fixture_json = r#" { "format_version": 1, "fixture_id": "overlay-backed-fixture", "source": { "kind": "captured-runtime" }, "state_import_path": "overlay.json", "commands": [ { "kind": "service_trigger_kind", "trigger_kind": 7 } ], "expected_summary": { "company_count": 1, "packed_event_imported_runtime_record_count": 1 } } "#; let fixture = load_fixture_document_from_str_with_base(fixture_json, &fixture_dir) .expect("overlay-backed fixture should load"); assert_eq!( fixture.state_origin, FixtureStateOrigin::ImportPath("overlay.json".to_string()) ); assert_eq!(fixture.state.event_runtime_records.len(), 1); let _ = std::fs::remove_file(snapshot_path); let _ = std::fs::remove_file(save_slice_path); let _ = std::fs::remove_file(import_path); let _ = std::fs::remove_dir(fixture_dir); } }