diff --git a/packages/devkit/src/cli/export.rs b/packages/devkit/src/cli/export.rs index a7f7815..840dfba 100644 --- a/packages/devkit/src/cli/export.rs +++ b/packages/devkit/src/cli/export.rs @@ -54,7 +54,6 @@ impl Window { pub struct Export; impl Export { - /// Serialize fee points to CSV. /// Filter points by window relative to the latest timestamp. pub fn filter_window(points: &[FeePoint], window: Window) -> &[FeePoint] { match window.cutoff_seconds() { @@ -124,6 +123,15 @@ mod tests { ] } + fn sample() -> Vec { + vec![FeePoint { + timestamp: 1000, + fee: 100, + ledger: 1, + is_spike: false, + }] + } + #[test] fn window_1h_filters() { let p = pts(); diff --git a/packages/devkit/src/harness/horizon_mock.rs b/packages/devkit/src/harness/horizon_mock.rs index dfeddde..4404e1f 100644 --- a/packages/devkit/src/harness/horizon_mock.rs +++ b/packages/devkit/src/harness/horizon_mock.rs @@ -12,6 +12,8 @@ pub struct HorizonMock { /// Optional canned JSON response for `GET /fee_stats`. When set, takes /// precedence over `scenario_path` and the convention-based file path. pub fee_stats_response: Option, + /// Unix timestamp when this mock was created (for uptime calculation). + pub start_time: u64, } impl HorizonMock { @@ -22,6 +24,7 @@ impl HorizonMock { scenario_path: None, error_rate: 0.0, fee_stats_response: None, + start_time: current_unix_secs(), } } @@ -80,7 +83,11 @@ impl HorizonMock { /// Returns the JSON body for `GET /health`. pub fn health_payload(&self) -> String { - format!(r#"{{"status":"ok","scenario":"{}"}}"#, self.scenario) + let uptime = current_unix_secs().saturating_sub(self.start_time); + format!( + r#"{{"status":"ok","scenario":"{}","uptime_secs":{}}}"#, + self.scenario, uptime + ) } /// Loads and returns the scenario JSON to be served at `GET /fee_stats`. @@ -125,6 +132,10 @@ pub struct HorizonMockConfig { pub delay_ms: u64, /// Probability [0.0, 1.0] of injecting a 500 error response. pub error_rate: f64, + /// Interval in seconds between automatic scenario rotations (0 = disabled). + pub rotate_secs: u64, + /// Ordered list of scenario names to rotate through (used when rotate_secs > 0). + pub rotation_scenarios: Vec, } impl Default for HorizonMockConfig { @@ -134,6 +145,8 @@ impl Default for HorizonMockConfig { scenario_path: std::path::PathBuf::from("src/harness/scenarios/normal.json"), delay_ms: 0, error_rate: 0.0, + rotate_secs: 0, + rotation_scenarios: Vec::new(), } } } @@ -156,6 +169,7 @@ impl HorizonMock { scenario_path: Some(config.scenario_path), error_rate: config.error_rate, fee_stats_response: None, + start_time: current_unix_secs(), } } } @@ -163,8 +177,8 @@ impl HorizonMock { /// Starts an axum HTTP server serving mock Horizon responses. /// /// Routes: -/// - `GET /fee_stats` — returns scenario fee stats JSON -/// - `GET /health` — returns `{"status":"ok","scenario":""}` +/// - `GET /fee_stats` — returns scenario fee stats JSON, with optional delay and error injection +/// - `GET /health` — returns `{"status":"ok","scenario":"","uptime_secs":N}` /// /// Binds to `0.0.0.0:port`. Returns when the server shuts down. pub async fn serve(mock: std::sync::Arc, port: u16) -> std::io::Result<()> { @@ -180,6 +194,14 @@ pub async fn serve(mock: std::sync::Arc, port: u16) -> std::io::Res get(move || { let m = m1.clone(); async move { + m.apply_delay(); + if m.should_inject_error() { + return ( + axum::http::StatusCode::SERVICE_UNAVAILABLE, + [(axum::http::header::CONTENT_TYPE, "application/json")], + r#"{"error":"service unavailable"}"#.to_string(), + ); + } match m.fee_stats_payload() { Ok(json) => ( axum::http::StatusCode::OK, @@ -212,6 +234,14 @@ pub async fn serve(mock: std::sync::Arc, port: u16) -> std::io::Res .map_err(std::io::Error::other) } +/// Returns the current Unix timestamp in seconds. +fn current_unix_secs() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() +} + /// Minimal pseudo-random float in [0.0, 1.0) using system time as entropy. fn rand_f64() -> f64 { let nanos = std::time::SystemTime::now() diff --git a/packages/devkit/src/harness/scenarios/mod.rs b/packages/devkit/src/harness/scenarios/mod.rs index 916fca3..3ddf9fa 100644 --- a/packages/devkit/src/harness/scenarios/mod.rs +++ b/packages/devkit/src/harness/scenarios/mod.rs @@ -68,6 +68,10 @@ pub fn load_from_file(path: &Path) -> std::io::Result { pub struct ScenarioRotator { scenarios: Vec, index: usize, + /// How often (in seconds) to advance to the next scenario. 0 = manual only. + pub interval_secs: u64, + /// Unix timestamp of the last rotation. + last_rotated: u64, } impl ScenarioRotator { @@ -78,6 +82,18 @@ impl ScenarioRotator { Self { scenarios, index: 0, + interval_secs: 0, + last_rotated: current_unix_secs(), + } + } + + /// Creates a rotator that automatically advances every `interval_secs` seconds. + pub fn with_interval(scenarios: Vec, interval_secs: u64) -> Self { + Self { + scenarios, + index: 0, + interval_secs, + last_rotated: current_unix_secs(), } } @@ -88,6 +104,27 @@ impl ScenarioRotator { } let current = self.scenarios[self.index].as_str(); self.index = (self.index + 1) % self.scenarios.len(); + self.last_rotated = current_unix_secs(); Some(current) } + + /// Advances if the rotation interval has elapsed. Returns the new scenario name if rotated. + pub fn advance_if_due(&mut self) -> Option<&str> { + if self.interval_secs == 0 { + return None; + } + let elapsed = current_unix_secs().saturating_sub(self.last_rotated); + if elapsed >= self.interval_secs { + self.advance() + } else { + None + } + } +} + +fn current_unix_secs() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() }