Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 96 additions & 0 deletions packages/devkit/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`):
Expand Down
5 changes: 5 additions & 0 deletions packages/devkit/tests/common/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
pub mod setup;
pub mod teardown;

pub use setup::*;
pub use teardown::*;
58 changes: 58 additions & 0 deletions packages/devkit/tests/common/setup.rs
Original file line number Diff line number Diff line change
@@ -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()));
}
}
20 changes: 20 additions & 0 deletions packages/devkit/tests/common/teardown.rs
Original file line number Diff line number Diff line change
@@ -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()
);
}
}
49 changes: 49 additions & 0 deletions packages/devkit/tests/integration_csv_writer.rs
Original file line number Diff line number Diff line change
@@ -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");
}
37 changes: 37 additions & 0 deletions packages/devkit/tests/integration_db_factory.rs
Original file line number Diff line number Diff line change
@@ -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");
}
Loading