use crate::lockorder::RankedMutex;
use std::collections::HashMap;
use std::sync::Arc;
use super::*;
use crate::profile::{AppConfig, AppState, Profile, ProfileName};
use crate::usage::{UsageInfo, UsageStore, UsageWindow, epoch_secs_to_iso, now_epoch_secs};
fn live_reset() -> String {
epoch_secs_to_iso(now_epoch_secs() + 3600)
}
fn expired_reset() -> String {
epoch_secs_to_iso(now_epoch_secs() - 3600)
}
fn window(utilization: f64, resets_at: Option<String>) -> UsageWindow {
UsageWindow {
utilization,
resets_at,
}
}
fn usage_info(five_hour: Option<UsageWindow>) -> UsageInfo {
UsageInfo {
five_hour,
..UsageInfo::default()
}
}
fn profile_with_usage(name: &str, threshold: Option<f64>, usage: Option<UsageInfo>) -> Profile {
use std::collections::BTreeMap;
Profile {
name: name.into(),
base_url: None,
api_key: None,
auto_start: false,
env: BTreeMap::new(),
models: Default::default(),
fallback_threshold: threshold,
last_resort: false,
bell_threshold: None,
credentials: None,
usage,
fetch_status: None,
provider: None,
third_party_usage: None,
}
}
fn profile_with_util(name: &str, threshold: Option<f64>, utilization: Option<f64>) -> Profile {
profile_with_usage(
name,
threshold,
utilization.map(|u| usage_info(Some(window(u, Some(live_reset()))))),
)
}
fn mark_last_resort(mut p: Profile) -> Profile {
p.last_resort = true;
p
}
fn config_with_chain(profiles: Vec<Profile>, active: &str) -> AppConfig {
let names: Vec<ProfileName> = profiles.iter().map(|p| p.name.clone()).collect();
AppConfig {
state: AppState {
active_profile: Some(active.into()),
profiles: names.clone(),
fallback_chain: names,
..AppState::default()
},
profiles,
}
}
#[test]
fn all_maxed_sinks_no_switch() {
let config = config_with_chain(
vec![
mark_last_resort(profile_with_util("a", Some(90.0), Some(95.0))),
mark_last_resort(profile_with_util("b", Some(90.0), Some(95.0))),
],
"a",
);
assert_eq!(next_target(&config, None), None);
}
#[test]
fn non_sink_active_migrates_to_sink_once() {
let config = config_with_chain(
vec![
profile_with_util("a", Some(95.0), Some(100.0)),
mark_last_resort(profile_with_util("b", Some(80.0), Some(100.0))),
],
"a",
);
assert_eq!(
next_target(&config, None),
Some(SwitchAction::To("b".to_string()))
);
}
#[test]
fn sink_active_maxed_stays_put() {
let config = config_with_chain(
vec![
profile_with_util("a", Some(95.0), Some(100.0)),
mark_last_resort(profile_with_util("b", Some(80.0), Some(100.0))),
],
"b",
);
assert_eq!(next_target(&config, None), None);
}
#[test]
fn sink_active_switches_to_member_with_headroom() {
let config = config_with_chain(
vec![
mark_last_resort(profile_with_util("a", Some(80.0), Some(100.0))),
profile_with_util("b", Some(95.0), Some(50.0)),
],
"a",
);
assert_eq!(
next_target(&config, None),
Some(SwitchAction::To("b".to_string()))
);
}
#[test]
fn no_sink_available_returns_none() {
let config = config_with_chain(
vec![
profile_with_util("a", Some(95.0), Some(100.0)),
profile_with_util("b", Some(95.0), Some(100.0)),
],
"a",
);
assert_eq!(next_target(&config, None), None);
}
#[test]
fn unmarked_hundred_threshold_active_no_longer_acts_as_sink() {
let mut config = config_with_chain(
vec![
profile_with_util("a", Some(100.0), Some(100.0)),
profile_with_util("b", Some(100.0), Some(100.0)),
],
"a",
);
config.state.wrap_off = true;
assert_eq!(next_target(&config, None), Some(SwitchAction::Off));
}
#[test]
fn wrap_off_switches_off_when_unmarked_hundred_threshold_member_present() {
let mut config = config_with_chain(
vec![
profile_with_util("a", Some(95.0), Some(100.0)),
profile_with_util("b", Some(100.0), Some(100.0)),
],
"a",
);
config.state.wrap_off = true;
assert_eq!(next_target(&config, None), Some(SwitchAction::Off));
}
fn store_with_utils(pairs: &[(&str, f64)]) -> UsageStore {
store_with_infos(
pairs
.iter()
.map(|(name, util)| (*name, usage_info(Some(window(*util, Some(live_reset()))))))
.collect(),
)
}
fn store_with_infos(entries: Vec<(&str, UsageInfo)>) -> UsageStore {
let map: HashMap<String, UsageInfo> = entries
.into_iter()
.map(|(name, info)| (name.to_string(), info))
.collect();
Arc::new(RankedMutex::new(map))
}
#[test]
fn snapshot_chain_captures_thresholds_and_active() {
let config = config_with_chain(
vec![
profile_with_util("a", Some(95.0), Some(50.0)),
mark_last_resort(profile_with_util("b", Some(80.0), Some(20.0))),
],
"a",
);
let snap = snapshot_chain(&config).expect("snapshot");
assert_eq!(snap.active, "a");
assert_eq!(snap.chain.len(), 2);
assert_eq!(snap.chain[0].name, "a");
assert!((snap.chain[0].threshold - 95.0).abs() < f64::EPSILON);
assert!(!snap.chain[0].last_resort);
assert_eq!(snap.chain[1].name, "b");
assert!((snap.chain[1].threshold - 80.0).abs() < f64::EPSILON);
assert!(
snap.chain[1].last_resort,
"last_resort is captured independent of threshold"
);
}
#[test]
fn snapshot_chain_none_when_active_not_in_chain() {
let mut config = config_with_chain(vec![profile_with_util("a", Some(95.0), Some(50.0))], "a");
config.state.fallback_chain = vec!["other".into()];
assert!(snapshot_chain(&config).is_none());
}
#[test]
fn auto_switch_returns_none_when_active_below_threshold() {
let config = config_with_chain(
vec![
profile_with_util("a", Some(95.0), None),
profile_with_util("b", Some(95.0), None),
],
"a",
);
let snap = snapshot_chain(&config).expect("snapshot");
let store = store_with_utils(&[("a", 90.0), ("b", 10.0)]); assert_eq!(next_auto_switch_target(&snap, &store), None);
}
#[test]
fn auto_switch_picks_member_with_headroom() {
let config = config_with_chain(
vec![
profile_with_util("a", Some(95.0), None),
profile_with_util("b", Some(95.0), None),
],
"a",
);
let snap = snapshot_chain(&config).expect("snapshot");
let store = store_with_utils(&[("a", 100.0), ("b", 20.0)]); assert_eq!(
next_auto_switch_target(&snap, &store),
Some(SwitchAction::To("b".to_string())),
);
}
#[test]
fn auto_switch_sink_loop_guard_holds() {
let config = config_with_chain(
vec![
mark_last_resort(profile_with_util("a", Some(80.0), None)),
mark_last_resort(profile_with_util("b", Some(80.0), None)),
],
"a",
);
let snap = snapshot_chain(&config).expect("snapshot");
let store = store_with_utils(&[("a", 100.0), ("b", 100.0)]); assert_eq!(next_auto_switch_target(&snap, &store), None);
}
#[test]
fn auto_switch_unmarked_hundred_threshold_member_is_not_a_sink() {
let mut config = config_with_chain(
vec![
profile_with_util("a", Some(95.0), None),
profile_with_util("b", Some(100.0), None),
],
"a",
);
config.state.wrap_off = true;
let snap = snapshot_chain(&config).expect("snapshot");
let store = store_with_utils(&[("a", 100.0), ("b", 100.0)]);
assert_eq!(
next_auto_switch_target(&snap, &store),
Some(SwitchAction::Off),
"threshold 100 without the mark must not park the snapshot walk"
);
}
#[test]
fn auto_switch_non_sink_active_migrates_to_sink_once() {
let config = config_with_chain(
vec![
profile_with_util("a", Some(95.0), None),
mark_last_resort(profile_with_util("b", Some(80.0), None)),
],
"a",
);
let snap = snapshot_chain(&config).expect("snapshot");
let store = store_with_utils(&[("a", 100.0), ("b", 100.0)]); assert_eq!(
next_auto_switch_target(&snap, &store),
Some(SwitchAction::To("b".to_string())),
);
}
#[test]
fn auto_switch_missing_util_is_not_exhausted() {
let config = config_with_chain(
vec![
profile_with_util("a", Some(95.0), None),
profile_with_util("b", Some(95.0), None),
],
"a",
);
let snap = snapshot_chain(&config).expect("snapshot");
let store = store_with_utils(&[("b", 10.0)]); assert_eq!(next_auto_switch_target(&snap, &store), None);
}
#[test]
fn wrap_off_switches_off_when_chain_spent() {
let mut config = config_with_chain(
vec![
profile_with_util("a", Some(95.0), Some(100.0)),
profile_with_util("b", Some(95.0), Some(100.0)),
],
"a",
);
config.state.wrap_off = true;
assert_eq!(next_target(&config, None), Some(SwitchAction::Off));
}
#[test]
fn wrap_off_prefers_sink_over_off() {
let mut config = config_with_chain(
vec![
profile_with_util("a", Some(95.0), Some(100.0)),
mark_last_resort(profile_with_util("b", Some(80.0), Some(100.0))),
],
"a",
);
config.state.wrap_off = true;
assert_eq!(
next_target(&config, None),
Some(SwitchAction::To("b".to_string()))
);
}
#[test]
fn wrap_off_skips_off_when_active_has_headroom() {
let mut config = config_with_chain(
vec![
profile_with_util("a", Some(95.0), Some(50.0)),
profile_with_util("b", Some(95.0), Some(100.0)),
],
"a",
);
config.state.wrap_off = true;
assert_eq!(next_target(&config, None), None);
}
#[test]
fn wrap_off_disabled_stays_put() {
let config = config_with_chain(
vec![
profile_with_util("a", Some(95.0), Some(100.0)),
profile_with_util("b", Some(95.0), Some(100.0)),
],
"a",
);
assert_eq!(next_target(&config, None), None);
}
fn reset_in(secs: i64) -> String {
epoch_secs_to_iso(now_epoch_secs() + secs)
}
#[test]
fn soonest_resume_empty_chain_is_none() {
let config = config_with_chain(vec![], "a");
assert_eq!(soonest_resume(&config), None);
}
#[test]
fn soonest_resume_picks_the_soonest_reset() {
let config = config_with_chain(
vec![
profile_with_usage(
"a",
Some(95.0),
Some(usage_info(Some(window(100.0, Some(reset_in(3600)))))),
),
profile_with_usage(
"b",
Some(95.0),
Some(usage_info(Some(window(100.0, Some(reset_in(1800)))))),
),
],
"a",
);
let (name, eta) = soonest_resume(&config).expect("all exhausted");
assert_eq!(name, "b", "b resets sooner than a");
assert!((1700..=1800).contains(&eta), "eta ~1800s, got {eta}");
}
#[test]
fn soonest_resume_ties_keep_earlier_chain_order() {
let reset = reset_in(3600);
let config = config_with_chain(
vec![
profile_with_usage(
"a",
Some(95.0),
Some(usage_info(Some(window(100.0, Some(reset.clone()))))),
),
profile_with_usage(
"b",
Some(95.0),
Some(usage_info(Some(window(100.0, Some(reset))))),
),
],
"a",
);
let (name, _) = soonest_resume(&config).expect("all exhausted");
assert_eq!(name, "a", "a tie keeps the earlier chain-order member");
}
#[test]
fn soonest_resume_none_when_one_member_recovered() {
let config = config_with_chain(
vec![
profile_with_usage(
"a",
Some(95.0),
Some(usage_info(Some(window(100.0, Some(reset_in(3600)))))),
),
profile_with_util("b", Some(95.0), Some(10.0)),
],
"a",
);
assert_eq!(soonest_resume(&config), None);
}
#[test]
fn soonest_resume_none_when_a_member_window_expired() {
let config = config_with_chain(
vec![
profile_with_usage(
"a",
Some(95.0),
Some(usage_info(Some(window(100.0, Some(reset_in(3600)))))),
),
profile_with_usage(
"b",
Some(95.0),
Some(usage_info(Some(window(100.0, Some(expired_reset()))))),
),
],
"a",
);
assert_eq!(soonest_resume(&config), None);
}
#[test]
fn soonest_resume_none_when_chain_member_missing_profile() {
let mut config = config_with_chain(
vec![profile_with_usage(
"a",
Some(95.0),
Some(usage_info(Some(window(100.0, Some(reset_in(3600)))))),
)],
"a",
);
config.state.fallback_chain.push("ghost".into());
assert_eq!(soonest_resume(&config), None);
}
#[test]
fn auto_switch_wrap_off_switches_off_when_chain_spent() {
let mut config = config_with_chain(
vec![
profile_with_util("a", Some(95.0), None),
profile_with_util("b", Some(95.0), None),
],
"a",
);
config.state.wrap_off = true;
let snap = snapshot_chain(&config).expect("snapshot");
assert!(snap.wrap_off);
let store = store_with_utils(&[("a", 100.0), ("b", 100.0)]); assert_eq!(
next_auto_switch_target(&snap, &store),
Some(SwitchAction::Off),
);
}
#[test]
fn find_recovered_returns_first_member_below_threshold() {
let members = vec![
ChainMember {
name: "a".into(),
threshold: 95.0,
last_resort: false,
},
ChainMember {
name: "b".into(),
threshold: 95.0,
last_resort: false,
},
];
let store = store_with_utils(&[("a", 100.0), ("b", 40.0)]);
assert_eq!(
find_recovered_member(&members, &store),
Some("b".to_string()),
);
}
#[test]
fn find_recovered_skips_exhausted_members() {
let members = vec![
ChainMember {
name: "a".into(),
threshold: 95.0,
last_resort: false,
},
ChainMember {
name: "b".into(),
threshold: 95.0,
last_resort: false,
},
];
let store = store_with_utils(&[("a", 100.0), ("b", 100.0)]);
assert_eq!(find_recovered_member(&members, &store), None);
}
#[test]
fn find_recovered_returns_none_when_no_member_has_data() {
let members = vec![
ChainMember {
name: "a".into(),
threshold: 95.0,
last_resort: false,
},
ChainMember {
name: "b".into(),
threshold: 95.0,
last_resort: false,
},
];
let store = store_with_utils(&[]); assert_eq!(find_recovered_member(&members, &store), None);
}
#[test]
fn find_recovered_uses_threshold_per_member() {
let members = vec![
ChainMember {
name: "a".into(),
threshold: 90.0,
last_resort: false,
}, ChainMember {
name: "b".into(),
threshold: 95.0,
last_resort: false,
}, ];
let store = store_with_utils(&[("a", 95.0), ("b", 94.0)]);
assert_eq!(
find_recovered_member(&members, &store),
Some("b".to_string()),
);
}
#[test]
fn find_recovered_recovers_when_window_expired() {
let members = vec![ChainMember {
name: "a".into(),
threshold: 95.0,
last_resort: false,
}];
let store = store_with_infos(vec![(
"a",
usage_info(Some(window(100.0, Some(expired_reset())))),
)]);
assert_eq!(
find_recovered_member(&members, &store),
Some("a".to_string()),
);
}
#[test]
fn find_recovered_recovers_when_windowless() {
let members = vec![ChainMember {
name: "a".into(),
threshold: 95.0,
last_resort: false,
}];
let store = store_with_infos(vec![("a", usage_info(None))]);
assert_eq!(
find_recovered_member(&members, &store),
Some("a".to_string()),
);
}
#[test]
fn find_recovered_treats_missing_resets_at_as_lapsed() {
let members = vec![ChainMember {
name: "a".into(),
threshold: 95.0,
last_resort: false,
}];
let store = store_with_infos(vec![("a", usage_info(Some(window(100.0, None))))]);
assert_eq!(
find_recovered_member(&members, &store),
Some("a".to_string()),
);
}
#[test]
fn auto_switch_ignores_expired_window_active() {
let config = config_with_chain(
vec![
profile_with_util("a", Some(95.0), None),
profile_with_util("b", Some(95.0), None),
],
"a",
);
let snap = snapshot_chain(&config).expect("snapshot");
let store = store_with_infos(vec![
("a", usage_info(Some(window(100.0, Some(expired_reset()))))),
("b", usage_info(Some(window(50.0, Some(live_reset()))))),
]);
assert_eq!(next_auto_switch_target(&snap, &store), None);
}
#[test]
fn next_target_accepts_member_with_expired_window() {
let config = config_with_chain(
vec![
profile_with_util("a", Some(95.0), Some(100.0)),
profile_with_usage(
"b",
Some(95.0),
Some(usage_info(Some(window(100.0, Some(expired_reset()))))),
),
],
"a",
);
assert_eq!(
next_target(&config, None),
Some(SwitchAction::To("b".into()))
);
}
#[test]
fn auto_switch_wrap_off_disabled_stays_put() {
let config = config_with_chain(
vec![
profile_with_util("a", Some(95.0), None),
profile_with_util("b", Some(95.0), None),
],
"a",
);
let snap = snapshot_chain(&config).expect("snapshot");
let store = store_with_utils(&[("a", 100.0), ("b", 100.0)]);
assert_eq!(next_auto_switch_target(&snap, &store), None);
}
#[test]
fn is_exhausted_projected_none_burn_falls_back_to_static_threshold() {
assert!(
is_exhausted_projected(96.0, 95.0, None, 90_000),
"over threshold, no rate available → static check fires"
);
assert!(
!is_exhausted_projected(90.0, 95.0, None, 90_000),
"under threshold, no rate available → static check doesn't fire"
);
}
#[test]
fn is_exhausted_projected_heavy_burn_fires_before_static_threshold() {
assert!(is_exhausted_projected(90.0, 95.0, Some(1200.0), 90_000));
}
#[test]
fn is_exhausted_projected_light_burn_runs_past_static_threshold() {
assert!(!is_exhausted_projected(96.0, 95.0, Some(4.0), 90_000));
}
#[test]
fn is_exhausted_active_mode_off_matches_static_is_exhausted() {
let exhausted = profile_with_util("a", Some(95.0), Some(100.0));
let headroom = profile_with_util("a", Some(95.0), Some(50.0));
assert_eq!(
is_exhausted_active(&exhausted, false, 90_000, None),
is_exhausted(&exhausted)
);
assert_eq!(
is_exhausted_active(&headroom, false, 90_000, None),
is_exhausted(&headroom)
);
assert!(is_exhausted_active(&exhausted, false, 90_000, None));
assert!(!is_exhausted_active(&headroom, false, 90_000, None));
}
#[test]
fn is_exhausted_active_burn_aware_falls_back_without_rate() {
let exhausted = profile_with_util("a", Some(95.0), Some(100.0));
let headroom = profile_with_util("a", Some(95.0), Some(50.0));
assert!(is_exhausted_active(&exhausted, true, 90_000, None));
assert!(!is_exhausted_active(&headroom, true, 90_000, None));
}
#[test]
fn next_target_burn_aware_none_rate_falls_back_to_static_threshold() {
let mut config = config_with_chain(
vec![
profile_with_util("a", Some(95.0), Some(100.0)),
profile_with_util("b", Some(95.0), Some(100.0)),
],
"a",
);
config.state.wrap_off = true;
config.state.burn_aware_switching = true;
assert_eq!(
next_target(&config, None),
Some(SwitchAction::Off),
"no rate available → static 100% >= 95% threshold fires, same as mode off"
);
}
fn write_history(name: &str, entries: &[(u64, UsageInfo)]) {
let path = crate::profile::profile_history_path(name).expect("history path");
std::fs::create_dir_all(path.parent().expect("parent dir")).expect("mkdir");
let mut body = String::new();
for (ts, usage) in entries {
let line = serde_json::json!({ "ts": ts, "name": name, "usage": usage });
body.push_str(&serde_json::to_string(&line).expect("serialize history line"));
body.push('\n');
}
std::fs::write(&path, body).expect("write history");
}
#[test]
fn burn_aware_heavy_burn_flips_wrap_off_decision_on_both_walks() {
let _home = crate::testutil::HomeSandbox::new();
let now = crate::usage::now_ms();
write_history(
"a",
&[
(
now - 360_000,
usage_info(Some(window(30.0, Some(live_reset())))),
),
(
now - 240_000,
usage_info(Some(window(50.0, Some(live_reset())))),
),
(
now - 120_000,
usage_info(Some(window(70.0, Some(live_reset())))),
),
],
);
let mut static_config = config_with_chain(
vec![
profile_with_util("a", Some(95.0), Some(90.0)),
profile_with_util("b", Some(95.0), Some(100.0)),
],
"a",
);
static_config.state.wrap_off = true;
assert_eq!(
next_target(&static_config, None),
None,
"static mode: 90% is still under the 95% threshold, active stays"
);
let active_window = window(90.0, Some(live_reset()));
let rate = burn_rate_for_profile("a", &active_window).expect("rate computed from history");
assert!((rate - 600.0).abs() < 1.0, "expected ~600 %/h, got {rate}");
let mut config = config_with_chain(
vec![
profile_with_util("a", Some(95.0), Some(90.0)),
profile_with_util("b", Some(95.0), Some(100.0)),
],
"a",
);
config.state.wrap_off = true;
config.state.burn_aware_switching = true;
config.state.refresh_interval_ms = 90_000;
assert_eq!(
next_target(&config, Some(rate)),
Some(SwitchAction::Off),
"burn-aware mode: heavy burn projects past 100% within one poll, no sink → Off"
);
let snap = snapshot_chain(&config).expect("snapshot");
assert!(snap.wrap_off);
assert!(snap.burn_aware);
assert_eq!(snap.interval_ms, 90_000);
let store = store_with_utils(&[("a", 90.0), ("b", 100.0)]);
assert_eq!(
next_auto_switch_target(&snap, &store),
Some(SwitchAction::Off),
"scheduler-side walk agrees with next_target under burn-aware mode"
);
}