diff --git a/packages/devkit/README.md b/packages/devkit/README.md index ea08c49..6d90a2b 100644 --- a/packages/devkit/README.md +++ b/packages/devkit/README.md @@ -214,6 +214,102 @@ devkit mock --port 8080 --scenario spike stellar-devkit = { path = "../devkit" } ``` +## Sandbox + +The `sandbox` module provides a scenario-driven testing DSL for simulating Stellar fee environments in isolation. It lets you compose network conditions, inject fixtures, and run assertions against simulated fee streams. + +### Scenarios + +Use `Scenario::builder()` to construct reproducible fee scenarios via a builder DSL: + +```rust +use stellar_devkit::sandbox::Scenario; + +let scenario = Scenario::builder("my_test") + .with_base_fee(200) + .with_spike_probability(0.15) + .with_ledger_count(500) + .with_seed(42) + .build(); +``` + +Each builder method configures a dimension of the simulation. The builder enforces valid ranges and returns errors for out-of-bound values. Once built, a `Scenario` is immutable and can be reused across multiple simulation runs. + +### Fixtures + +Pre-built fixture profiles capture common network states: + +| Fixture | Description | +|---|---| +| `Normal` | Low-fee baseline with minimal spikes; ideal for regression testing | +| `Congested` | Sustained high-fee demand with elevated base fees | +| `HighVariance` | Wide fee swings and frequent spikes; tests volatility handling | +| `Recovery` | Starts congested and gradually returns to baseline; tests cooldown logic | +| `Spike` | Mostly calm with a single sharp fee spike; tests spike detection | + +Load a fixture by name: + +```rust +use stellar_devkit::sandbox::fixtures::{Fixture, load_fixture}; + +let fixture = load_fixture(Fixture::Congested); +let scenario = fixture.into_scenario(); +``` + +### Runner + +The `Runner` executes a sandbox closure against a scenario, managing the simulation lifecycle: + +```rust +use stellar_devkit::sandbox::{Scenario, Runner}; + +let scenario = Scenario::builder("example") + .with_ledger_count(100) + .build(); + +Runner::new(scenario).run(|ctx| { + // ctx provides access to generated fees, timestamps, and metadata + assert!(!ctx.fees().is_empty()); + println!("Simulated {} ledgers", ctx.fees().len()); +}); +``` + +The runner handles RNG seeding, timestamp generation, and fee model execution internally. The closure receives a `SandboxContext` with read access to all simulation outputs. + +### Assertion helpers + +The sandbox provides built-in assertion helpers for common test validations: + +```rust +use stellar_devkit::sandbox::assertions::*; + +// Assert all fees fall within an expected range +assert_fee_in_range(&fees, min_stroops, max_stroops); + +// Assert the number of spikes matches expectations +assert_spike_count(&fees, expected_count); + +// Assert the quality score exceeds a threshold (0.0–1.0) +assert_quality_score_above(&fees, 0.8); +``` + +### Time travel + +Control simulated time to test time-dependent logic: + +```rust +use stellar_devkit::sandbox::time::*; + +// Advance the simulation clock by a duration +advance_time(Duration::from_secs(3600)); + +// Set the clock to an absolute timestamp +set_time(1_700_000_000); + +// Read the current simulated time +let now = current_time(); +``` + ## Benchmarks Baseline results measured on reference hardware (Apple M-series, single-core, `cargo bench`): diff --git a/packages/devkit/tests/common/mod.rs b/packages/devkit/tests/common/mod.rs new file mode 100644 index 0000000..9199ad4 --- /dev/null +++ b/packages/devkit/tests/common/mod.rs @@ -0,0 +1,5 @@ +pub mod setup; +pub mod teardown; + +pub use setup::*; +pub use teardown::*; diff --git a/packages/devkit/tests/common/setup.rs b/packages/devkit/tests/common/setup.rs new file mode 100644 index 0000000..801bc7f --- /dev/null +++ b/packages/devkit/tests/common/setup.rs @@ -0,0 +1,58 @@ +use std::path::{Path, PathBuf}; +use tempfile::TempDir; + +pub struct TestContext { + pub temp_dir: TempDir, + pub fixtures_dir: PathBuf, +} + +impl TestContext { + pub fn new() -> Self { + let temp_dir = tempfile::tempdir().expect("Failed to create temp dir"); + let fixtures_dir = temp_dir.path().join("fixtures"); + std::fs::create_dir_all(&fixtures_dir).expect("Failed to create fixtures dir"); + + Self { + temp_dir, + fixtures_dir, + } + } + + pub fn fixture_path(&self, name: &str) -> PathBuf { + self.fixtures_dir.join(name) + } + + pub fn write_fixture(&self, name: &str, content: &str) -> PathBuf { + let path = self.fixture_path(name); + std::fs::write(&path, content).expect("Failed to write fixture"); + path + } +} + +pub struct TestDatabase { + path: PathBuf, +} + +impl TestDatabase { + pub fn new() -> Self { + let path = std::env::temp_dir().join(format!("test_{}.db", uuid::Uuid::new_v4())); + Self { path } + } + + pub fn path(&self) -> &Path { + &self.path + } + + pub fn connection_string(&self) -> String { + format!("sqlite:{}", self.path.display()) + } +} + +impl Drop for TestDatabase { + fn drop(&mut self) { + let _ = std::fs::remove_file(&self.path); + let _ = std::fs::remove_file(format!("{}-journal", self.path.display())); + let _ = std::fs::remove_file(format!("{}-wal", self.path.display())); + let _ = std::fs::remove_file(format!("{}-shm", self.path.display())); + } +} diff --git a/packages/devkit/tests/common/teardown.rs b/packages/devkit/tests/common/teardown.rs new file mode 100644 index 0000000..04a4985 --- /dev/null +++ b/packages/devkit/tests/common/teardown.rs @@ -0,0 +1,20 @@ +use std::path::Path; + +pub fn cleanup_test_artifacts(dir: &Path) { + if dir.exists() { + let _ = std::fs::remove_dir_all(dir); + } +} + +pub fn assert_no_temp_files_remaining(dir: &Path) { + if dir.exists() { + let entries: Vec<_> = std::fs::read_dir(dir) + .map(|e| e.filter_map(|e| e.ok()).collect()) + .unwrap_or_default(); + assert!( + entries.is_empty(), + "Temp directory should be clean but has {} entries", + entries.len() + ); + } +} diff --git a/packages/devkit/tests/integration_csv_writer.rs b/packages/devkit/tests/integration_csv_writer.rs new file mode 100644 index 0000000..8278cbb --- /dev/null +++ b/packages/devkit/tests/integration_csv_writer.rs @@ -0,0 +1,49 @@ +use std::io::Write; +use tempfile::NamedTempFile; + +use stellar_devkit::io::csv::CsvWriter; + +#[test] +fn test_csv_writer_creates_valid_file() { + let mut csv_file = NamedTempFile::new().expect("Failed to create temp file"); + { + let mut writer = CsvWriter::new(&mut csv_file).expect("Failed to create CsvWriter"); + writer.write_header().expect("Failed to write header"); + for i in 0..10 { + writer + .write_row(stellar_devkit::io::csv::FeeRecord { + timestamp_ms: 1700000000000 + i * 6000, + fee_stroops: 100 + i as u64, + sequence: i as u64, + }) + .expect("Failed to write row"); + } + writer.flush().expect("Failed to flush"); + } + + let content = std::fs::read_to_string(csv_file.path()).expect("Failed to read file"); + let lines: Vec<&str> = content.lines().collect(); + assert_eq!(lines.len(), 11, "expected 1 header + 10 data rows"); + assert!(lines[0].contains("timestamp_ms"), "header should contain timestamp_ms"); +} + +#[test] +fn test_csv_writer_roundtrip() { + let mut csv_file = NamedTempFile::new().expect("Failed to create temp file"); + let record = stellar_devkit::io::csv::FeeRecord { + timestamp_ms: 1700000000000, + fee_stroops: 3849, + sequence: 42, + }; + { + let mut writer = CsvWriter::new(&mut csv_file).expect("Failed to create CsvWriter"); + writer.write_header().expect("Failed to write header"); + writer.write_row(record).expect("Failed to write row"); + writer.flush().expect("Failed to flush"); + } + + let content = std::fs::read_to_string(csv_file.path()).expect("Failed to read file"); + assert!(content.contains("1700000000000"), "should contain timestamp"); + assert!(content.contains("3849"), "should contain fee_stroops"); + assert!(content.contains("42"), "should contain sequence"); +} diff --git a/packages/devkit/tests/integration_db_factory.rs b/packages/devkit/tests/integration_db_factory.rs new file mode 100644 index 0000000..34b829a --- /dev/null +++ b/packages/devkit/tests/integration_db_factory.rs @@ -0,0 +1,37 @@ +use std::sync::Arc; +use std::path::PathBuf; + +#[test] +fn test_db_factory_creates_sqlite_database() { + let temp_dir = tempfile::tempdir().expect("Failed to create temp dir"); + let db_path = temp_dir.path().join("test_factory.db"); + + let conn_str = format!("sqlite:{}", db_path.display()); + + assert!(conn_str.starts_with("sqlite:"), "connection string should start with sqlite:"); + assert!( + conn_str.contains("test_factory.db"), + "connection string should contain database name" + ); +} + +#[test] +fn test_db_factory_in_memory_database() { + let conn_str = "sqlite::memory:"; + + assert!( + conn_str.contains("memory"), + "in-memory connection should contain 'memory'" + ); +} + +#[test] +fn test_db_factory_multiple_connections() { + let temp_dir = tempfile::tempdir().expect("Failed to create temp dir"); + let db_path = temp_dir.path().join("multi_conn.db"); + + let conn_str1 = format!("sqlite:{}", db_path.display()); + let conn_str2 = format!("sqlite:{}", db_path.display()); + + assert_eq!(conn_str1, conn_str2, "same path should produce same connection string"); +}