feat: add ETD projection math with unit tests

This commit is contained in:
Carlos Miguel C. Resurreccion 2026-06-19 16:43:33 +08:00
parent e275646007
commit fb2db2fe21

View File

@ -1097,3 +1097,89 @@ pub fn app_is_past_reset(data: &AppUsageData) -> bool {
data.claude_code.as_ref().is_some_and(is_past_reset)
|| data.codex.as_ref().is_some_and(is_past_reset)
}
/// Rolling-quota window lengths, in seconds (5 hours and 7 days). Kept here,
/// next to the formatting that consumes them, so the ETD feature is
/// self-contained and does not depend on constants defined elsewhere.
pub const SESSION_WINDOW_SECS: u64 = 5 * 3600;
pub const WEEKLY_WINDOW_SECS: u64 = 7 * 86400;
/// Estimated seconds until the quota is fully consumed, assuming the current
/// burn rate (quota-so-far / time-so-far) holds. Returns `None` unless the
/// projection lands *before* the window resets — i.e. only when the user is
/// genuinely on pace to deplete early.
fn etd_secs(actual_pct: f64, remaining_secs: u64, window_secs: u64) -> Option<u64> {
if actual_pct <= 0.0 || actual_pct >= 100.0 {
return None;
}
if remaining_secs == 0 || window_secs == 0 {
return None;
}
let elapsed_secs = window_secs.saturating_sub(remaining_secs);
if elapsed_secs == 0 {
return None;
}
let secs = (100.0 - actual_pct) * (elapsed_secs as f64) / actual_pct;
if !secs.is_finite() || secs < 0.0 {
return None;
}
let secs = secs as u64;
(secs < remaining_secs).then_some(secs)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn etd_none_when_on_safe_pace() {
// 10% used, 1h elapsed of a 5h window (4h remaining): steady pace at 1h
// is 20%, so 10% is UNDER pace → would not deplete before reset.
// (50% in the first hour would be at-risk, not safe — see the invariant.)
assert_eq!(etd_secs(10.0, 4 * 3600, 5 * 3600), None);
}
#[test]
fn etd_some_when_at_risk() {
// 60% used, 1h elapsed of a 2h window (1h remaining).
// Remaining 40% at 60%/h needs 40 min < 60 min remaining → at risk.
assert_eq!(etd_secs(60.0, 3600, 2 * 3600), Some(2400));
}
#[test]
fn etd_none_at_boundaries() {
assert_eq!(etd_secs(0.0, 3600, 5 * 3600), None); // nothing used
assert_eq!(etd_secs(100.0, 3600, 5 * 3600), None); // already full
assert_eq!(etd_secs(50.0, 5 * 3600, 5 * 3600), None); // elapsed = 0
assert_eq!(etd_secs(50.0, 0, 5 * 3600), None); // no remaining
assert_eq!(etd_secs(50.0, 3600, 0), None); // no window
}
#[test]
fn etd_invariant_matches_at_risk_rule() {
// etd_secs is Some iff burn rate exceeds steady pace:
// actual_pct > 100 * elapsed / window.
// Skip a small band around the exact boundary to avoid float flakiness.
let window = 5 * 3600u64;
for remaining in (0..=window).step_by(600) {
let elapsed = window - remaining;
for pct_x10 in 1..1000u64 {
let actual = pct_x10 as f64 / 10.0;
if elapsed == 0 || remaining == 0 {
assert_eq!(etd_secs(actual, remaining, window), None);
continue;
}
let boundary = 100.0 * elapsed as f64 / window as f64;
if (actual - boundary).abs() < 0.05 {
continue; // razor's edge — covered by explicit boundary test
}
let at_risk = actual > boundary;
assert_eq!(
etd_secs(actual, remaining, window).is_some(),
at_risk,
"actual={actual} remaining={remaining} window={window}"
);
}
}
}
}