mirror of
https://github.com/CodeZeno/Claude-Code-Usage-Monitor.git
synced 2026-06-20 10:19:27 +08:00
feat: add ETD projection math with unit tests
This commit is contained in:
parent
e275646007
commit
fb2db2fe21
@ -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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user