use crate::usage::Window;
pub(crate) const MARGIN: f64 = 5.0;
pub(crate) const TOO_EARLY: f64 = 0.03;
#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize)]
pub struct Pace {
pub expected: f64,
pub delta: f64,
#[serde(flatten)]
pub standing: Standing,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
#[serde(tag = "standing", rename_all = "snake_case")]
pub enum Standing {
Under,
Even,
Over { runs_out_in: i64 },
}
pub fn of(
used: f64,
resets_at: Option<i64>,
length_seconds: Option<i64>,
at: i64,
now: i64,
) -> Option<Pace> {
let length = length_seconds.filter(|length| *length > 0)?;
let resets_at = resets_at?;
let at = at.min(now);
let left = resets_at - at;
if resets_at <= now || left > length || used >= 100.0 {
return None;
}
let gone = length - left;
let part = gone as f64 / length as f64;
if part < TOO_EARLY {
return None;
}
let used = used.max(0.0);
let expected = part * 100.0;
let delta = used - expected;
let standing = if delta > MARGIN {
let from_reading = ((100.0 - used) * gone as f64 / used) as i64;
Standing::Over {
runs_out_in: (from_reading - (now - at)).max(0),
}
} else if delta < -MARGIN {
Standing::Under
} else {
Standing::Even
};
Some(Pace {
expected,
delta,
standing,
})
}
impl Window {
pub fn pace(&self, at: i64, now: i64) -> Option<Pace> {
of(self.percent, self.resets_at, self.length_seconds, at, now)
}
}
pub fn first_to_run_out<T>(
limits: impl IntoIterator<Item = (T, Option<Pace>)>,
) -> Option<(T, i64)> {
limits
.into_iter()
.filter_map(|(limit, pace)| match pace?.standing {
Standing::Over { runs_out_in } => Some((limit, runs_out_in)),
Standing::Under | Standing::Even => None,
})
.min_by_key(|(_, runs_out_in)| *runs_out_in)
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Lasts<'a> {
RunsOut { limit: &'a Window, in_seconds: i64 },
UntilReset { in_seconds: i64 },
}
impl Lasts<'_> {
pub fn seconds(self) -> i64 {
match self {
Lasts::RunsOut { in_seconds, .. } | Lasts::UntilReset { in_seconds } => in_seconds,
}
}
}
pub(crate) fn lasts(windows: &[Window], at: i64, now: i64) -> Option<Lasts<'_>> {
let paced = windows.iter().map(|w| (w, w.pace(at, now)));
if let Some((limit, in_seconds)) = first_to_run_out(paced) {
return Some(Lasts::RunsOut { limit, in_seconds });
}
until_reset(windows, now)
}
pub(crate) fn until_reset(windows: &[Window], now: i64) -> Option<Lasts<'_>> {
windows
.iter()
.filter_map(|w| w.resets_at.map(|at| at - now))
.filter(|left| *left > 0)
.min()
.map(|in_seconds| Lasts::UntilReset { in_seconds })
}
#[cfg(test)]
mod tests {
use super::*;
const NOW: i64 = 1_789_935_000;
const HOUR: i64 = 3_600;
const DAY: i64 = 86_400;
const WEEK: i64 = 7 * DAY;
fn pace(used: f64, gone: i64, length: i64) -> Option<Pace> {
of(used, Some(NOW + length - gone), Some(length), NOW, NOW)
}
fn window(kind: &str, used: f64, gone: i64, length: i64) -> Window {
Window {
kind: kind.into(),
scope: None,
severity: None,
percent: used,
resets_at: Some(NOW + length - gone),
is_active: true,
length_seconds: Some(length),
}
}
#[test]
fn a_limit_is_measured_against_the_part_of_its_window_gone_by() {
let half = pace(50.0, WEEK / 2, WEEK).unwrap();
assert!((half.expected - 50.0).abs() < 1e-9);
assert!(half.delta.abs() < 1e-9);
assert_eq!(half.standing, Standing::Even);
let gone = 10 * HOUR + 18 * 60;
let busy = pace(33.0, gone, WEEK).unwrap();
assert!((busy.expected - 6.13).abs() < 0.01, "{}", busy.expected);
assert!(matches!(busy.standing, Standing::Over { .. }));
assert_eq!(
pace(10.0, WEEK / 2, WEEK).unwrap().standing,
Standing::Under
);
assert_eq!(
pace(54.9, WEEK / 2, WEEK).unwrap().standing,
Standing::Even,
"within the margin"
);
assert_eq!(
pace(44.9, WEEK / 2, WEEK).unwrap().standing,
Standing::Under
);
}
#[test]
fn a_limit_faster_than_even_says_when_it_runs_out() {
let gone = 10 * HOUR;
let Standing::Over { runs_out_in } = pace(33.0, gone, WEEK).unwrap().standing else {
panic!("faster than even");
};
let at_its_rate = (67.0 * gone as f64 / 33.0) as i64;
assert_eq!(runs_out_in, at_its_rate);
assert!(runs_out_in < WEEK - gone, "before the reset");
let Standing::Over { runs_out_in } = pace(55.1, 5 * HOUR / 2, 5 * HOUR).unwrap().standing
else {
panic!("faster than even");
};
assert!(runs_out_in < 5 * HOUR / 2, "{runs_out_in}");
}
#[test]
fn a_pace_is_said_only_where_it_means_something() {
assert_eq!(of(40.0, None, Some(WEEK), NOW, NOW), None);
assert_eq!(of(40.0, Some(NOW + DAY), None, NOW, NOW), None);
assert_eq!(of(40.0, Some(NOW + DAY), Some(0), NOW, NOW), None);
assert_eq!(of(40.0, Some(NOW), Some(WEEK), NOW, NOW), None);
assert_eq!(of(40.0, Some(NOW - 60), Some(WEEK), NOW, NOW), None);
assert_eq!(of(40.0, Some(NOW + WEEK + 60), Some(WEEK), NOW, NOW), None);
assert_eq!(pace(100.0, DAY, WEEK), None, "used up says so itself");
assert_eq!(pace(104.0, DAY, WEEK), None);
let early = (TOO_EARLY * (5 * HOUR) as f64) as i64;
assert_eq!(pace(20.0, early - 1, 5 * HOUR), None);
assert!(pace(20.0, early + 1, 5 * HOUR).is_some());
}
#[test]
fn a_readings_pace_is_its_own_and_only_counts_down() {
let read = NOW - HOUR;
let resets = NOW + WEEK - 11 * HOUR;
let then = of(33.0, Some(resets), Some(WEEK), read, read).unwrap();
let later = of(33.0, Some(resets), Some(WEEK), read, NOW).unwrap();
assert_eq!(later.expected, then.expected);
assert_eq!(later.delta, then.delta);
let (Standing::Over { runs_out_in: was }, Standing::Over { runs_out_in: is }) =
(then.standing, later.standing)
else {
panic!("over pace both times");
};
assert_eq!(is, was - HOUR);
let long_ago = of(90.0, Some(NOW + 3 * DAY), Some(WEEK), NOW - 3 * DAY, NOW).unwrap();
assert_eq!(
long_ago.standing,
Standing::Over { runs_out_in: 0 },
"projected to have run out already: about to, as far as anyone knows"
);
assert_eq!(
of(33.0, Some(NOW - 1), Some(WEEK), NOW - DAY, NOW),
None,
"it has reset since it was read"
);
assert_eq!(
of(33.0, Some(resets), Some(WEEK), NOW + HOUR, NOW),
of(33.0, Some(resets), Some(WEEK), NOW, NOW),
"a reading from a clock ahead of this one counts as taken now"
);
}
#[test]
fn an_untouched_limit_is_under_pace() {
assert_eq!(pace(0.0, DAY, WEEK).unwrap().standing, Standing::Under);
assert_eq!(pace(-1.0, DAY, WEEK).unwrap().standing, Standing::Under);
}
#[test]
fn an_account_lasts_until_its_first_limit_runs_out_or_resets() {
let session = window("session", 20.0, 4 * HOUR, 5 * HOUR);
let week = window("weekly_all", 33.0, 10 * HOUR, WEEK);
let busy = [session.clone(), week];
let Some(Lasts::RunsOut { limit, in_seconds }) = lasts(&busy, NOW, NOW) else {
panic!("the week runs out");
};
assert_eq!(limit.kind, "weekly_all");
assert_eq!(in_seconds, (67.0 * (10 * HOUR) as f64 / 33.0) as i64);
let calm = window("weekly_all", 5.0, 3 * DAY, WEEK);
assert_eq!(
lasts(&[session, calm], NOW, NOW),
Some(Lasts::UntilReset { in_seconds: HOUR })
);
let mut unknown = window("weekly_all", 5.0, 3 * DAY, WEEK);
unknown.resets_at = None;
assert_eq!(lasts(&[unknown], NOW, NOW), None);
assert_eq!(
until_reset(&busy, NOW),
Some(Lasts::UntilReset { in_seconds: HOUR }),
"the week's pace says nothing of an account nobody is using"
);
}
#[test]
fn the_first_limit_to_run_out_is_the_soonest() {
let paces = [
("week", pace(40.0, DAY, WEEK)),
("session", pace(90.0, 2 * HOUR, 5 * HOUR)),
("calm", pace(1.0, DAY, WEEK)),
("unknown", None),
];
let (first, _) = first_to_run_out(paces).unwrap();
assert_eq!(first, "session");
assert_eq!(first_to_run_out([("calm", pace(1.0, DAY, WEEK))]), None);
}
#[test]
fn a_pace_reads_in_json_as_its_figures_and_standing() {
let over = Pace {
expected: 6.0,
delta: 27.0,
standing: Standing::Over {
runs_out_in: 72_000,
},
};
assert_eq!(
serde_json::to_value(over).unwrap(),
serde_json::json!({
"expected": 6.0,
"delta": 27.0,
"standing": "over",
"runs_out_in": 72_000,
})
);
let under = Pace {
expected: 50.0,
delta: -40.0,
standing: Standing::Under,
};
assert_eq!(
serde_json::to_value(under).unwrap(),
serde_json::json!({"expected": 50.0, "delta": -40.0, "standing": "under"})
);
}
}