use crate::slots::SlotRecord;
use std::path::{Path, PathBuf};
#[derive(Default)]
pub struct Chooser {
last_pointer: Option<PathBuf>,
seen_once: bool,
}
impl Chooser {
pub fn choose(
&mut self,
pointer: Option<&Path>,
rotated: Option<&str>,
slots: &[SlotRecord],
) -> Option<SlotRecord> {
let now = pointer.map(Path::to_path_buf);
let changed = self.seen_once && now != self.last_pointer;
self.last_pointer = now;
self.seen_once = true;
let by_pointer = pointer.and_then(|p| slots.iter().find(|r| r.config_dir == p));
if changed {
if let Some(r) = by_pointer {
return Some(r.clone());
}
}
if let Some(name) = rotated {
if let Some(r) = slots.iter().find(|r| r.name == name) {
return Some(r.clone());
}
}
by_pointer.or_else(|| slots.first()).cloned()
}
}
pub fn usage_block(
measured: &[(String, String)],
unread: &[(String, String)],
refused: &[String],
) -> Vec<String> {
let width = measured
.iter()
.map(|(n, _)| n.chars().count())
.chain(unread.iter().map(|(n, _)| n.chars().count()))
.max()
.unwrap_or(0);
let mut out: Vec<String> = measured
.iter()
.map(|(n, v)| {
let mark = if refused.iter().any(|r| r == n) {
" - refusing turns"
} else {
""
};
format!("{n:width$} {v}{mark}")
})
.collect();
out.sort();
let mut rest: Vec<String> = unread
.iter()
.filter(|(n, _)| !measured.iter().any(|(m, _)| m == n))
.map(|(n, why)| format!("{n:width$} ({why})"))
.collect();
rest.sort();
out.extend(rest);
out
}
pub fn usage_line(
measured: &[(String, String)],
unread: &[(String, String)],
refused: &[String],
) -> String {
let mut parts: Vec<String> = measured
.iter()
.map(|(n, v)| {
if refused.iter().any(|r| r == n) {
format!("{n} {v} but refusing turns")
} else {
format!("{n} {v}")
}
})
.collect();
parts.sort();
let mut rest: Vec<String> = unread
.iter()
.filter(|(n, _)| !measured.iter().any(|(m, _)| m == n))
.map(|(n, why)| format!("{n} ({why})"))
.collect();
rest.sort();
parts.extend(rest);
parts.join(", ")
}
pub fn announce_bench(memo: &mut Option<(String, String)>, from: &str, to: &str) -> bool {
let pair = (from.to_string(), to.to_string());
if memo.as_ref() == Some(&pair) {
return false;
}
*memo = Some(pair);
true
}
pub fn clear_bench_note(memo: &mut Option<(String, String)>) {
*memo = None;
}
pub fn window_left(label: &str, used_pct: f64, resets: Option<String>) -> String {
let left = (100.0 - used_pct).clamp(0.0, 100.0);
match resets {
Some(r) => format!("{label} {left:.0}% left, resets {r}"),
None => format!("{label} {left:.0}% left"),
}
}
pub fn reset_clock(at: i64, now: i64, tz_offset: i64) -> String {
if at <= now {
return "now".into();
}
let l = at + tz_offset;
let (h24, mins) = ((l % 86400) / 3600, (l % 3600) / 60);
let ampm = if h24 < 12 { "am" } else { "pm" };
let h12 = match h24 % 12 {
0 => 12,
h => h,
};
let time = if mins == 0 {
format!("{h12}{ampm}")
} else {
format!("{h12}:{mins:02}{ampm}")
};
if at - now < 86400 && (l / 86400) == ((now + tz_offset) / 86400) {
return time;
}
const DAY: [&str; 7] = ["Thu", "Fri", "Sat", "Sun", "Mon", "Tue", "Wed"];
format!("{} {time}", DAY[((l / 86400) % 7) as usize])
}
pub fn burned_uuids(named: &[(String, Option<String>)], ruled_out: &[String]) -> Vec<String> {
let mut out: Vec<String> = named
.iter()
.filter(|(name, _)| ruled_out.iter().any(|r| r == name))
.filter_map(|(_, uuid)| uuid.clone())
.collect();
out.sort();
out.dedup();
out
}
#[derive(Clone, Debug, PartialEq)]
pub struct Candidate {
pub name: String,
pub uuid: Option<String>,
pub ruled_out: bool,
pub usable: bool,
}
pub fn next_usable(candidates: &[Candidate]) -> Option<&Candidate> {
let ruled_out: Vec<String> = candidates
.iter()
.filter(|c| c.ruled_out)
.map(|c| c.name.clone())
.collect();
let named: Vec<(String, Option<String>)> = candidates
.iter()
.map(|c| (c.name.clone(), c.uuid.clone()))
.collect();
let burned = burned_uuids(&named, &ruled_out);
candidates.iter().find(|c| {
!c.ruled_out
&& c.usable
&& !c
.uuid
.as_deref()
.is_some_and(|u| burned.iter().any(|b| b == u))
})
}
pub fn over_threshold(five_h: Option<f64>, seven_d: Option<f64>, threshold: f64) -> bool {
let limit = (threshold * 100.0).clamp(0.0, 100.0);
[five_h, seven_d].into_iter().flatten().any(|p| p >= limit)
}
pub fn over_threshold_with(
five_h: Option<f64>,
seven_d: Option<f64>,
threshold: f64,
_credits_available: bool,
) -> bool {
over_threshold(five_h, seven_d, threshold)
}
pub fn headroom(five_h: Option<f64>, seven_d: Option<f64>) -> Option<f64> {
let worst = [five_h, seven_d]
.into_iter()
.flatten()
.fold(f64::NAN, f64::max);
worst.is_finite().then(|| (100.0 - worst).clamp(0.0, 100.0))
}
pub fn by_headroom<'a, T>(
items: &mut [T],
rank: impl Fn(&T) -> usize,
room: impl Fn(&T) -> Option<f64>,
) where
T: 'a,
{
items.sort_by(|a, b| {
rank(a).cmp(&rank(b)).then_with(|| {
match (room(a), room(b)) {
(Some(x), Some(y)) => y.total_cmp(&x), (Some(_), None) => std::cmp::Ordering::Less,
(None, Some(_)) => std::cmp::Ordering::Greater,
(None, None) => std::cmp::Ordering::Equal,
}
})
});
}
pub fn rotate_target(
current: &str,
slots: &[SlotRecord],
state: &std::collections::HashMap<String, crate::proxy::ratelimit::Quota>,
) -> Option<String> {
slots
.iter()
.filter(|r| r.name != current)
.find(|r| !state.get(&r.name).is_some_and(|q| q.rejected))
.map(|r| r.name.clone())
}
#[cfg(test)]
mod usage_block_tests {
use super::*;
fn p(v: &[(&str, &str)]) -> Vec<(String, String)> {
v.iter()
.map(|(a, b)| (a.to_string(), b.to_string()))
.collect()
}
#[test]
fn each_account_gets_its_own_line() {
let out = usage_block(
&p(&[
("bsgong", "5h 61% resets 1:47pm · 7d 27% resets Tue 9am"),
("rnd", "5h 0% · 7d 6% resets Sun 5pm"),
]),
&p(&[("personal", "no saved token"), ("bsgong", "throttled")]),
&[],
);
assert_eq!(out.len(), 3, "one line each, bsgong not repeated: {out:?}");
assert!(out[0].starts_with("bsgong"), "{out:?}");
assert!(
out.iter().any(|l| l.contains("(no saved token)")),
"{out:?}"
);
let starts: Vec<usize> = out
.iter()
.map(|l| {
let name_end = l.find(' ').expect("name then padding");
name_end + (l[name_end..].len() - l[name_end..].trim_start().len())
})
.collect();
assert!(
starts.windows(2).all(|w| w[0] == w[1]),
"values form a column: {starts:?} in {out:?}"
);
}
#[test]
fn an_account_refusing_turns_is_marked() {
let out = usage_block(&p(&[("rnd", "5h 0%")]), &[], &["rnd".to_string()]);
assert!(out[0].contains("refusing"), "{out:?}");
}
}
#[cfg(test)]
mod announce_bench_tests {
use super::*;
#[test]
fn the_same_redirection_is_announced_once() {
let mut memo = None;
assert!(
announce_bench(&mut memo, "rnd", "bsgong"),
"first time, say it"
);
assert!(
!announce_bench(&mut memo, "rnd", "bsgong"),
"same again, stay quiet"
);
assert!(!announce_bench(&mut memo, "rnd", "bsgong"));
}
#[test]
fn a_new_destination_is_announced() {
let mut memo = None;
announce_bench(&mut memo, "rnd", "bsgong");
assert!(announce_bench(&mut memo, "rnd", "personal"));
assert!(announce_bench(&mut memo, "personal", "bsgong"));
}
#[test]
fn the_episode_ending_makes_the_next_one_news() {
let mut memo = None;
announce_bench(&mut memo, "rnd", "bsgong");
clear_bench_note(&mut memo);
assert!(announce_bench(&mut memo, "rnd", "bsgong"));
}
}
#[cfg(test)]
mod window_left_tests {
use super::*;
#[test]
fn a_window_reports_what_is_left_not_what_is_used() {
assert_eq!(window_left("5h", 0.0, None), "5h 100% left");
assert_eq!(window_left("7d", 30.0, None), "7d 70% left");
assert_eq!(window_left("5h", 100.0, None), "5h 0% left");
}
#[test]
fn the_reset_rides_along_when_there_is_one() {
let s = window_left("5h", 61.0, Some("1:47pm".into()));
assert_eq!(s, "5h 39% left, resets 1:47pm");
}
#[test]
fn a_reading_past_the_ends_is_clamped() {
assert_eq!(window_left("5h", 105.0, None), "5h 0% left");
assert_eq!(window_left("5h", -3.0, None), "5h 100% left");
}
}
#[cfg(test)]
mod reset_clock_tests {
use super::*;
#[test]
fn a_same_day_reset_is_just_the_time() {
assert_eq!(reset_clock(15 * 3600, 9 * 3600, 0), "3pm");
assert_eq!(reset_clock(15 * 3600 + 30 * 60, 9 * 3600, 0), "3:30pm");
assert_eq!(reset_clock(9 * 3600, 8 * 3600, 0), "9am");
}
#[test]
fn a_reset_already_past_reads_as_now() {
assert_eq!(reset_clock(100, 100, 0), "now");
assert_eq!(reset_clock(50, 100, 0), "now");
}
#[test]
fn a_reset_on_another_day_carries_the_day() {
let out = reset_clock(3 * 86400 + 15 * 3600, 9 * 3600, 0);
assert!(out.contains("3pm"), "{out}");
assert!(out.len() > "3pm".len(), "it names the day too: {out}");
}
}
#[cfg(test)]
mod usage_line_tests {
use super::*;
fn p(v: &[(&str, &str)]) -> Vec<(String, String)> {
v.iter()
.map(|(a, b)| (a.to_string(), b.to_string()))
.collect()
}
#[test]
fn an_account_that_is_refusing_turns_does_not_read_as_a_reserve() {
let line = usage_line(&p(&[("rnd", "0% (on credits)")]), &[], &["rnd".to_string()]);
assert!(line.contains("refusing"), "{line}");
assert!(
line.contains("0%"),
"the measurement is still shown: {line}"
);
}
#[test]
fn an_account_with_a_number_is_never_also_listed_as_unread() {
let line = usage_line(
&p(&[("bsgong", "89% (on credits)")]),
&p(&[
("bsgong", "usage endpoint throttled"),
("rnd", "usage endpoint throttled"),
]),
&[],
);
assert_eq!(
line,
"bsgong 89% (on credits), rnd (usage endpoint throttled)"
);
assert_eq!(line.matches("bsgong").count(), 1, "named once: {line}");
}
#[test]
fn a_failed_reread_keeps_the_number_it_already_had() {
let line = usage_line(
&p(&[("a", "50%")]),
&p(&[("a", "usage endpoint throttled")]),
&[],
);
assert_eq!(line, "a 50%");
}
#[test]
fn accounts_with_no_number_are_named_with_their_reason() {
let line = usage_line(
&p(&[]),
&p(&[("b", "no saved token"), ("a", "throttled")]),
&[],
);
assert_eq!(line, "a (throttled), b (no saved token)");
}
}
#[cfg(test)]
mod identity_tests {
use super::*;
fn named() -> Vec<(String, Option<String>)> {
vec![
("bsgong".into(), Some("8dd1a9aa".into())),
("bsgong-slot".into(), Some("8dd1a9aa".into())),
("rnd".into(), Some("202743db".into())),
]
}
#[test]
fn one_account_in_two_slots_is_burned_once_either_is() {
let burned = burned_uuids(&named(), &["bsgong".to_string()]);
assert_eq!(burned, vec!["8dd1a9aa"], "its twin is ruled out too");
assert!(
!burned.contains(&"202743db".to_string()),
"a genuinely different account is untouched"
);
}
#[test]
fn an_unreadable_identity_rules_nothing_out() {
let named = vec![("a".to_string(), None), ("b".to_string(), None)];
assert!(burned_uuids(&named, &["a".to_string()]).is_empty());
}
fn c(name: &str, uuid: Option<&str>, ruled_out: bool) -> Candidate {
Candidate {
name: name.into(),
uuid: uuid.map(str::to_string),
ruled_out,
usable: true,
}
}
#[test]
fn a_spent_account_does_not_hand_the_turn_to_its_own_twin() {
let list = [
c("bsgong", Some("8dd1a9aa"), true),
c("bsgong-slot", Some("8dd1a9aa"), false),
c("rnd", Some("202743db"), false),
];
assert_eq!(
next_usable(&list).map(|c| c.name.as_str()),
Some("rnd"),
"the twin is behind the same wall"
);
}
#[test]
fn an_ordinary_failover_still_moves_to_the_next_account() {
let list = [c("a", Some("u-a"), true), c("b", Some("u-b"), false)];
assert_eq!(next_usable(&list).map(|c| c.name.as_str()), Some("b"));
}
#[test]
fn unknown_identities_do_not_block_a_failover() {
let list = [c("a", None, true), c("b", None, false)];
assert_eq!(next_usable(&list).map(|c| c.name.as_str()), Some("b"));
}
#[test]
fn a_slot_with_no_usable_login_is_never_offered() {
let mut only = c("a", Some("u"), false);
only.usable = false;
assert_eq!(next_usable(&[only]), None);
}
#[test]
fn nothing_tried_burns_nothing() {
assert!(burned_uuids(&named(), &[]).is_empty());
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
fn slot(name: &str, dir: &str) -> crate::slots::SlotRecord {
crate::slots::SlotRecord {
tool: "claude-code".into(),
name: name.into(),
id: name.into(),
config_dir: PathBuf::from(dir),
adopted: false,
}
}
#[test]
fn a_changed_pointer_wins_over_a_rotation() {
let slots = vec![slot("rnd", "/s/rnd"), slot("bsgong", "/s/bsgong")];
let mut c = Chooser::default();
assert_eq!(
c.choose(Some(&PathBuf::from("/s/rnd")), None, &slots)
.unwrap()
.name,
"rnd"
);
assert_eq!(
c.choose(Some(&PathBuf::from("/s/rnd")), Some("bsgong"), &slots)
.unwrap()
.name,
"bsgong"
);
assert_eq!(
c.choose(Some(&PathBuf::from("/s/bsgong")), Some("bsgong"), &slots)
.unwrap()
.name,
"bsgong"
);
assert_eq!(
c.choose(Some(&PathBuf::from("/s/rnd")), Some("bsgong"), &slots)
.unwrap()
.name,
"rnd",
"an explicit new choice overrides the rotation"
);
}
#[test]
fn unknown_pointer_falls_back_and_no_slots_yields_nothing() {
let slots = vec![slot("rnd", "/s/rnd")];
let mut c = Chooser::default();
assert_eq!(
c.choose(Some(&PathBuf::from("/nope")), None, &slots)
.unwrap()
.name,
"rnd",
"an unresolvable pointer still serves the request"
);
assert!(c.choose(None, None, &[]).is_none(), "no slots -> no choice");
}
#[test]
fn rotation_skips_the_current_and_the_known_spent_accounts() {
use crate::proxy::ratelimit::Quota;
let slots = vec![
slot("rnd", "/s/rnd"),
slot("bsgong", "/s/b"),
slot("claude", "/s/c"),
];
let spent = |name: &str| {
(
name.to_string(),
Quota {
rejected: true,
..Default::default()
},
)
};
let mut state: std::collections::HashMap<String, Quota> =
[spent("rnd"), spent("bsgong")].into_iter().collect();
assert_eq!(
rotate_target("rnd", &slots, &state).as_deref(),
Some("claude"),
"the first account that is neither current nor spent"
);
state.extend([spent("claude")]);
assert_eq!(
rotate_target("rnd", &slots, &state),
None,
"every account spent -> nothing to rotate to"
);
}
#[test]
fn headroom_is_the_worst_window_not_the_best() {
assert_eq!(headroom(Some(2.0), Some(97.0)), Some(3.0));
assert_eq!(headroom(Some(40.0), None), Some(60.0));
assert_eq!(headroom(None, None), None, "unmeasured is not empty");
}
#[test]
fn candidates_sort_by_room_with_explicit_rank_winning() {
let mut v = vec![
("plenty", usize::MAX, Some(90.0)),
("scarce", usize::MAX, Some(5.0)),
("unknown", usize::MAX, None),
("pinned", 0, Some(1.0)),
];
by_headroom(&mut v, |t| t.1, |t| t.2);
assert_eq!(
v.iter().map(|t| t.0).collect::<Vec<_>>(),
vec!["pinned", "plenty", "scarce", "unknown"],
"a pinned account first, then most room, unmeasured last"
);
}
#[test]
fn over_threshold_only_fires_on_a_measured_window() {
assert!(over_threshold(Some(98.0), None, 0.98));
assert!(over_threshold(Some(99.5), None, 0.98));
assert!(
over_threshold(None, Some(100.0), 0.98),
"either window can trip it"
);
assert!(!over_threshold(Some(97.9), Some(50.0), 0.98));
assert!(!over_threshold(None, None, 0.98));
assert!(!over_threshold(Some(99.0), None, 1.0));
assert!(over_threshold(Some(100.0), None, 1.0));
}
#[test]
fn a_rotation_naming_an_unknown_account_is_ignored() {
let slots = vec![slot("rnd", "/s/rnd")];
let mut c = Chooser::default();
c.choose(Some(&PathBuf::from("/s/rnd")), None, &slots);
assert_eq!(
c.choose(Some(&PathBuf::from("/s/rnd")), Some("deleted"), &slots)
.unwrap()
.name,
"rnd",
"a rotation target removed meanwhile must not strand the request"
);
}
}
#[derive(Default)]
pub struct Sidelined {
marks: std::collections::HashMap<String, std::time::Instant>,
}
pub const SIDELINE_FOR: std::time::Duration = std::time::Duration::from_secs(600);
impl Sidelined {
pub fn mark(&mut self, name: &str, now: std::time::Instant) {
self.marks.insert(name.to_string(), now);
}
pub fn contains(&self, name: &str, now: std::time::Instant) -> bool {
self.marks
.get(name)
.is_some_and(|at| now.duration_since(*at) < SIDELINE_FOR)
}
pub fn clear(&mut self, name: &str) {
self.marks.remove(name);
}
pub fn active(&self, now: std::time::Instant) -> usize {
self.marks
.values()
.filter(|at| now.duration_since(**at) < SIDELINE_FOR)
.count()
}
}
#[cfg(test)]
mod sidelined_tests {
use super::*;
use std::time::Instant;
#[test]
fn a_refusal_holds_an_account_out_then_lets_it_back() {
let mut s = Sidelined::default();
let t0 = Instant::now();
s.mark("rnd", t0);
assert!(s.contains("rnd", t0), "held out right after the refusal");
assert!(
s.contains("rnd", t0 + SIDELINE_FOR - std::time::Duration::from_secs(1)),
"still held out inside the window"
);
assert!(
!s.contains("rnd", t0 + SIDELINE_FOR),
"and offered again once it lapses - a login fixed meanwhile must be usable"
);
assert_eq!(s.active(t0 + SIDELINE_FOR), 0);
}
#[test]
fn naming_an_account_puts_it_back_at_once() {
let mut s = Sidelined::default();
let t0 = Instant::now();
s.mark("rnd", t0);
s.clear("rnd");
assert!(!s.contains("rnd", t0));
}
}
#[cfg(test)]
mod extra_usage_tests {
use super::*;
#[test]
fn credits_do_not_keep_a_capped_account_in_front() {
for credits in [false, true] {
assert!(
over_threshold_with(Some(100.0), Some(55.0), 0.98, credits),
"capped is capped (credits: {credits})"
);
}
}
#[test]
fn credits_change_nothing_below_the_threshold() {
for credits in [false, true] {
assert!(!over_threshold_with(Some(10.0), Some(20.0), 0.98, credits));
}
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum Strategy {
#[default]
Roomiest,
ConsumeFirst,
}
impl Strategy {
pub fn parse(s: &str) -> Option<Self> {
match s.trim().to_ascii_lowercase().as_str() {
"roomiest" | "best" => Some(Self::Roomiest),
"consume-first" | "consume_first" | "soonest" => Some(Self::ConsumeFirst),
_ => None,
}
}
pub fn as_str(self) -> &'static str {
match self {
Self::Roomiest => "roomiest",
Self::ConsumeFirst => "consume-first",
}
}
}
pub fn order_by<T>(
items: &mut [T],
strategy: Strategy,
rank: impl Fn(&T) -> usize,
room: impl Fn(&T) -> Option<f64>,
resets_in: impl Fn(&T) -> Option<i64>,
) {
match strategy {
Strategy::Roomiest => by_headroom(items, rank, room),
Strategy::ConsumeFirst => items.sort_by(|a, b| {
rank(a).cmp(&rank(b)).then_with(|| {
let usable = |t: &T| room(t).is_none_or(|r| r > 0.0);
match (usable(a), usable(b)) {
(true, false) => return std::cmp::Ordering::Less,
(false, true) => return std::cmp::Ordering::Greater,
_ => {}
}
match (resets_in(a), resets_in(b)) {
(Some(x), Some(y)) => x.cmp(&y), (Some(_), None) => std::cmp::Ordering::Less,
(None, Some(_)) => std::cmp::Ordering::Greater,
(None, None) => std::cmp::Ordering::Equal,
}
})
}),
}
}
pub const HYSTERESIS_MARGIN: f64 = 10.0;
pub fn worth_moving_to(
current_room: Option<f64>,
candidate_room: Option<f64>,
margin: f64,
) -> bool {
match (current_room, candidate_room) {
(Some(now), Some(next)) => next - now >= margin,
_ => false,
}
}
#[cfg(test)]
mod strategy_tests {
use super::*;
type Acct = (&'static str, usize, Option<f64>, Option<i64>);
fn order(strategy: Strategy, mut v: Vec<Acct>) -> Vec<&'static str> {
order_by(&mut v, strategy, |a| a.1, |a| a.2, |a| a.3);
v.into_iter().map(|a| a.0).collect()
}
#[test]
fn roomiest_takes_the_account_with_the_most_left() {
let v = vec![
("tight", 0, Some(5.0), Some(60)),
("roomy", 0, Some(80.0), Some(9_000)),
];
assert_eq!(order(Strategy::Roomiest, v), ["roomy", "tight"]);
}
#[test]
fn consume_first_takes_the_window_about_to_reset() {
let v = vec![
("roomy", 0, Some(80.0), Some(9_000)),
("soon", 0, Some(30.0), Some(60)),
];
assert_eq!(order(Strategy::ConsumeFirst, v), ["soon", "roomy"]);
}
#[test]
fn consume_first_still_skips_an_account_with_nothing_in_it() {
let v = vec![
("empty", 0, Some(0.0), Some(30)),
("has-some", 0, Some(20.0), Some(9_000)),
];
assert_eq!(order(Strategy::ConsumeFirst, v), ["has-some", "empty"]);
}
#[test]
fn an_explicit_rank_outranks_both_strategies() {
for s in [Strategy::Roomiest, Strategy::ConsumeFirst] {
let v = vec![
("second", 1, Some(99.0), Some(10)),
("preferred", 0, Some(1.0), Some(99_999)),
];
assert_eq!(order(s, v)[0], "preferred", "{s:?}");
}
}
#[test]
fn unmeasured_accounts_sort_after_measured_ones() {
for s in [Strategy::Roomiest, Strategy::ConsumeFirst] {
let v = vec![
("unknown", 0, None, None),
("known", 0, Some(50.0), Some(500)),
];
assert_eq!(order(s, v)[0], "known", "{s:?}");
}
}
#[test]
fn the_names_round_trip() {
for s in [Strategy::Roomiest, Strategy::ConsumeFirst] {
assert_eq!(Strategy::parse(s.as_str()), Some(s));
}
assert_eq!(Strategy::parse("best"), Some(Strategy::Roomiest));
assert_eq!(Strategy::parse("nonsense"), None);
}
}
#[cfg(test)]
mod hysteresis_tests {
use super::*;
#[test]
fn a_marginal_improvement_is_not_worth_the_move() {
assert!(!worth_moving_to(Some(8.0), Some(12.0), HYSTERESIS_MARGIN));
assert!(worth_moving_to(Some(8.0), Some(40.0), HYSTERESIS_MARGIN));
}
#[test]
fn moving_to_something_worse_is_never_worth_it() {
assert!(!worth_moving_to(Some(50.0), Some(20.0), HYSTERESIS_MARGIN));
}
#[test]
fn an_unmeasured_side_is_not_a_reason_to_move() {
assert!(!worth_moving_to(None, Some(90.0), HYSTERESIS_MARGIN));
assert!(!worth_moving_to(Some(1.0), None, HYSTERESIS_MARGIN));
}
}
pub fn measure_after(headroom: Option<f64>) -> std::time::Duration {
let secs = match headroom {
None => 60,
Some(h) if h <= 10.0 => 60,
Some(h) if h <= 25.0 => 120,
Some(h) if h <= 50.0 => 300,
_ => 900,
};
std::time::Duration::from_secs(secs)
}
#[cfg(test)]
mod pacing_tests {
use super::*;
#[test]
fn a_nearly_spent_account_is_watched_closely() {
assert!(measure_after(Some(5.0)) <= std::time::Duration::from_secs(60));
}
#[test]
fn a_fresh_one_is_left_alone_far_longer() {
assert!(measure_after(Some(95.0)) >= std::time::Duration::from_secs(600));
}
#[test]
fn more_room_never_means_a_shorter_wait() {
let mut last = std::time::Duration::ZERO;
for h in [0.0, 10.0, 25.0, 50.0, 75.0, 100.0] {
let d = measure_after(Some(h));
assert!(d >= last, "wait shrank at {h}%: {last:?} -> {d:?}");
last = d;
}
}
#[test]
fn an_unmeasured_account_is_read_soon() {
assert_eq!(measure_after(None), measure_after(Some(0.0)));
}
}