diff --git a/apexchainx_calculator/src/calculation.rs b/apexchainx_calculator/src/calculation.rs index 22f45e2..cae0ffe 100644 --- a/apexchainx_calculator/src/calculation.rs +++ b/apexchainx_calculator/src/calculation.rs @@ -306,7 +306,7 @@ fn set_count_lane(packed: u128, index: u32, value: u32) -> u128 { /// /// - **Lazy Reset Strategy**: Resets are non-blocking and lazy; counters are not automatically reset by background cron tasks. /// Instead, reset is triggered on the next `calculate_sla` invocation for that specific severity lane once 7 days have passed. -/// - **Lane Isolation**: Reset is per-severity lane. Calculations or inactivity in one severity level do not reset telemetry for other severities. +/// - **Per-Counter Isolation**: Resets are per-counter within each severity lane. Inactivity in calculation or violation counters resets only its respective counter (e.g., a stale calculation counter reset will not wipe a fresh violation counter). /// - **Reinitialization**: Upon reset, the lane's calculation and violation counters are cleared to 0 before the current invocation is recorded, /// reinitializing the count to 1 calculation (and 1 violation if the current calculation violated SLA). /// @@ -327,8 +327,10 @@ pub fn record_severity_telemetry(env: &Env, severity: &Symbol, met: bool) { let last_violation = count_lane(last_violations, index) as u64; let calc_stale = last_calc != 0 && now.saturating_sub(last_calc) >= week_seconds; let violation_stale = last_violation != 0 && now.saturating_sub(last_violation) >= week_seconds; - if calc_stale || violation_stale { + if calc_stale { calculations = set_count_lane(calculations, index, 0); + } + if violation_stale { violations = set_count_lane(violations, index, 0); } diff --git a/apexchainx_calculator/src/history.rs b/apexchainx_calculator/src/history.rs index 7457faa..edfeb03 100644 --- a/apexchainx_calculator/src/history.rs +++ b/apexchainx_calculator/src/history.rs @@ -72,11 +72,16 @@ pub fn prune_history(env: &Env, caller: &Address, keep_latest: u32) -> Result<() /// Prunes history entries older than `min_age_seconds`. /// Admin only. Emits a `pruned_a` event. +/// +/// Returns `Err(SLAError::InvalidInput)` if `min_age_seconds >= now`. pub fn prune_history_by_age(env: &Env, caller: &Address, min_age_seconds: u64) -> Result<(), SLAError> { crate::SLACalculatorContract::check_version(env)?; crate::SLACalculatorContract::require_admin(env, caller)?; let now = env.ledger().timestamp(); + if min_age_seconds >= now { + return Err(SLAError::InvalidInput); + } let cutoff = now.saturating_sub(min_age_seconds); let history: Vec = env @@ -192,7 +197,12 @@ pub fn get_history_page_with_meta(env: &Env, offset: u32, limit: u32) -> Result< }) } -/// Returns all history entries for a specific outage ID. +/// Returns all history entries for a specific outage ID in chronological order (oldest-first). +/// +/// When an outage has multiple entries across config generations (up to +/// `MAX_RECALCS_PER_OUTAGE`), each entry carries its `config_version_hash` +/// so consumers can match records to specific config generations. The final +/// entry in the returned array represents the latest decision. pub fn get_history_by_outage(env: &Env, outage_id: Symbol) -> Result, SLAError> { crate::SLACalculatorContract::check_version(env)?; let history: Vec = env diff --git a/apexchainx_calculator/src/lib.rs b/apexchainx_calculator/src/lib.rs index e6c885d..680d4d4 100644 --- a/apexchainx_calculator/src/lib.rs +++ b/apexchainx_calculator/src/lib.rs @@ -1691,18 +1691,7 @@ impl SLACalculatorContract { /// Returns a deterministic backend-friendly snapshot of all config values. pub fn get_config_snapshot(env: Env) -> Result { Self::check_version(&env)?; - - let mut entries = Vec::new(&env); - - for severity in Self::canonical_severities(&env) { - let config = Self::load_config(&env, &severity)?; - entries.push_back(SLAConfigEntry { severity, config }); - } - - Ok(SLAConfigSnapshot { - version: symbol_short!("v1"), - entries, - }) + Self::build_config_snapshot(&env) } /// Returns the config snapshot recorded for a given version hash, if any. (#408) @@ -1736,11 +1725,18 @@ impl SLACalculatorContract { /// Builds the canonical config snapshot (canonical severities only). (#408) fn build_config_snapshot(env: &Env) -> Result { + let configs: Map = env + .storage() + .instance() + .get(&CONFIG_KEY) + .ok_or(SLAError::NotInitialized)?; + let mut entries = Vec::new(env); for severity in Self::canonical_severities(env) { - let config = Self::load_config(env, &severity)?; + let config = configs.get(severity.clone()).ok_or(SLAError::ConfigNotFound)?; entries.push_back(SLAConfigEntry { severity, config }); } + Ok(SLAConfigSnapshot { version: symbol_short!("v1"), entries, @@ -1855,18 +1851,33 @@ impl SLACalculatorContract { } /// Returns the full audit state including roles, config, stats, and history. + /// + /// Performs a single version check and direct single-pass storage key reads + /// (admin, operator, pending slots, pause state/info, configs, stats, history len) + /// to eliminate redundant delegated version checks and storage key deserializations. pub fn get_full_audit_state(env: Env) -> Result { Self::check_version(&env)?; - let admin = Self::get_admin(env.clone())?; - let operator = Self::get_operator(env.clone())?; - let pending_admin = Self::get_pending_admin(env.clone())?; - let pending_operator = Self::get_pending_operator(env.clone())?; - let paused = Self::is_paused(env.clone())?; - let pause_info = Self::get_pause_info(env.clone())?; - let config_snapshot = Self::get_config_snapshot(env.clone())?; - let stats = Self::get_stats(env.clone())?; - let result_schema = Self::get_result_schema(env.clone())?; + let admin: Address = env + .storage() + .instance() + .get(&ADMIN_KEY) + .ok_or(SLAError::NotInitialized)?; + let operator: Address = env + .storage() + .instance() + .get(&OPERATOR_KEY) + .ok_or(SLAError::NotInitialized)?; + let pending_admin: Option
= env.storage().instance().get(&PENDING_ADMIN_KEY); + let pending_operator: Option
= env.storage().instance().get(&PENDING_OP_KEY); + let paused: bool = env.storage().instance().get(&PAUSED_KEY).unwrap_or(false); + let pause_info: Option = env.storage().instance().get(&PAUSE_INFO_KEY); + let config_snapshot = Self::build_config_snapshot(&env)?; + let stats: SLAStats = env + .storage() + .instance() + .get(&STATS_KEY) + .ok_or(SLAError::NotInitialized)?; let history: Vec = env .storage() @@ -1875,6 +1886,23 @@ impl SLACalculatorContract { .unwrap_or_else(|| Vec::new(&env)); let history_len = history.len(); + let result_schema = SLAResultSchema { + version: symbol_short!("v1"), + schema_version: RESULT_SCHEMA_VERSION, + result_field_count: RESULT_SCHEMA_FIELD_COUNT, + status_met: symbol_short!("met"), + status_violated: symbol_short!("viol"), + payment_reward: symbol_short!("rew"), + payment_penalty: symbol_short!("pen"), + rating_exceptional: symbol_short!("top"), + rating_excellent: symbol_short!("excel"), + rating_good: symbol_short!("good"), + rating_poor: symbol_short!("poor"), + includes_config_version_hash: true, + deprecated_symbols: Vec::new(&env), + severity_aliases: Vec::new(&env), + }; + Ok(AuditState { admin, operator, @@ -2928,8 +2956,10 @@ impl SLACalculatorContract { let last_violation = Self::count_lane(last_violations, index) as u64; let calc_stale = last_calc != 0 && now.saturating_sub(last_calc) >= week_seconds; let violation_stale = last_violation != 0 && now.saturating_sub(last_violation) >= week_seconds; - if calc_stale || violation_stale { + if calc_stale { calculations = Self::set_count_lane(calculations, index, 0); + } + if violation_stale { violations = Self::set_count_lane(violations, index, 0); } @@ -3065,12 +3095,16 @@ impl SLACalculatorContract { /// SC-063 – Prune history entries older than `min_age_seconds` before the /// current ledger timestamp. Entries with `recorded_at == 0` (view-mode /// results that were never stored with a real timestamp) are always kept. + /// Returns `Err(SLAError::InvalidInput)` if `min_age_seconds >= now`. /// Admin-only. Emits a `pruned_a` event. pub fn prune_history_by_age(env: Env, caller: Address, min_age_seconds: u64) -> Result<(), SLAError> { Self::check_version(&env)?; Self::require_admin(&env, &caller)?; let now = env.ledger().timestamp(); + if min_age_seconds >= now { + return Err(SLAError::InvalidInput); + } let cutoff = now.saturating_sub(min_age_seconds); let history: Vec = env @@ -3200,8 +3234,13 @@ impl SLACalculatorContract { // SC-060: History query by outage identifier // ------------------------------------------------------------------- - /// Returns all history entries whose `outage_id` matches the given value. - /// Returns an empty Vec when no matching entries exist. + /// Returns all history entries whose `outage_id` matches the given value in + /// chronological order (oldest-first). + /// + /// When an outage has multiple entries across config generations (up to + /// `MAX_RECALCS_PER_OUTAGE`), each entry carries its `config_version_hash` + /// so consumers can match records to specific config generations. The final + /// entry in the returned array represents the latest decision. pub fn get_history_by_outage(env: Env, outage_id: Symbol) -> Result, SLAError> { Self::check_version(&env)?; let history: Vec = env diff --git a/apexchainx_calculator/src/tests.rs b/apexchainx_calculator/src/tests.rs index 32b76b9..01f1c07 100644 --- a/apexchainx_calculator/src/tests.rs +++ b/apexchainx_calculator/src/tests.rs @@ -304,6 +304,61 @@ fn test_severity_telemetry_weekly_reset_semantics() { assert_eq!(high4.violation_rate, 100); } +#[test] +fn test_record_severity_telemetry_decoupled_lane_resets() { + let (env, client, actors) = setup(); + + // 1. Initial calculation & violation at t = 1,000,000 + env.ledger().set_timestamp(1_000_000); + client.calculate_sla( + &actors.operator, + &symbol_short!("EVT001"), + &symbol_short!("high"), + &40, // violation (threshold = 30) + ); + + let t1 = client.get_severity_telemetry(); + let high1 = t1.get(1).unwrap(); + assert_eq!(high1.calculations, 1); + assert_eq!(high1.violations, 1); + + // 2. Advance time by 4 days to t = 1,345,600 and record a MET calculation. + // Last calculation ts becomes 1,345,600. Last violation ts remains 1,000,000. + env.ledger().set_timestamp(1_000_000 + 4 * 86_400); + client.calculate_sla( + &actors.operator, + &symbol_short!("EVT002"), + &symbol_short!("high"), + &10, // met + ); + + let t2 = client.get_severity_telemetry(); + let high2 = t2.get(1).unwrap(); + assert_eq!(high2.calculations, 2); + assert_eq!(high2.violations, 1); + + // 3. Advance time to t = 1,000,000 + 7.5 days (1,648,000). + // Time since last violation = 648,000s >= 604,800s (violation_stale = true). + // Time since last calculation = 302,400s < 604,800s (calc_stale = false). + env.ledger().set_timestamp(1_000_000 + 7 * 86_400 + 43_200); + + // Record another MET calculation. + client.calculate_sla( + &actors.operator, + &symbol_short!("EVT003"), + &symbol_short!("high"), + &10, // met + ); + + let t3 = client.get_severity_telemetry(); + let high3 = t3.get(1).unwrap(); + // Violation counter reset to 0 then 0 added (since EVT003 was met) = 0 violations. + // Calculation counter survived reset! Was 2, incremented by 1 = 3 calculations. + assert_eq!(high3.calculations, 3); + assert_eq!(high3.violations, 0); + assert_eq!(high3.violation_rate, 0); +} + #[test] fn test_severity_telemetry_counters_saturate_at_u32_max() { let (env, client, actors) = setup(); @@ -3315,6 +3370,66 @@ fn test_get_latest_by_outage_does_not_return_other_outage() { assert!(result.is_none()); } +#[test] +fn test_get_history_by_outage_multi_generation_history() { + let (env, client, actors) = setup(); + + // 1. Initial calculation under generation 1 + client.calculate_sla( + &actors.operator, + &symbol(&env, "OUT_MULTI"), + &symbol_short!("critical"), + &10, + ); + + let res1 = client.get_history_by_outage(&symbol(&env, "OUT_MULTI")); + assert_eq!(res1.len(), 1); + let hash1 = res1.get(0).unwrap().config_version_hash; + + // 2. Change configuration (creates new config generation) + client.set_config(&actors.admin, &symbol_short!("critical"), &20, &200, &1000); + + // 3. Recalculate under generation 2 + client.calculate_sla( + &actors.operator, + &symbol(&env, "OUT_MULTI"), + &symbol_short!("critical"), + &10, + ); + + let res2 = client.get_history_by_outage(&symbol(&env, "OUT_MULTI")); + assert_eq!(res2.len(), 2); + let hash2 = res2.get(1).unwrap().config_version_hash; + + // Verify config_version_hash differs between generations + assert_ne!(hash1, hash2); + + // Verify ordering: first entry is generation 1, last entry is generation 2 + assert_eq!(res2.get(0).unwrap().config_version_hash, hash1); + assert_eq!(res2.get(1).unwrap().config_version_hash, hash2); + + // Verify latest accessor matches final entry of get_history_by_outage + let latest = client.get_latest_by_outage(&symbol(&env, "OUT_MULTI")).unwrap(); + assert_eq!(latest.config_version_hash, hash2); +} + +#[test] +fn test_get_full_audit_state_single_pass_efficiency() { + let (_env, client, actors) = setup(); + + let state = client.get_full_audit_state(); + assert_eq!(state.admin, actors.admin); + assert_eq!(state.operator, actors.operator); + assert_eq!(state.pending_admin, None); + assert_eq!(state.pending_operator, None); + assert_eq!(state.paused, false); + assert_eq!(state.pause_info.len(), 0); + assert_eq!(state.config_snapshot.entries.len(), 4); + assert_eq!(state.stats.total_calculations, 0); + assert_eq!(state.history_len, 0); + assert_eq!(state.result_schema.version, symbol_short!("v1")); +} + // ============================================================ // SC-062 – Bounded-history retention // ============================================================ @@ -3380,7 +3495,7 @@ fn test_prune_by_age_removes_old_entries() { fn test_prune_by_age_keeps_all_when_none_old_enough() { let env = Env::default(); env.mock_all_auths(); - env.ledger().set_timestamp(1000); + env.ledger().set_timestamp(3000); let cid = env.register_contract(None, SLACalculatorContract); let client = SLACalculatorContractClient::new(&env, &cid); @@ -3391,17 +3506,52 @@ fn test_prune_by_age_keeps_all_when_none_old_enough() { client.calculate_sla(&op, &symbol_short!("E1"), &symbol_short!("critical"), &5); client.calculate_sla(&op, &symbol_short!("E2"), &symbol_short!("high"), &10); - // Prune with min_age_seconds=2000 → cutoff = 1000 - 2000 saturates to 0 - // All entries have recorded_at=1000 >= 0 → nothing removed - client.prune_history_by_age(&admin, &2000); + // Prune with min_age_seconds=500 -> cutoff = 3000 - 500 = 2500 + // All entries have recorded_at=3000 >= 2500 -> nothing removed + client.prune_history_by_age(&admin, &500); let history = client.get_history(); assert_eq!(history.len(), 2); } +#[test] +#[should_panic] +fn test_prune_by_age_rejects_min_age_equal_to_now() { + let env = Env::default(); + env.mock_all_auths(); + env.ledger().set_timestamp(1000); + + let cid = env.register_contract(None, SLACalculatorContract); + let client = SLACalculatorContractClient::new(&env, &cid); + let admin = soroban_sdk::Address::generate(&env); + let op = soroban_sdk::Address::generate(&env); + client.initialize(&admin, &op); + + // min_age_seconds == now (1000) -> rejected with InvalidInput + client.prune_history_by_age(&admin, &1000); +} + +#[test] +#[should_panic] +fn test_prune_by_age_rejects_min_age_greater_than_now() { + let env = Env::default(); + env.mock_all_auths(); + env.ledger().set_timestamp(1000); + + let cid = env.register_contract(None, SLACalculatorContract); + let client = SLACalculatorContractClient::new(&env, &cid); + let admin = soroban_sdk::Address::generate(&env); + let op = soroban_sdk::Address::generate(&env); + client.initialize(&admin, &op); + + // min_age_seconds (2000) > now (1000) -> rejected with InvalidInput + client.prune_history_by_age(&admin, &2000); +} + #[test] fn test_prune_by_age_empty_history_is_noop() { - let (_env, client, actors) = setup(); + let (env, client, actors) = setup(); + env.ledger().set_timestamp(1000); // No entries – should not panic client.prune_history_by_age(&actors.admin, &100); assert_eq!(client.get_history().len(), 0); @@ -3410,7 +3560,8 @@ fn test_prune_by_age_empty_history_is_noop() { #[test] #[should_panic] fn test_prune_by_age_operator_cannot_prune() { - let (_env, client, actors) = setup(); + let (env, client, actors) = setup(); + env.ledger().set_timestamp(1000); client.prune_history_by_age(&actors.operator, &100); } diff --git a/apexchainx_calculator/test_snapshots/tests/parity_baseline.json b/apexchainx_calculator/test_snapshots/tests/parity_baseline.json deleted file mode 100644 index 5e51e47..0000000 --- a/apexchainx_calculator/test_snapshots/tests/parity_baseline.json +++ /dev/null @@ -1,607 +0,0 @@ -{ - "baseline_version": 1, - "description": "Canonical parity baseline for ApexChainx SLA calculator. Each entry records a locked-in historical golden vector. The Rust parity checker (parity_tests.rs) must pass against every entry before a release is cut. Update this file, the inline PARITY_VECTORS table in parity_tests.rs, and CHANGELOG when a deliberate calculation change is made.", - "reward_multiplier_boundaries": { - "description": "Multiplier applied to reward_base; determined by ratio = (mttr * 100) / threshold", - "excel": "50 ≤ ratio < 75 → multiplier 150 → reward = reward_base * 150 / 100", - "good": "ratio ≥ 75 → multiplier 100 → reward = reward_base * 100 / 100", - "top": "ratio < 50 → multiplier 200 → reward = reward_base * 200 / 100", - "viol": "mttr > threshold → amount = -((mttr - threshold) * penalty_per_minute)" - }, - "schema": { - "case_id": "Unique identifier for this test vector", - "config": { - "penalty_per_minute": "Penalty amount charged per minute of overtime (positive integer stored as i128)", - "reward_base": "Base reward amount for meeting SLA targets (positive integer stored as i128)", - "threshold_minutes": "Maximum allowed MTTR before SLA is considered violated" - }, - "expected": { - "amount": "Signed integer: positive = reward, negative = penalty", - "payment_type": "rew | pen", - "rating": "top | excel | good | poor", - "status": "met | viol" - }, - "input": { - "mttr_minutes": "Measured time to repair in minutes" - }, - "severity": "Severity level label (informational; config fields carry the actual parameters)" - }, - "vectors": [ - { - "case_id": "critical_mttr_0_top", - "config": { - "penalty_per_minute": 100, - "reward_base": 750, - "threshold_minutes": 15 - }, - "expected": { - "amount": 1500, - "payment_type": "rew", - "rating": "top", - "status": "met" - }, - "input": { - "mttr_minutes": 0 - }, - "severity": "critical" - }, - { - "case_id": "critical_mttr_7_top", - "config": { - "penalty_per_minute": 100, - "reward_base": 750, - "threshold_minutes": 15 - }, - "expected": { - "amount": 1500, - "payment_type": "rew", - "rating": "top", - "status": "met" - }, - "input": { - "mttr_minutes": 7 - }, - "severity": "critical" - }, - { - "case_id": "critical_mttr_8_excel", - "config": { - "penalty_per_minute": 100, - "reward_base": 750, - "threshold_minutes": 15 - }, - "expected": { - "amount": 1125, - "payment_type": "rew", - "rating": "excel", - "status": "met" - }, - "input": { - "mttr_minutes": 8 - }, - "severity": "critical" - }, - { - "case_id": "critical_mttr_11_excel", - "config": { - "penalty_per_minute": 100, - "reward_base": 750, - "threshold_minutes": 15 - }, - "expected": { - "amount": 1125, - "payment_type": "rew", - "rating": "excel", - "status": "met" - }, - "input": { - "mttr_minutes": 11 - }, - "severity": "critical" - }, - { - "case_id": "critical_mttr_12_good", - "config": { - "penalty_per_minute": 100, - "reward_base": 750, - "threshold_minutes": 15 - }, - "expected": { - "amount": 750, - "payment_type": "rew", - "rating": "good", - "status": "met" - }, - "input": { - "mttr_minutes": 12 - }, - "severity": "critical" - }, - { - "case_id": "critical_mttr_15_exact", - "config": { - "penalty_per_minute": 100, - "reward_base": 750, - "threshold_minutes": 15 - }, - "expected": { - "amount": 750, - "payment_type": "rew", - "rating": "good", - "status": "met" - }, - "input": { - "mttr_minutes": 15 - }, - "severity": "critical" - }, - { - "case_id": "critical_mttr_16_viol", - "config": { - "penalty_per_minute": 100, - "reward_base": 750, - "threshold_minutes": 15 - }, - "expected": { - "amount": -100, - "payment_type": "pen", - "rating": "poor", - "status": "viol" - }, - "input": { - "mttr_minutes": 16 - }, - "severity": "critical" - }, - { - "case_id": "critical_mttr_30_viol", - "config": { - "penalty_per_minute": 100, - "reward_base": 750, - "threshold_minutes": 15 - }, - "expected": { - "amount": -1500, - "payment_type": "pen", - "rating": "poor", - "status": "viol" - }, - "input": { - "mttr_minutes": 30 - }, - "severity": "critical" - }, - { - "case_id": "high_mttr_0_top", - "config": { - "penalty_per_minute": 50, - "reward_base": 750, - "threshold_minutes": 30 - }, - "expected": { - "amount": 1500, - "payment_type": "rew", - "rating": "top", - "status": "met" - }, - "input": { - "mttr_minutes": 0 - }, - "severity": "high" - }, - { - "case_id": "high_mttr_14_top", - "config": { - "penalty_per_minute": 50, - "reward_base": 750, - "threshold_minutes": 30 - }, - "expected": { - "amount": 1500, - "payment_type": "rew", - "rating": "top", - "status": "met" - }, - "input": { - "mttr_minutes": 14 - }, - "severity": "high" - }, - { - "case_id": "high_mttr_15_excel", - "config": { - "penalty_per_minute": 50, - "reward_base": 750, - "threshold_minutes": 30 - }, - "expected": { - "amount": 1125, - "payment_type": "rew", - "rating": "excel", - "status": "met" - }, - "input": { - "mttr_minutes": 15 - }, - "severity": "high" - }, - { - "case_id": "high_mttr_22_excel", - "config": { - "penalty_per_minute": 50, - "reward_base": 750, - "threshold_minutes": 30 - }, - "expected": { - "amount": 1125, - "payment_type": "rew", - "rating": "excel", - "status": "met" - }, - "input": { - "mttr_minutes": 22 - }, - "severity": "high" - }, - { - "case_id": "high_mttr_23_good", - "config": { - "penalty_per_minute": 50, - "reward_base": 750, - "threshold_minutes": 30 - }, - "expected": { - "amount": 750, - "payment_type": "rew", - "rating": "good", - "status": "met" - }, - "input": { - "mttr_minutes": 23 - }, - "severity": "high" - }, - { - "case_id": "high_mttr_30_exact", - "config": { - "penalty_per_minute": 50, - "reward_base": 750, - "threshold_minutes": 30 - }, - "expected": { - "amount": 750, - "payment_type": "rew", - "rating": "good", - "status": "met" - }, - "input": { - "mttr_minutes": 30 - }, - "severity": "high" - }, - { - "case_id": "high_mttr_31_viol", - "config": { - "penalty_per_minute": 50, - "reward_base": 750, - "threshold_minutes": 30 - }, - "expected": { - "amount": -50, - "payment_type": "pen", - "rating": "poor", - "status": "viol" - }, - "input": { - "mttr_minutes": 31 - }, - "severity": "high" - }, - { - "case_id": "high_mttr_60_viol", - "config": { - "penalty_per_minute": 50, - "reward_base": 750, - "threshold_minutes": 30 - }, - "expected": { - "amount": -1500, - "payment_type": "pen", - "rating": "poor", - "status": "viol" - }, - "input": { - "mttr_minutes": 60 - }, - "severity": "high" - }, - { - "case_id": "medium_mttr_0_top", - "config": { - "penalty_per_minute": 25, - "reward_base": 750, - "threshold_minutes": 60 - }, - "expected": { - "amount": 1500, - "payment_type": "rew", - "rating": "top", - "status": "met" - }, - "input": { - "mttr_minutes": 0 - }, - "severity": "medium" - }, - { - "case_id": "medium_mttr_29_top", - "config": { - "penalty_per_minute": 25, - "reward_base": 750, - "threshold_minutes": 60 - }, - "expected": { - "amount": 1500, - "payment_type": "rew", - "rating": "top", - "status": "met" - }, - "input": { - "mttr_minutes": 29 - }, - "severity": "medium" - }, - { - "case_id": "medium_mttr_30_excel", - "config": { - "penalty_per_minute": 25, - "reward_base": 750, - "threshold_minutes": 60 - }, - "expected": { - "amount": 1125, - "payment_type": "rew", - "rating": "excel", - "status": "met" - }, - "input": { - "mttr_minutes": 30 - }, - "severity": "medium" - }, - { - "case_id": "medium_mttr_44_excel", - "config": { - "penalty_per_minute": 25, - "reward_base": 750, - "threshold_minutes": 60 - }, - "expected": { - "amount": 1125, - "payment_type": "rew", - "rating": "excel", - "status": "met" - }, - "input": { - "mttr_minutes": 44 - }, - "severity": "medium" - }, - { - "case_id": "medium_mttr_45_good", - "config": { - "penalty_per_minute": 25, - "reward_base": 750, - "threshold_minutes": 60 - }, - "expected": { - "amount": 750, - "payment_type": "rew", - "rating": "good", - "status": "met" - }, - "input": { - "mttr_minutes": 45 - }, - "severity": "medium" - }, - { - "case_id": "medium_mttr_60_exact", - "config": { - "penalty_per_minute": 25, - "reward_base": 750, - "threshold_minutes": 60 - }, - "expected": { - "amount": 750, - "payment_type": "rew", - "rating": "good", - "status": "met" - }, - "input": { - "mttr_minutes": 60 - }, - "severity": "medium" - }, - { - "case_id": "medium_mttr_61_viol", - "config": { - "penalty_per_minute": 25, - "reward_base": 750, - "threshold_minutes": 60 - }, - "expected": { - "amount": -25, - "payment_type": "pen", - "rating": "poor", - "status": "viol" - }, - "input": { - "mttr_minutes": 61 - }, - "severity": "medium" - }, - { - "case_id": "medium_mttr_120_viol", - "config": { - "penalty_per_minute": 25, - "reward_base": 750, - "threshold_minutes": 60 - }, - "expected": { - "amount": -1500, - "payment_type": "pen", - "rating": "poor", - "status": "viol" - }, - "input": { - "mttr_minutes": 120 - }, - "severity": "medium" - }, - { - "case_id": "low_mttr_0_top", - "config": { - "penalty_per_minute": 10, - "reward_base": 600, - "threshold_minutes": 120 - }, - "expected": { - "amount": 1200, - "payment_type": "rew", - "rating": "top", - "status": "met" - }, - "input": { - "mttr_minutes": 0 - }, - "severity": "low" - }, - { - "case_id": "low_mttr_59_top", - "config": { - "penalty_per_minute": 10, - "reward_base": 600, - "threshold_minutes": 120 - }, - "expected": { - "amount": 1200, - "payment_type": "rew", - "rating": "top", - "status": "met" - }, - "input": { - "mttr_minutes": 59 - }, - "severity": "low" - }, - { - "case_id": "low_mttr_60_excel", - "config": { - "penalty_per_minute": 10, - "reward_base": 600, - "threshold_minutes": 120 - }, - "expected": { - "amount": 900, - "payment_type": "rew", - "rating": "excel", - "status": "met" - }, - "input": { - "mttr_minutes": 60 - }, - "severity": "low" - }, - { - "case_id": "low_mttr_89_excel", - "config": { - "penalty_per_minute": 10, - "reward_base": 600, - "threshold_minutes": 120 - }, - "expected": { - "amount": 900, - "payment_type": "rew", - "rating": "excel", - "status": "met" - }, - "input": { - "mttr_minutes": 89 - }, - "severity": "low" - }, - { - "case_id": "low_mttr_90_good", - "config": { - "penalty_per_minute": 10, - "reward_base": 600, - "threshold_minutes": 120 - }, - "expected": { - "amount": 600, - "payment_type": "rew", - "rating": "good", - "status": "met" - }, - "input": { - "mttr_minutes": 90 - }, - "severity": "low" - }, - { - "case_id": "low_mttr_120_exact", - "config": { - "penalty_per_minute": 10, - "reward_base": 600, - "threshold_minutes": 120 - }, - "expected": { - "amount": 600, - "payment_type": "rew", - "rating": "good", - "status": "met" - }, - "input": { - "mttr_minutes": 120 - }, - "severity": "low" - }, - { - "case_id": "low_mttr_121_viol", - "config": { - "penalty_per_minute": 10, - "reward_base": 600, - "threshold_minutes": 120 - }, - "expected": { - "amount": -10, - "payment_type": "pen", - "rating": "poor", - "status": "viol" - }, - "input": { - "mttr_minutes": 121 - }, - "severity": "low" - }, - { - "case_id": "low_mttr_240_viol", - "config": { - "penalty_per_minute": 10, - "reward_base": 600, - "threshold_minutes": 120 - }, - "expected": { - "amount": -1200, - "payment_type": "pen", - "rating": "poor", - "status": "viol" - }, - "input": { - "mttr_minutes": 240 - }, - "severity": "low" - } - ] -}