diff --git a/apps/desktop-tauri/src-tauri/src/commands/bridge.rs b/apps/desktop-tauri/src-tauri/src/commands/bridge.rs index 0e0d00f072..f3063b125e 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/bridge.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/bridge.rs @@ -83,6 +83,22 @@ impl RateWindowSnapshot { } } + /// The core window this snapshot was built from, as far as pace needs it: + /// usage, length, reset time and reset description. + pub(crate) fn to_rate_window(&self) -> RateWindow { + let resets_at = self + .resets_at + .as_deref() + .and_then(|value| chrono::DateTime::parse_from_rfc3339(value).ok()) + .map(|value| value.with_timezone(&chrono::Utc)); + RateWindow::with_details( + self.used_percent, + self.window_minutes, + resets_at, + self.reset_description.clone(), + ) + } + /// Enrich with raw reserve info derived from pace analysis. /// delta_percent = actual - expected; negative means ahead (in reserve). /// Only meaningful for longer windows (weekly); skip if reserve rounds to 0. @@ -208,18 +224,6 @@ pub struct PaceSnapshot { /// Block of the window this pace comes from (upstream hides its pace). #[serde(default)] pub monthly_limit_block: Option, - /// Whether the window this pace comes from carries its own length. - /// Upstream `UsageStore.weeklyPace` (UsageStore+HistoricalPace.swift) - /// gives the menu bar no pace for a generic window without - /// `windowMinutes`, rather than scoring it against the 7-day fallback, - /// so the tray pace tint skips it. Card lanes keep their own pace. - /// Tray-only; not sent to the frontend. - #[serde(default = "default_window_has_length", skip_serializing)] - pub window_has_length: bool, -} - -fn default_window_has_length() -> bool { - true } /// One burndown chart point (RFC 3339 capture time + remaining percent). @@ -353,6 +357,11 @@ pub struct ProviderUsageSnapshot { pub error_state: codexbar::core::ProviderStateKind, #[serde(default)] pub pace: Option, + /// Whether the fetch allows pace (upstream `allowsPace(dataConfidence:)`). + /// The tray scores its pace tint at presentation time, so it needs this + /// beside the windows. Tray-only; not sent to the frontend. + #[serde(default = "default_pace_authoritative", skip_serializing)] + pub pace_authoritative: bool, #[serde(default)] pub account_organization: Option, #[serde(default)] @@ -380,6 +389,10 @@ fn default_source_label() -> String { "seed".to_string() } +fn default_pace_authoritative() -> bool { + true +} + fn default_error_state() -> codexbar::core::ProviderStateKind { codexbar::core::ProviderStateKind::Unknown } @@ -475,8 +488,6 @@ impl ProviderUsageSnapshot { expected_used_percent: p.expected_used_percent, actual_used_percent: p.actual_used_percent, monthly_limit_block: MonthlyLimitBlockSnapshot::for_pace(usage, &blocked), - window_has_length: primary_pace_window - .is_some_and(|window| window.window_minutes.is_some()), }); // Pace of the secondary lane, which also derives the row's reserve. @@ -685,6 +696,7 @@ impl ProviderUsageSnapshot { error: None, error_state: codexbar::core::ProviderStateKind::Ready, pace, + pace_authoritative: allows_pace, account_organization: usage.account_organization.clone(), tray_status_label: None, fetch_duration_ms: None, @@ -744,6 +756,7 @@ impl ProviderUsageSnapshot { error: Some(error), error_state: state_kind, pace: None, + pace_authoritative: true, account_organization: None, tray_status_label: None, fetch_duration_ms: None, diff --git a/apps/desktop-tauri/src-tauri/src/commands/bridge/pace.rs b/apps/desktop-tauri/src-tauri/src/commands/bridge/pace.rs index 326c5f2a9b..73b83fa35f 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/bridge/pace.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/bridge/pace.rs @@ -267,6 +267,15 @@ mod tests { assert_eq!(snap.primary.pace, None); assert_eq!(snap.secondary.as_ref().unwrap().pace, None); + // The tray scores its own pace, so it needs the flag too. + assert!(!snap.pace_authoritative); + assert!( + snapshot( + UsageSnapshot::new(resetting_in(now, 60.0, Some(300), 150)), + true + ) + .pace_authoritative + ); } #[test] @@ -386,18 +395,17 @@ mod tests { // Extras keep plain lane pace, as on the Mac: a 14-day reset does not // fit the 7-day fallback, so the seat-credit window shows none. assert_eq!(snap.extra_rate_windows[0].window.pace, None); - // The provider-level pace (tray colour) needs a window length, as - // upstream `UsageStore.weeklyPace` does, so it stays unset here. + // The provider-level pace (the Settings detail) scores the length-less + // window against the 7-day fallback, which a 14-day reset overruns. assert!(snap.pace.is_none()); } /// Within the 7-day fallback, a length-less Copilot window still yields - /// a provider-level pace (the Settings detail shows it), but upstream - /// `UsageStore.weeklyPace` gives the menu bar none for a window without - /// `windowMinutes`, so it is marked as having no length for the tray. - /// The card lane keeps its reset-window pace. + /// a provider-level pace (the Settings detail shows it). The card lane + /// keeps its reset-window pace. The tray scores its own pace and skips + /// the window (`tray_presentation::menu_bar_pace`). #[test] - fn copilot_provider_pace_is_marked_lengthless_for_the_tray() { + fn copilot_lengthless_window_keeps_provider_and_lane_pace() { let now = february_now(); let usage = UsageSnapshot::new(RateWindow::with_details( 60.0, @@ -408,29 +416,11 @@ mod tests { let snap = snapshot_at(ProviderId::Copilot, usage); - let pace = snap.pace.as_ref().expect("provider-level pace"); - assert!(!pace.window_has_length); + assert!(snap.pace.is_some()); assert!(lane_pace(&snap.primary).reset_window); - let json = serde_json::to_value(pace).unwrap(); - assert!(json.get("windowHasLength").is_none()); - } - - #[test] - fn provider_pace_from_a_window_with_a_length_keeps_the_tray_tint() { - let now = february_now(); - let usage = UsageSnapshot::new(RateWindow::with_details( - 60.0, - Some(10_080), - Some(now + Duration::days(3)), - None, - )); - - let snap = snapshot_at(ProviderId::Claude, usage); - - assert!(snap.pace.as_ref().expect("pace").window_has_length); - let cached: PaceSnapshot = - serde_json::from_str(r#"{"stage":"behind","deltaPercent":-8.0}"#).unwrap(); - assert!(cached.window_has_length); + assert!(snap.pace_authoritative); + let json = serde_json::to_value(&snap).unwrap(); + assert!(json.get("paceAuthoritative").is_none()); } /// Cursor's plan bars carry the billing-cycle length; the API bar is the diff --git a/apps/desktop-tauri/src-tauri/src/commands/chart.rs b/apps/desktop-tauri/src-tauri/src/commands/chart.rs index 32883200a0..ebd184a4a5 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/chart.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/chart.rs @@ -8,7 +8,7 @@ use crate::commands::bridge::RateWindowSnapshot; use crate::state::AppState; use chrono::{DateTime, Utc}; -use codexbar::core::{OpenAIDashboardCacheStore, RateWindow}; +use codexbar::core::OpenAIDashboardCacheStore; use codexbar::cost_reporting_period::{CostReportingPeriod, CostTimeZone}; use codexbar::cost_scanner::{ CostScanner, CostSummary, TodayUsage, get_daily_cost_history, get_daily_token_history, @@ -221,7 +221,9 @@ fn build_provider_chart_data_with_cancel( ..ProviderChartData::empty(provider_id) }; } - let live_window = weekly_window.as_ref().map(rate_window_from_snapshot); + let live_window = weekly_window + .as_ref() + .map(RateWindowSnapshot::to_rate_window); let build_snapshot = || { codexbar::providers::chart::build_chart_snapshot( &provider_id, @@ -466,20 +468,6 @@ fn local_usage_summary_from_cost_summary( }) } -fn rate_window_from_snapshot(snapshot: &RateWindowSnapshot) -> RateWindow { - let resets_at = snapshot - .resets_at - .as_deref() - .and_then(|value| DateTime::parse_from_rfc3339(value).ok()) - .map(|value| value.with_timezone(&Utc)); - RateWindow::with_details( - snapshot.used_percent, - snapshot.window_minutes, - resets_at, - snapshot.reset_description.clone(), - ) -} - fn active_chart_scans() -> &'static Mutex>> { static ACTIVE: OnceLock>>> = OnceLock::new(); ACTIVE.get_or_init(|| Mutex::new(HashMap::new())) diff --git a/apps/desktop-tauri/src-tauri/src/commands/providers.rs b/apps/desktop-tauri/src-tauri/src/commands/providers.rs index 496b8621ad..f9f79e8112 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/providers.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/providers.rs @@ -1722,6 +1722,7 @@ mod reset_backfill_tests { error: None, error_state: codexbar::core::ProviderStateKind::Ready, pace: None, + pace_authoritative: true, account_organization: None, tray_status_label: None, fetch_duration_ms: None, diff --git a/apps/desktop-tauri/src-tauri/src/commands/providers/reset_backfill.rs b/apps/desktop-tauri/src-tauri/src/commands/providers/reset_backfill.rs index 86dfc3e84f..5a41b498a6 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/providers/reset_backfill.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/providers/reset_backfill.rs @@ -144,6 +144,7 @@ mod tests { error: None, error_state: codexbar::core::ProviderStateKind::Ready, pace: None, + pace_authoritative: true, account_organization: None, tray_status_label: None, fetch_duration_ms: None, diff --git a/apps/desktop-tauri/src-tauri/src/powertoys.rs b/apps/desktop-tauri/src-tauri/src/powertoys.rs index 78eb086e4b..5b59a047db 100644 --- a/apps/desktop-tauri/src-tauri/src/powertoys.rs +++ b/apps/desktop-tauri/src-tauri/src/powertoys.rs @@ -217,6 +217,7 @@ mod tests { error: None, error_state: codexbar::core::ProviderStateKind::Ready, pace: None, + pace_authoritative: true, account_organization: Some("Example Org".to_string()), tray_status_label: None, fetch_duration_ms: None, @@ -276,6 +277,7 @@ mod tests { error: None, error_state: codexbar::core::ProviderStateKind::Ready, pace: None, + pace_authoritative: true, account_organization: None, tray_status_label: None, fetch_duration_ms: None, diff --git a/apps/desktop-tauri/src-tauri/src/tray_bridge.rs b/apps/desktop-tauri/src-tauri/src/tray_bridge.rs index 7aa865c46c..92c585d277 100644 --- a/apps/desktop-tauri/src-tauri/src/tray_bridge.rs +++ b/apps/desktop-tauri/src-tauri/src/tray_bridge.rs @@ -778,6 +778,7 @@ mod tests { error: None, error_state: codexbar::core::ProviderStateKind::Ready, pace: None, + pace_authoritative: true, account_organization: None, tray_status_label: None, fetch_duration_ms: None, diff --git a/apps/desktop-tauri/src-tauri/src/tray_presentation.rs b/apps/desktop-tauri/src-tauri/src/tray_presentation.rs index 576db0cc79..d8f0ac430f 100644 --- a/apps/desktop-tauri/src-tauri/src/tray_presentation.rs +++ b/apps/desktop-tauri/src-tauri/src/tray_presentation.rs @@ -1,6 +1,8 @@ //! Pure tray presentation policy shared by the native tray surfaces. use crate::commands::{ProviderUsageSnapshot, RateWindowSnapshot}; +use chrono::{DateTime, Utc}; +use codexbar::core::{IconLane, ProviderId, UsagePace}; use codexbar::settings::{Language, MetricPreference, Settings, TrayIconMode}; use codexbar::tray::{ TrayPaceColor, render_bar_icon_rgba_with_pace, render_percent_icon_rgba_with_pace, @@ -56,6 +58,16 @@ impl<'a> TrayPresentationPlan<'a> { settings: &'a Settings, snapshots: &'a [ProviderUsageSnapshot], rates_cache: Option<&'a crate::commands::CurrencyRateCache>, + ) -> Self { + Self::resolve_at(settings, snapshots, rates_cache, Utc::now()) + } + + /// [`Self::resolve`] with the pace tint scored at `now`. + pub(crate) fn resolve_at( + settings: &'a Settings, + snapshots: &'a [ProviderUsageSnapshot], + rates_cache: Option<&'a crate::commands::CurrencyRateCache>, + now: DateTime, ) -> Self { let ordered = ordered_snapshot_refs(settings, snapshots); let healthy = ordered @@ -66,7 +78,7 @@ impl<'a> TrayPresentationPlan<'a> { // The retired Display mode "minimal" is folded into this flag once on load. let prefer_highest = settings.menu_bar_shows_highest_usage; let selected = pick_tray_provider(&healthy, prefer_highest); - let icon_pace = tray_pace_color(settings, selected); + let icon_pace = tray_pace_color(settings, selected, now); let (icon, status_rows) = match settings.tray_icon_mode { TrayIconMode::Stacked => { @@ -219,20 +231,73 @@ fn resolve_single_provider_icon_plan( /// Pace tint for the provider that drives a single-provider tray icon. /// /// Opt-in through `menu_bar_color_pace`; on-track and unknown stages keep the -/// normal usage-level colour. A pace scored from a window without its own -/// length gets no tint, as upstream `UsageStore.weeklyPace` gives the menu -/// bar no pace for it (Copilot's reset-date-only windows, for example). +/// normal usage-level colour. The pace comes from the menu bar pace window +/// (upstream `menuBarPaceWindow`, StatusItemController+Animation.swift +/// :1207-1225): the lane the provider declares, or else the window the icon +/// draws for the selected metric. fn tray_pace_color( settings: &Settings, selected: Option<&ProviderUsageSnapshot>, + now: DateTime, ) -> Option { if !settings.menu_bar_color_pace { return None; } - selected - .and_then(|snapshot| snapshot.pace.as_ref()) - .filter(|pace| pace.window_has_length) - .and_then(|pace| TrayPaceColor::from_stage(&pace.stage)) + let snapshot = selected?; + let window = menu_bar_pace_window(snapshot, settings)?; + let pace = menu_bar_pace(snapshot, &window, settings.weekly_progress_work_days, now)?; + TrayPaceColor::from_stage(crate::commands::pace::stage_str(pace.stage)) +} + +/// Window the menu bar pace is scored on. A provider-declared lane wins over +/// the selected metric; a missing declared lane gives no pace. +fn menu_bar_pace_window( + snapshot: &ProviderUsageSnapshot, + settings: &Settings, +) -> Option { + let declared = ProviderId::from_cli_name(&snapshot.provider_id) + .and_then(|id| codexbar::core::instantiate_provider(id).menu_bar_pace_lane()); + match declared { + Some(IconLane::Primary) => Some(snapshot.primary.clone()), + Some(IconLane::Secondary) => snapshot.secondary.clone(), + None => Some(crate::usage_metric::selected_usage_icon_windows(snapshot, settings).0), + } +} + +/// Generic branch of upstream `UsageStore.weeklyPace` +/// (UsageStore+HistoricalPace.swift:8-64): the fetch must allow pace and the +/// window must have quota left and its own length (no 7-day fallback). A +/// monthly sentinel resolves to the real calendar cycle first +/// (`paceWindowForElapsedEligibility`, :118-124), and a pace expecting less +/// than 3% use is dropped. +fn menu_bar_pace( + snapshot: &ProviderUsageSnapshot, + window: &RateWindowSnapshot, + work_days: Option, + now: DateTime, +) -> Option { + const MINIMUM_EXPECTED_PERCENT: f64 = 3.0; + if !snapshot.pace_authoritative || window.is_informational { + return None; + } + window.window_minutes?; + let rate_window = window.to_rate_window(); + if rate_window.remaining_percent() <= 0.0 { + return None; + } + let pace_window = match ProviderId::from_cli_name(&snapshot.provider_id) { + Some(id) => id + .pace_capability() + .resolved_reset_window_for_pace(&rate_window), + None => rate_window, + }; + let pace = UsagePace::weekly( + &pace_window, + Some(now), + crate::commands::pace::WEEKLY_FALLBACK_MINUTES, + work_days, + )?; + (pace.expected_used_percent >= MINIMUM_EXPECTED_PERCENT).then_some(pace) } fn fallback_percents( diff --git a/apps/desktop-tauri/src-tauri/src/tray_presentation_tests.rs b/apps/desktop-tauri/src-tauri/src/tray_presentation_tests.rs index 10ccb2921c..445c13317d 100644 --- a/apps/desktop-tauri/src-tauri/src/tray_presentation_tests.rs +++ b/apps/desktop-tauri/src-tauri/src/tray_presentation_tests.rs @@ -74,6 +74,7 @@ fn fake_snapshot_with( error: None, error_state: ProviderStateKind::Ready, pace: None, + pace_authoritative: true, account_organization: None, open_ai_api_usage: None, tray_status_label: None, @@ -724,142 +725,204 @@ fn f5_headline_returns_primary_when_all_informational() { assert!(headline.is_informational); } -#[test] -fn tray_pace_color_is_opt_in_and_follows_canonical_stage() { - let pace = |stage: &str, delta_percent: f64| crate::commands::PaceSnapshot { - stage: stage.to_string(), - delta_percent, - will_last_to_reset: true, - eta_seconds: None, - expected_used_percent: 40.0, - actual_used_percent: 32.0, - monthly_limit_block: None, - window_has_length: true, - }; - let with_pace = |pace: Option| { - let mut snapshot = fake_snapshot("codex", "Codex", 50.0); - snapshot.pace = pace; - vec![snapshot] - }; - let default_settings = Settings::default(); - let colored_settings = Settings { - menu_bar_color_pace: true, - ..Settings::default() - }; - let colored_percent_settings = Settings { +fn pace_now() -> chrono::DateTime { + "2026-10-12T12:00:00Z".parse().expect("fixed now") +} + +/// A `minutes`-long window that resets `reset_in` minutes after `pace_now`. +fn paced_window(used: f64, minutes: Option, reset_in: i64) -> RateWindowSnapshot { + let mut window = fake_snapshot("claude", "Claude", used).primary; + window.window_minutes = minutes; + window.resets_at = Some((pace_now() + chrono::Duration::minutes(reset_in)).to_rfc3339()); + window +} + +fn paced_snapshot(id: &str, primary: RateWindowSnapshot) -> ProviderUsageSnapshot { + let mut snapshot = fake_snapshot(id, id, primary.used_percent); + snapshot.primary = primary; + snapshot +} + +/// Work days stay off: work-day progress follows the machine time zone. +fn tint_settings() -> Settings { + Settings { menu_bar_color_pace: true, - menu_bar_icon_style: codexbar::settings::MenuBarIconStyle::Percent, + weekly_progress_work_days: None, ..Settings::default() - }; - let render = |settings: &Settings, snapshots: &[ProviderUsageSnapshot]| { - TrayPresentationPlan::resolve(settings, snapshots, None).render_icon() - }; + } +} - let unpaced = with_pace(None); - let behind_snapshots = with_pace(Some(pace("behind", -8.0))); - let ahead_snapshots = with_pace(Some(pace("ahead", 8.0))); - let on_track_snapshots = with_pace(Some(pace("on_track", 1.0))); - - let normal = render(&default_settings, &unpaced); - let disabled = render(&default_settings, &behind_snapshots); - let behind = render(&colored_settings, &behind_snapshots); - let ahead = render(&colored_settings, &ahead_snapshots); - let on_track = render(&colored_settings, &on_track_snapshots); - - assert_eq!(normal, disabled); - assert_eq!(normal, on_track); - assert_ne!(normal, behind); - assert_ne!(behind, ahead); - - let percent_normal = render( - &Settings { - menu_bar_icon_style: codexbar::settings::MenuBarIconStyle::Percent, - ..Settings::default() - }, - &unpaced, - ); - let percent_behind = render(&colored_percent_settings, &behind_snapshots); - assert_ne!(percent_normal, percent_behind); -} - -/// Upstream `UsageStore.weeklyPace`: no menu-bar pace for a window without -/// its own length (Copilot's reset-date-only windows). The rendered RGBA with -/// the tint enabled must equal the untinted icon. -#[test] -fn tray_pace_tint_skips_pace_from_a_window_without_a_length() { - let pace = |window_has_length: bool| crate::commands::PaceSnapshot { - stage: "far_ahead".to_string(), - delta_percent: 20.0, - will_last_to_reset: false, - eta_seconds: None, - expected_used_percent: 30.0, - actual_used_percent: 50.0, - monthly_limit_block: None, - window_has_length, - }; - let with_pace = |pace: Option| { - let mut snapshot = fake_snapshot("copilot", "Copilot", 50.0); - snapshot.pace = pace; - vec![snapshot] +fn tint(settings: &Settings, snapshots: &[ProviderUsageSnapshot]) -> Option { + TrayPresentationPlan::resolve_at(settings, snapshots, None, pace_now()).icon_pace +} + +fn render_at(settings: &Settings, snapshots: &[ProviderUsageSnapshot]) -> (Vec, u32, u32) { + TrayPresentationPlan::resolve_at(settings, snapshots, None, pace_now()).render_icon() +} + +/// Half way through a five-hour window the expected use is 50%. +#[test] +fn tray_pace_tint_is_opt_in_and_follows_the_drawn_window_stage() { + let behind = vec![paced_snapshot("claude", paced_window(30.0, Some(300), 150))]; + let ahead = vec![paced_snapshot("claude", paced_window(70.0, Some(300), 150))]; + let on_track = vec![paced_snapshot("claude", paced_window(51.0, Some(300), 150))]; + let colored = tint_settings(); + let plain = Settings { + menu_bar_color_pace: false, + ..tint_settings() }; + + assert_eq!(tint(&colored, &behind), Some(TrayPaceColor::Behind)); + assert_eq!(tint(&colored, &ahead), Some(TrayPaceColor::Ahead)); + assert_eq!(tint(&colored, &on_track), None); + assert_eq!(tint(&plain, &ahead), None); + for style in [ - codexbar::settings::MenuBarIconStyle::default(), + codexbar::settings::MenuBarIconStyle::Bars, codexbar::settings::MenuBarIconStyle::Percent, ] { let colored = Settings { - menu_bar_color_pace: true, menu_bar_icon_style: style, - ..Settings::default() + ..tint_settings() }; - let render = |snapshots: &[ProviderUsageSnapshot]| { - TrayPresentationPlan::resolve(&colored, snapshots, None).render_icon() + let plain = Settings { + menu_bar_color_pace: false, + ..colored.clone() }; - let unpaced = render(&with_pace(None)); - let lengthless = render(&with_pace(Some(pace(false)))); - let with_length = render(&with_pace(Some(pace(true)))); - - assert_eq!(unpaced, lengthless); - assert_ne!(unpaced, with_length); + let plain_behind = render_at(&plain, &behind); + let tinted_behind = render_at(&colored, &behind); + // An on-track 51% icon differs from a 30% one only by usage, so + // compare tinted and untinted icons for the same usage. + assert_eq!(render_at(&plain, &on_track), render_at(&colored, &on_track)); + assert_ne!(plain_behind, tinted_behind); + assert_ne!( + render_at(&colored, &ahead), + render_at(&plain, &ahead), + "{style:?} ahead" + ); } } +/// Upstream `menuBarPaceWindow` paces the selected metric window +/// (`percentWindow`), not the session lane. +#[test] +fn tray_pace_tint_follows_the_selected_metric_lane() { + let mut snapshot = paced_snapshot("claude", paced_window(30.0, Some(300), 150)); + snapshot.secondary = Some(paced_window(70.0, Some(10_080), 5_040)); + let snapshots = vec![snapshot]; + + let mut weekly = tint_settings(); + weekly.set_provider_metric(ProviderId::Claude, MetricPreference::Weekly); + assert_eq!(tint(&weekly, &snapshots), Some(TrayPaceColor::Ahead)); + + let mut session = tint_settings(); + session.set_provider_metric(ProviderId::Claude, MetricPreference::Session); + assert_eq!(tint(&session, &snapshots), Some(TrayPaceColor::Behind)); + + // Claude's Automatic metric draws the fuller lane, the weekly one here. + assert_eq!( + tint(&tint_settings(), &snapshots), + Some(TrayPaceColor::Ahead) + ); +} + +/// Upstream `menuBarPaceWindow` paces Codex on its weekly lane whatever +/// metric the menu bar shows; no weekly lane means no pace. +#[test] +fn codex_tray_pace_tint_uses_its_weekly_lane() { + let mut snapshot = paced_snapshot("codex", paced_window(30.0, Some(300), 150)); + snapshot.secondary = Some(paced_window(70.0, Some(10_080), 5_040)); + let mut session = tint_settings(); + session.set_provider_metric(ProviderId::Codex, MetricPreference::Session); + assert_eq!(tint(&session, &[snapshot]), Some(TrayPaceColor::Ahead)); + + let session_only = paced_snapshot("codex", paced_window(70.0, Some(300), 150)); + assert_eq!(tint(&session, &[session_only]), None); +} + +/// Upstream `UsageStore.weeklyPace` (UsageStore+HistoricalPace.swift:8-64): +/// no pace without a length, without pace authority, with no quota left, or +/// when it expects less than 3% use. +#[test] +fn tray_pace_tint_follows_the_mac_weekly_pace_guards() { + let colored = tint_settings(); + + let lengthless = vec![paced_snapshot("copilot", paced_window(70.0, None, 150))]; + assert_eq!(tint(&colored, &lengthless), None); + + let mut estimated = paced_snapshot("claude", paced_window(70.0, Some(300), 150)); + estimated.pace_authoritative = false; + assert_eq!(tint(&colored, &[estimated]), None); + + let exhausted = vec![paced_snapshot( + "claude", + paced_window(100.0, Some(300), 150), + )]; + assert_eq!(tint(&colored, &exhausted), None); + + // 5 of 300 minutes gone: 1.7% expected. 10 gone: 3.3% expected. + let too_early = vec![paced_snapshot("claude", paced_window(40.0, Some(300), 295))]; + assert_eq!(tint(&colored, &too_early), None); + let early = vec![paced_snapshot("claude", paced_window(40.0, Some(300), 290))]; + assert_eq!(tint(&colored, &early), Some(TrayPaceColor::Ahead)); +} + +/// A provider-declared icon lane (Doubao's Agent Plan) is the drawn window, +/// so it drives the tint. +#[test] +fn tray_pace_tint_uses_the_icon_fallback_window() { + let mut snapshot = paced_snapshot("doubao", paced_window(0.0, None, 150)); + snapshot.primary.is_informational = true; + snapshot + .extra_rate_windows + .push(crate::commands::NamedRateWindowSnapshot { + id: "doubao-agent-plan".to_string(), + title: "Agent Plan".to_string(), + window: paced_window(70.0, Some(300), 150), + fallback_lane: false, + icon_fallback: Some(codexbar::core::IconLane::Primary), + }); + assert_eq!( + tint(&tint_settings(), &[snapshot]), + Some(TrayPaceColor::Ahead) + ); +} + +/// The Mac menu bar paces a regenerating window that has a length; only the +/// card swaps its pace for the regen line. +#[test] +fn tray_pace_tint_scores_a_regenerating_window_with_a_length() { + let mut window = paced_window(70.0, Some(300), 150); + window.reset_regenerates = true; + let snapshots = vec![paced_snapshot("synthetic", window)]; + assert_eq!( + tint(&tint_settings(), &snapshots), + Some(TrayPaceColor::Ahead) + ); +} + #[test] fn stacked_tray_icon_keeps_usage_colours_when_pace_tint_is_enabled() { - let mut codex = fake_snapshot("codex", "Codex", 50.0); - codex.pace = Some(crate::commands::PaceSnapshot { - stage: "far_ahead".to_string(), - delta_percent: 20.0, - will_last_to_reset: false, - eta_seconds: None, - expected_used_percent: 30.0, - actual_used_percent: 50.0, - monthly_limit_block: None, - window_has_length: true, - }); - let claude = fake_snapshot("claude", "Claude", 20.0); - let snapshots = vec![codex.clone(), claude.clone()]; - let unpaced = vec![ - ProviderUsageSnapshot { - pace: None, - ..codex - }, - claude, - ]; + let mut codex = paced_snapshot("codex", paced_window(50.0, Some(300), 150)); + codex.secondary = Some(paced_window(90.0, Some(10_080), 5_040)); + let claude = paced_snapshot("claude", paced_window(80.0, Some(300), 150)); + let snapshots = vec![codex, claude]; + // Both providers are ahead of pace, so the single icon would tint. + assert_eq!( + tint(&tint_settings(), &snapshots), + Some(TrayPaceColor::Ahead) + ); let stacked = |color_pace: bool| Settings { tray_icon_mode: TrayIconMode::Stacked, menu_bar_color_pace: color_pace, - ..Settings::default() + ..tint_settings() }; let stacked_settings = stacked(true); - let plan = TrayPresentationPlan::resolve(&stacked_settings, &snapshots, None); + let plan = TrayPresentationPlan::resolve_at(&stacked_settings, &snapshots, None, pace_now()); assert!(matches!(plan.icon, TrayIconPlan::Stacked { .. })); assert_eq!(plan.icon_pace, None); - let unpaced_settings = stacked(false); - assert_eq!( - plan.render_icon(), - TrayPresentationPlan::resolve(&unpaced_settings, &unpaced, None).render_icon() - ); + assert_eq!(plan.render_icon(), render_at(&stacked(false), &snapshots)); } #[test] diff --git a/apps/desktop-tauri/src-tauri/src/usage_metric.rs b/apps/desktop-tauri/src-tauri/src/usage_metric.rs index 51bceaa5d5..6cde3ede2a 100644 --- a/apps/desktop-tauri/src-tauri/src/usage_metric.rs +++ b/apps/desktop-tauri/src-tauri/src/usage_metric.rs @@ -489,6 +489,7 @@ mod tests { error: None, error_state: codexbar::core::ProviderStateKind::Ready, pace: None, + pace_authoritative: true, account_organization: None, tray_status_label: None, fetch_duration_ms: None, diff --git a/apps/desktop-tauri/src/lib/metricRowModel.test.ts b/apps/desktop-tauri/src/lib/metricRowModel.test.ts index c90d46292e..86b2b24131 100644 --- a/apps/desktop-tauri/src/lib/metricRowModel.test.ts +++ b/apps/desktop-tauri/src/lib/metricRowModel.test.ts @@ -291,6 +291,31 @@ describe("metricRowPresentation pace", () => { expect(row.metaText).toBe("23% in reserve · Lasts until reset"); }); + // Upstream MenuCardView+ModelHelpers.swift:179-186 and + // MenuCardView.swift:1407-1415: a regenerating window keeps its length but + // shows the regen line instead of pace. Workday ticks stay (:1433-1437). + it("hides pace on a regenerating window that keeps its length", () => { + const session = rateWindow({ windowMinutes: 300, resetRegenerates: true, pace: pace() }); + const sessionRow = metricRowPresentation(input({ snap: session }), en); + expect(sessionRow.metaText).toBeNull(); + expect(sessionRow.bar.pacePercent).toBeNull(); + expect(sessionRow.bar.paceDeficit).toBe(false); + + const week = weekly({ resetRegenerates: true }); + const weekRow = metricRowPresentation( + input({ lane: "secondary", snap: week, weeklyProgressWorkDays: 5, usageThresholds: null }), + en, + ); + expect(weekRow.metaText).toBeNull(); + expect(weekRow.bar.pacePercent).toBeNull(); + expect(weekRow.bar.markers).toEqual([ + { percent: 20, kind: "workday" }, + { percent: 40, kind: "workday" }, + { percent: 60, kind: "workday" }, + { percent: 80, kind: "workday" }, + ]); + }); + describe("reset-window pace (upstream ProviderPaceCapability)", () => { // Copilot premium requests: no window length, 60% used half way through // a 28-day cycle, scored by the bridge against the resolved month. diff --git a/apps/desktop-tauri/src/lib/metricRowModel.ts b/apps/desktop-tauri/src/lib/metricRowModel.ts index 955dacf646..10e333fd7b 100644 --- a/apps/desktop-tauri/src/lib/metricRowModel.ts +++ b/apps/desktop-tauri/src/lib/metricRowModel.ts @@ -234,6 +234,10 @@ function visiblePace(input: MetricRowInput): { pace: WindowPaceSnapshot; kind: P const { snap } = input; const pace = snap.pace; if (!input.paceEnabled || !pace || !(snap.usedPercent < 100)) return null; + // A regenerating window shows its regen line instead of pace, as upstream + // MenuCardView+ModelHelpers.swift:179-186 and MenuCardView.swift:1407-1415 + // do. The window keeps its length, so workday ticks and the tray still use it. + if (snap.resetRegenerates) return null; if (!Number.isFinite(pace.expectedUsedPercent) || !Number.isFinite(pace.actualUsedPercent)) return null; const kind = paceKind(input.lane, snap.windowMinutes, pace.resetWindow === true); if (!kind) return null; diff --git a/apps/desktop-tauri/src/lib/paceBudget.test.ts b/apps/desktop-tauri/src/lib/paceBudget.test.ts index 0d9f27ceaa..518453fce5 100644 --- a/apps/desktop-tauri/src/lib/paceBudget.test.ts +++ b/apps/desktop-tauri/src/lib/paceBudget.test.ts @@ -70,6 +70,18 @@ describe("getPaceBudget", () => { ).toBeNull(); expect(getPaceBudget(snapshot({ isExhausted: true }), now)).toBeNull(); }); + + it("returns no budget for a regenerating window", () => { + const now = new Date("2026-06-12T04:00:00.000Z"); + + expect(getPaceBudget(snapshot({ resetRegenerates: false }), now)).toEqual({ + now: 20, + nextHour: 30, + nextFiveHours: 70, + today: 80, + }); + expect(getPaceBudget(snapshot({ resetRegenerates: true }), now)).toBeNull(); + }); }); describe("getPaceChartSnapshot", () => { @@ -124,4 +136,15 @@ describe("getPaceChartSnapshot", () => { ), ).toBeNull(); }); + + it("returns no chart for a regenerating window", () => { + const now = new Date("2026-06-12T04:00:00.000Z"); + + expect( + getPaceChartSnapshot(snapshot({ usedPercent: 30, resetRegenerates: false }), now), + ).toEqual({ elapsedPercent: 40, usedPercent: 30, projectedPercent: 75 }); + expect( + getPaceChartSnapshot(snapshot({ usedPercent: 30, resetRegenerates: true }), now), + ).toBeNull(); + }); }); diff --git a/apps/desktop-tauri/src/lib/paceBudget.ts b/apps/desktop-tauri/src/lib/paceBudget.ts index 4bb3ffad1f..23115567a0 100644 --- a/apps/desktop-tauri/src/lib/paceBudget.ts +++ b/apps/desktop-tauri/src/lib/paceBudget.ts @@ -19,7 +19,10 @@ function paceWindow( snap: RateWindowSnapshot, nowMs: number, ): PaceWindow | null { + // A regenerating window refills continuously, so it has no linear + // pace (upstream hides pace for these windows on the card). if ( + snap.resetRegenerates || snap.windowMinutes == null || !Number.isFinite(snap.windowMinutes) || snap.windowMinutes <= 0 || diff --git a/rust/src/core/provider.rs b/rust/src/core/provider.rs index 3e1ffe5bc9..1f7b266210 100755 --- a/rust/src/core/provider.rs +++ b/rust/src/core/provider.rs @@ -1083,6 +1083,14 @@ pub trait Provider: Send + Sync { None } + /// Lane the menu bar pace is scored on, whatever metric the menu bar + /// shows (upstream `menuBarPaceWindow`, + /// StatusItemController+Animation.swift). `None` scores the window the + /// tray icon draws. + fn menu_bar_pace_lane(&self) -> Option { + None + } + /// Label for the primary lane in the menu bar metric picker when the /// lane is not a session window (upstream `menuBarLayoutPrimaryLabel`). /// `None` keeps the generic session label. diff --git a/rust/src/providers/abacus/mod.rs b/rust/src/providers/abacus/mod.rs index 43fcb572c7..9640f070c8 100644 --- a/rust/src/providers/abacus/mod.rs +++ b/rust/src/providers/abacus/mod.rs @@ -486,6 +486,11 @@ impl Provider for AbacusProvider { ProviderId::Abacus } + /// Upstream `menuBarPaceWindow` paces Abacus on its primary credits lane. + fn menu_bar_pace_lane(&self) -> Option { + Some(crate::core::IconLane::Primary) + } + fn metadata(&self) -> &ProviderMetadata { &self.metadata } diff --git a/rust/src/providers/codebuff/mod.rs b/rust/src/providers/codebuff/mod.rs index e26fc572bd..1581d78b88 100644 --- a/rust/src/providers/codebuff/mod.rs +++ b/rust/src/providers/codebuff/mod.rs @@ -299,6 +299,9 @@ fn weekly_window(value: &Value) -> Option { return None; } let mut window = RateWindow::new((used / limit * 100.0).clamp(0.0, 100.0)); + // Upstream `CodebuffUsageSnapshot.makeWeeklyWindow` gives the weekly lane + // a 7-day length, so pace can score it. + window.window_minutes = Some(7 * 24 * 60); if let Some(reset) = string_at(value, &["weeklyResetsAt", "resetsAt", "resetAt"]).and_then(parse_datetime) { @@ -370,4 +373,25 @@ mod tests { Some("Team · 60 remaining") ); } + + #[test] + fn weekly_lane_is_seven_days_and_credits_lane_has_no_length() { + // Upstream `CodebuffUsageSnapshot.makeWeeklyWindow` sets + // `windowMinutes: 7 * 24 * 60`; the credits window keeps nil. + let usage = json!({ "usage": 25, "quota": 100 }); + let subscription = json!({ + "rateLimit": { + "weeklyUsed": 10, + "weeklyLimit": 50, + "weeklyResetsAt": "2026-10-19T00:00:00Z" + } + }); + + let snapshot = CodebuffProvider::snapshot_from_values(&usage, Some(&subscription)); + + assert_eq!(snapshot.primary.window_minutes, None); + let weekly = snapshot.secondary.expect("weekly lane"); + assert_eq!(weekly.window_minutes, Some(10_080)); + assert_eq!(weekly.used_percent, 20.0); + } } diff --git a/rust/src/providers/codex/mod.rs b/rust/src/providers/codex/mod.rs index 9c07598b5f..deb12da190 100755 --- a/rust/src/providers/codex/mod.rs +++ b/rust/src/providers/codex/mod.rs @@ -174,6 +174,12 @@ impl Provider for CodexProvider { false } + /// Upstream `menuBarPaceWindow` paces Codex on its weekly lane, which + /// `normalize_named_windows` puts in the secondary slot. + fn menu_bar_pace_lane(&self) -> Option { + Some(crate::core::IconLane::Secondary) + } + fn id(&self) -> ProviderId { ProviderId::Codex } diff --git a/rust/src/providers/grok/credits_proxy.rs b/rust/src/providers/grok/credits_proxy.rs index 5466e6037e..cca845bb0b 100644 --- a/rust/src/providers/grok/credits_proxy.rs +++ b/rust/src/providers/grok/credits_proxy.rs @@ -80,8 +80,10 @@ fn parse_timestamp(raw: Option<&str>) -> Option> { .map(|parsed| parsed.with_timezone(&Utc)) } -/// Full billing-cycle length; `None` unless the start precedes both the end -/// and `now`, so a reset timestamp alone never invents a cadence. +/// Full billing-cycle length; `None` unless the start precedes the end and +/// is not in the future, so a reset timestamp alone never invents a cadence. +/// Upstream `GrokCreditsProxyFetcher.windowMinutes` keeps the length of a +/// period that has already ended. fn window_minutes( start: Option<&str>, end: Option>, @@ -89,7 +91,7 @@ fn window_minutes( ) -> Option { let start = parse_timestamp(start)?; let end = end?; - if start > now || now >= end { + if start > now || end <= start { return None; } u32::try_from((end - start).num_minutes()) diff --git a/rust/src/providers/grok/credits_proxy_tests.rs b/rust/src/providers/grok/credits_proxy_tests.rs index 1623ec0e77..5254d880c8 100644 --- a/rust/src/providers/grok/credits_proxy_tests.rs +++ b/rust/src/providers/grok/credits_proxy_tests.rs @@ -90,6 +90,19 @@ fn measures_only_matching_valid_period_bounds() { } } +/// Upstream `GrokCreditsProxyFetcher.windowMinutes` requires only +/// `end > start` and `start <= now`: a period that ended before the fetch +/// keeps its 7-day length, and the reset stays at that end. +#[test] +fn ended_period_keeps_its_length() { + let parsed = parse( + r#"{"config":{"creditUsagePercent":40,"currentPeriod":{"start":"2026-08-01T00:00:00Z","end":"2026-08-08T00:00:00Z"}}}"#, + ); + assert_eq!(parsed.billing.used_percent, Some(40.0)); + assert_eq!(parsed.billing.resets_at, Some(at("2026-08-08T00:00:00Z"))); + assert_eq!(parsed.billing.window_minutes, Some(10_080)); +} + #[test] fn derives_percent_from_on_demand_cap_and_usage() { let parsed = parse(r#"{"config":{"onDemandCap":{"val":1000.0},"onDemandUsed":{"val":250.5}}}"#); diff --git a/rust/src/providers/minimax/coding_plan.rs b/rust/src/providers/minimax/coding_plan.rs index fc3f2906e5..ee85463238 100644 --- a/rust/src/providers/minimax/coding_plan.rs +++ b/rust/src/providers/minimax/coding_plan.rs @@ -111,6 +111,27 @@ fn window_minutes(start: Option>, end: Option>) -> O } } +/// Upstream `MiniMaxUsageSnapshot.windowMinutes(for:)`: the length a service +/// row's window type names ("Today", "Weekly", "5 hours", "30 minutes", +/// "2 days"). Other labels ("Custom", "Unknown") have no length. +pub(super) fn window_minutes_for_window_type(window_type: &str) -> Option { + let window_type = window_type.trim().to_lowercase(); + match window_type.as_str() { + "today" => return Some(24 * 60), + "weekly" => return Some(7 * 24 * 60), + _ => {} + } + let mut parts = window_type.split_whitespace(); + let value = parts.next()?.parse::().ok()?; + let per_unit = match parts.next()? { + "hour" | "hours" | "h" | "hr" | "hrs" => 60, + "minute" | "minutes" | "min" | "mins" | "m" => 1, + "day" | "days" | "d" => 24 * 60, + _ => return None, + }; + value.checked_mul(per_unit) +} + /// `resetsAt(end:remains:now:)`. fn resets_at( end: Option>, @@ -362,7 +383,14 @@ fn make_remains_row( reset_description(&window_type, &time_range, now, resets) }; - let win_minutes = window_minutes(start_dt, end_dt); + // Upstream `MiniMaxUsageSnapshot.windowMinutes(for:)` gives every weekly + // row 7 days from its window type, even when the payload has no weekly + // start and end. Other rows keep their payload span. + let win_minutes = if window_type.eq_ignore_ascii_case("weekly") { + window_minutes_for_window_type(&window_type) + } else { + window_minutes(start_dt, end_dt) + }; let percent = if is_unlimited { 0.0 @@ -694,299 +722,5 @@ pub(crate) fn is_token_plan_without_coding_plan(err: &ProviderError) -> bool { } #[cfg(test)] -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn oversized_remains_yields_no_reset_line_and_keeps_usage() { - // `remains` above ~8.3e15 ms cannot be represented as a timestamp: - // `Duration::try_seconds` must return None, so `resets_at` is None and - // the usage percentage survives (upstream #3758/#3764). - assert_eq!(resets_at(None, Some(i64::MAX), now()), None); - } - - #[test] - fn negative_remains_yields_no_reset_line() { - assert_eq!(resets_at(None, Some(-5), now()), None); - assert_eq!(resets_at(None, Some(0), now()), None); - } - - #[test] - fn positive_remains_keeps_the_reset_line() { - // 90_000 > 1_000_000? No: below the heuristic, treated as seconds. - let reset = resets_at(None, Some(60), now()).expect("small remains"); - assert_eq!(reset, now() + Duration::seconds(60)); - // Above the heuristic: treated as milliseconds. - let reset_ms = resets_at(None, Some(2_000_000), now()).expect("ms remains"); - assert_eq!(reset_ms, now() + Duration::seconds(2_000)); - } - - #[test] - fn end_time_in_the_future_wins_over_remains() { - let end = now() + Duration::seconds(120); - assert_eq!(resets_at(Some(end), Some(60), now()), Some(end)); - } - - fn now() -> DateTime { - Utc.with_ymd_and_hms(2026, 8, 3, 12, 0, 0).unwrap() - } - - #[test] - fn parses_remains_json_snake_case() { - let json = serde_json::json!({ - "data": { - "current_subscribe_title": "MiniMax Pro", - "model_remains": [{ - "model_name": "General", - "current_interval_total_count": 100, - "current_interval_usage_count": 73, - "start_time": 1785792000, - "end_time": 1785878400, - "current_interval_status": 0, - "current_weekly_total_count": 1000, - "current_weekly_usage_count": 850, - "current_weekly_remaining_percent": 85, - "weekly_start_time": 1785792000, - "weekly_end_time": 1786396800, - "current_weekly_status": 0 - }] - } - }); - let snapshot = parse_coding_plan_value(&json, now()).unwrap(); - let MiniMaxCodingPlanSnapshot::Remains { plan_name, rows } = snapshot else { - panic!("expected Remains"); - }; - assert_eq!(plan_name.as_deref(), Some("MiniMax Pro")); - // remaining percent 73 → used 27 - assert!((rows[0].percent - 27.0).abs() < 0.01); - // window_minutes = (end-start)/60 = 86400/60 = 1440 - assert_eq!(rows[0].window_minutes, Some(1440)); - assert!(rows[0].resets_at.is_some()); - // General is text-gen → weekly row generated - assert_eq!(rows.len(), 2); - assert!(rows[1].is_weekly); - } - - #[test] - fn parses_counts_only_entry() { - let json = serde_json::json!({ - "data": { - "model_remains": [{ - "model_name": "General", - "current_interval_total_count": 100, - "current_interval_usage_count": 40, - "start_time": 1722691200, - "end_time": 1722777600, - "current_interval_status": 0 - }] - } - }); - let snapshot = parse_coding_plan_value(&json, now()).unwrap(); - let MiniMaxCodingPlanSnapshot::Remains { rows, .. } = snapshot else { - panic!("expected Remains"); - }; - // total=100, remaining=40 → used 60/100 = 60% - assert!((rows[0].percent - 60.0).abs() < 0.01); - } - - #[test] - fn parses_remaining_percent_with_boost_field_ignored() { - let json = serde_json::json!({ - "data": { - "model_remains": [{ - "model_name": "General", - "current_interval_total_count": 0, - "current_interval_usage_count": 0, - "current_interval_remaining_percent": 50, - "interval_boost_permill": 1500, - "start_time": 1722691200, - "end_time": 1722777600, - "current_interval_status": 0 - }] - } - }); - let snapshot = parse_coding_plan_value(&json, now()).unwrap(); - let MiniMaxCodingPlanSnapshot::Remains { rows, .. } = snapshot else { - panic!("expected Remains"); - }; - // remainingPercent 50 → used 50%; boost field is ignored dead plumbing - assert!((rows[0].percent - 50.0).abs() < 0.01); - } - - #[test] - fn placeholder_lane_skipped_for_video() { - let json = serde_json::json!({ - "data": { - "model_remains": [{ - "model_name": "video", - "current_interval_total_count": 0, - "current_interval_usage_count": 0, - "current_interval_remaining_percent": 100, - "current_interval_status": 3 - }] - } - }); - let snapshot = parse_coding_plan_value(&json, now()).unwrap(); - let MiniMaxCodingPlanSnapshot::Remains { rows, .. } = snapshot else { - panic!("expected Remains"); - }; - // video is not text-gen → no weekly; interval is placeholder → skipped - assert!(rows.is_empty()); - } - - #[test] - fn placeholder_general_weekly_kept_as_unlimited() { - let json = serde_json::json!({ - "data": { - "model_remains": [{ - "model_name": "General", - "current_interval_total_count": 0, - "current_interval_usage_count": 0, - "current_interval_remaining_percent": 100, - "current_interval_status": 3, - "current_weekly_total_count": 0, - "current_weekly_usage_count": 0, - "current_weekly_remaining_percent": 100, - "current_weekly_status": 3 - }] - } - }); - let snapshot = parse_coding_plan_value(&json, now()).unwrap(); - let MiniMaxCodingPlanSnapshot::Remains { rows, .. } = snapshot else { - panic!("expected Remains"); - }; - // interval is placeholder → skipped, but weekly is unlimited → kept - let weekly = rows.iter().find(|r| r.is_weekly).unwrap(); - assert!(weekly.is_unlimited); - assert_eq!(weekly.reset_description.as_deref(), Some("Unlimited")); - assert!((weekly.percent - 0.0).abs() < 0.01); - } - - #[test] - fn base_resp_1004_is_auth_required() { - let json = serde_json::json!({ - "data": { - "base_resp": { "status_code": 1004 }, - "model_remains": [] - } - }); - let err = parse_coding_plan_value(&json, now()).unwrap_err(); - assert!(matches!(err, ProviderError::AuthRequired)); - } - - #[test] - fn base_resp_status_msg_login_is_auth_required() { - let json = serde_json::json!({ - "data": { - "base_resp": { "status_code": 2000, "status_msg": "please log in" }, - "model_remains": [] - } - }); - let err = parse_coding_plan_value(&json, now()).unwrap_err(); - assert!(matches!(err, ProviderError::AuthRequired)); - } - - #[test] - fn base_resp_other_status_is_other_error() { - let json = serde_json::json!({ - "data": { - "base_resp": { "status_code": 2000, "status_msg": "quota sync failed" }, - "model_remains": [] - } - }); - let err = parse_coding_plan_value(&json, now()).unwrap_err(); - assert!(matches!(err, ProviderError::Other(_))); - } - - #[test] - fn base_resp_without_status_code_parses_fine() { - let json = serde_json::json!({ - "data": { - "base_resp": {}, - "model_remains": [{ - "model_name": "General", - "current_interval_total_count": 100, - "current_interval_usage_count": 30, - "current_interval_remaining_percent": 70, - "start_time": 1785792000, - "end_time": 1785878400, - "current_interval_status": 0 - }] - } - }); - let snapshot = parse_coding_plan_value(&json, now()).unwrap(); - let MiniMaxCodingPlanSnapshot::Remains { rows, .. } = snapshot else { - panic!("expected Remains"); - }; - // remainingPercent 70 → used 30%; no error from absent status_code - assert!((rows[0].percent - 30.0).abs() < 0.01); - } - - #[test] - fn empty_model_remains_is_parse_error() { - let json = serde_json::json!({ - "data": { - "model_remains": [] - } - }); - let err = parse_coding_plan_value(&json, now()).unwrap_err(); - assert!(matches!(err, ProviderError::Parse(_))); - } - - #[test] - fn parses_multi_service_json() { - let json = serde_json::json!({ - "data": { - "services": [{ - "service_type": "Text Generation Pro", - "window_type": "Today", - "time_range": "2026/08/03 00:00 - 2026/08/04 00:00", - "usage": 250, - "limit": 1000 - }] - } - }); - let snapshot = parse_coding_plan_value(&json, now()).unwrap(); - match snapshot { - MiniMaxCodingPlanSnapshot::Services(rows) => { - assert_eq!(rows.len(), 1); - assert!((rows[0].percent - 25.0).abs() < 0.01); - assert!(rows[0].service_type.contains("Pro")); - } - _ => panic!("expected Services"), - } - } - - #[test] - fn token_plan_predicate_matches_reporter_2062_message() { - // Live reporter evidence (issue #254): the legacy remains endpoint 200s - // with base_resp 2062 for Token Plan accounts. - let json = serde_json::json!({ - "base_resp": { - "status_code": 2062, - "status_msg": "no active token plan subscription" - } - }); - let err = parse_coding_plan_value(&json, now()).unwrap_err(); - assert!(matches!(err, ProviderError::Other(_))); - assert!(is_token_plan_without_coding_plan(&err)); - } - - #[test] - fn token_plan_predicate_rejects_unrelated_errors() { - assert!(!is_token_plan_without_coding_plan( - &ProviderError::AuthRequired - )); - assert!(!is_token_plan_without_coding_plan(&ProviderError::Other( - "MiniMax coding plan status 2000".to_string() - ))); - assert!(!is_token_plan_without_coding_plan(&ProviderError::Parse( - "Missing coding plan data.".to_string() - ))); - // Case-insensitive match on the message itself. - assert!(is_token_plan_without_coding_plan(&ProviderError::Other( - "No Active Token Plan Subscription".to_string() - ))); - } -} +#[path = "coding_plan_tests.rs"] +mod tests; diff --git a/rust/src/providers/minimax/coding_plan_html.rs b/rust/src/providers/minimax/coding_plan_html.rs index a2fafdb909..e72f918b22 100644 --- a/rust/src/providers/minimax/coding_plan_html.rs +++ b/rust/src/providers/minimax/coding_plan_html.rs @@ -9,7 +9,9 @@ use serde_json::Value; use crate::core::{ProviderError, RateWindow, UsageSnapshot}; -use super::coding_plan::{MiniMaxCodingPlanSnapshot, parse_coding_plan_value}; +use super::coding_plan::{ + MiniMaxCodingPlanSnapshot, parse_coding_plan_value, window_minutes_for_window_type, +}; #[cfg(test)] use super::coding_plan::{RemainsRow, ServiceRow}; @@ -364,7 +366,7 @@ pub(super) fn to_usage_snapshot( let first = &rows[0]; let primary = RateWindow::with_details( first.percent, - None, + window_minutes_for_window_type(&first.window_type), first.resets_at, first.reset_description.clone(), ); @@ -375,7 +377,7 @@ pub(super) fn to_usage_snapshot( let second = &rows[1]; usage = usage.with_secondary(RateWindow::with_details( second.percent, - None, + window_minutes_for_window_type(&second.window_type), second.resets_at, second.reset_description.clone(), )); @@ -388,7 +390,7 @@ pub(super) fn to_usage_snapshot( format!("{} · {}", row.service_type, row.window_type), RateWindow::with_details( row.percent, - None, + window_minutes_for_window_type(&row.window_type), row.resets_at, row.reset_description.clone(), ), @@ -651,4 +653,71 @@ mod tests { assert_eq!(minutes_from_duration(f64::INFINITY, "days"), None); assert_eq!(minutes_from_duration(5.0, "hours"), Some(300)); } + + #[test] + fn services_rows_take_their_length_from_the_window_type() { + // Upstream `MiniMaxUsageSnapshot.rateWindow(for:)` / + // `windowMinutes(for:)`: Today 1440, Weekly 10080, "N hours" N*60. + let json = serde_json::json!({ + "data": { + "services": [ + { + "service_type": "Text Generation", + "window_type": "5 hours", + "time_range": "10:00-15:00(UTC+8)", + "usage": 30, + "limit": 100 + }, + { + "service_type": "Text Generation", + "window_type": "Weekly", + "time_range": "08/03 00:00 - 08/10 00:00(UTC+8)", + "usage": 400, + "limit": 1000 + }, + { + "service_type": "Image Generation", + "window_type": "Today", + "time_range": "00:00-24:00(UTC+8)", + "usage": 2, + "limit": 10 + }, + { + "service_type": "Video Generation", + "window_type": "Custom", + "time_range": "N/A", + "usage": 1, + "limit": 4 + } + ] + } + }); + let snapshot = parse_coding_plan_value(&json, now()).unwrap(); + let usage = to_usage_snapshot(&snapshot, now()).unwrap(); + + assert_eq!(usage.primary.window_minutes, Some(300)); + assert_eq!( + usage.secondary.as_ref().and_then(|w| w.window_minutes), + Some(10_080) + ); + let extra: Vec> = usage + .extra_rate_windows + .iter() + .map(|w| w.window.window_minutes) + .collect(); + assert_eq!(extra, vec![Some(1440), None]); + } + + #[test] + fn window_type_minutes_follow_the_mac_units() { + assert_eq!(window_minutes_for_window_type(" Today "), Some(1440)); + assert_eq!(window_minutes_for_window_type("WEEKLY"), Some(10_080)); + assert_eq!(window_minutes_for_window_type("5 hours"), Some(300)); + assert_eq!(window_minutes_for_window_type("1 hr"), Some(60)); + assert_eq!(window_minutes_for_window_type("30 minutes"), Some(30)); + assert_eq!(window_minutes_for_window_type("2 days"), Some(2880)); + assert_eq!(window_minutes_for_window_type("Custom"), None); + assert_eq!(window_minutes_for_window_type("Unknown"), None); + assert_eq!(window_minutes_for_window_type("5 fortnights"), None); + } } diff --git a/rust/src/providers/minimax/coding_plan_tests.rs b/rust/src/providers/minimax/coding_plan_tests.rs new file mode 100644 index 0000000000..39a6bc11f3 --- /dev/null +++ b/rust/src/providers/minimax/coding_plan_tests.rs @@ -0,0 +1,322 @@ +use super::*; + +#[test] +fn oversized_remains_yields_no_reset_line_and_keeps_usage() { + // `remains` above ~8.3e15 ms cannot be represented as a timestamp: + // `Duration::try_seconds` must return None, so `resets_at` is None and + // the usage percentage survives (upstream #3758/#3764). + assert_eq!(resets_at(None, Some(i64::MAX), now()), None); +} + +#[test] +fn negative_remains_yields_no_reset_line() { + assert_eq!(resets_at(None, Some(-5), now()), None); + assert_eq!(resets_at(None, Some(0), now()), None); +} + +#[test] +fn positive_remains_keeps_the_reset_line() { + // 90_000 > 1_000_000? No: below the heuristic, treated as seconds. + let reset = resets_at(None, Some(60), now()).expect("small remains"); + assert_eq!(reset, now() + Duration::seconds(60)); + // Above the heuristic: treated as milliseconds. + let reset_ms = resets_at(None, Some(2_000_000), now()).expect("ms remains"); + assert_eq!(reset_ms, now() + Duration::seconds(2_000)); +} + +#[test] +fn end_time_in_the_future_wins_over_remains() { + let end = now() + Duration::seconds(120); + assert_eq!(resets_at(Some(end), Some(60), now()), Some(end)); +} + +fn now() -> DateTime { + Utc.with_ymd_and_hms(2026, 8, 3, 12, 0, 0).unwrap() +} + +#[test] +fn parses_remains_json_snake_case() { + let json = serde_json::json!({ + "data": { + "current_subscribe_title": "MiniMax Pro", + "model_remains": [{ + "model_name": "General", + "current_interval_total_count": 100, + "current_interval_usage_count": 73, + "start_time": 1785792000, + "end_time": 1785878400, + "current_interval_status": 0, + "current_weekly_total_count": 1000, + "current_weekly_usage_count": 850, + "current_weekly_remaining_percent": 85, + "weekly_start_time": 1785792000, + "weekly_end_time": 1786396800, + "current_weekly_status": 0 + }] + } + }); + let snapshot = parse_coding_plan_value(&json, now()).unwrap(); + let MiniMaxCodingPlanSnapshot::Remains { plan_name, rows } = snapshot else { + panic!("expected Remains"); + }; + assert_eq!(plan_name.as_deref(), Some("MiniMax Pro")); + // remaining percent 73 → used 27 + assert!((rows[0].percent - 27.0).abs() < 0.01); + // window_minutes = (end-start)/60 = 86400/60 = 1440 + assert_eq!(rows[0].window_minutes, Some(1440)); + assert!(rows[0].resets_at.is_some()); + // General is text-gen → weekly row generated + assert_eq!(rows.len(), 2); + assert!(rows[1].is_weekly); +} + +#[test] +fn weekly_row_without_start_and_end_is_seven_days() { + let json = serde_json::json!({ + "data": { + "model_remains": [{ + "model_name": "General", + "current_interval_total_count": 100, + "current_interval_usage_count": 60, + "current_interval_status": 0, + "current_weekly_total_count": 1000, + "current_weekly_usage_count": 900, + "current_weekly_status": 0, + "weekly_remains_time": 172800000 + }] + } + }); + let snapshot = parse_coding_plan_value(&json, now()).unwrap(); + let MiniMaxCodingPlanSnapshot::Remains { rows, .. } = snapshot else { + panic!("expected Remains"); + }; + assert_eq!(rows.len(), 2); + // No start/end on the interval row: no length, as before. + assert_eq!(rows[0].window_minutes, None); + // The weekly row has no weekly start/end but is still 7 days. + assert!(rows[1].is_weekly); + assert_eq!(rows[1].window_minutes, Some(10_080)); + assert!((rows[1].percent - 10.0).abs() < 0.01); +} + +#[test] +fn parses_counts_only_entry() { + let json = serde_json::json!({ + "data": { + "model_remains": [{ + "model_name": "General", + "current_interval_total_count": 100, + "current_interval_usage_count": 40, + "start_time": 1722691200, + "end_time": 1722777600, + "current_interval_status": 0 + }] + } + }); + let snapshot = parse_coding_plan_value(&json, now()).unwrap(); + let MiniMaxCodingPlanSnapshot::Remains { rows, .. } = snapshot else { + panic!("expected Remains"); + }; + // total=100, remaining=40 → used 60/100 = 60% + assert!((rows[0].percent - 60.0).abs() < 0.01); +} + +#[test] +fn parses_remaining_percent_with_boost_field_ignored() { + let json = serde_json::json!({ + "data": { + "model_remains": [{ + "model_name": "General", + "current_interval_total_count": 0, + "current_interval_usage_count": 0, + "current_interval_remaining_percent": 50, + "interval_boost_permill": 1500, + "start_time": 1722691200, + "end_time": 1722777600, + "current_interval_status": 0 + }] + } + }); + let snapshot = parse_coding_plan_value(&json, now()).unwrap(); + let MiniMaxCodingPlanSnapshot::Remains { rows, .. } = snapshot else { + panic!("expected Remains"); + }; + // remainingPercent 50 → used 50%; boost field is ignored dead plumbing + assert!((rows[0].percent - 50.0).abs() < 0.01); +} + +#[test] +fn placeholder_lane_skipped_for_video() { + let json = serde_json::json!({ + "data": { + "model_remains": [{ + "model_name": "video", + "current_interval_total_count": 0, + "current_interval_usage_count": 0, + "current_interval_remaining_percent": 100, + "current_interval_status": 3 + }] + } + }); + let snapshot = parse_coding_plan_value(&json, now()).unwrap(); + let MiniMaxCodingPlanSnapshot::Remains { rows, .. } = snapshot else { + panic!("expected Remains"); + }; + // video is not text-gen → no weekly; interval is placeholder → skipped + assert!(rows.is_empty()); +} + +#[test] +fn placeholder_general_weekly_kept_as_unlimited() { + let json = serde_json::json!({ + "data": { + "model_remains": [{ + "model_name": "General", + "current_interval_total_count": 0, + "current_interval_usage_count": 0, + "current_interval_remaining_percent": 100, + "current_interval_status": 3, + "current_weekly_total_count": 0, + "current_weekly_usage_count": 0, + "current_weekly_remaining_percent": 100, + "current_weekly_status": 3 + }] + } + }); + let snapshot = parse_coding_plan_value(&json, now()).unwrap(); + let MiniMaxCodingPlanSnapshot::Remains { rows, .. } = snapshot else { + panic!("expected Remains"); + }; + // interval is placeholder → skipped, but weekly is unlimited → kept + let weekly = rows.iter().find(|r| r.is_weekly).unwrap(); + assert!(weekly.is_unlimited); + assert_eq!(weekly.reset_description.as_deref(), Some("Unlimited")); + assert!((weekly.percent - 0.0).abs() < 0.01); +} + +#[test] +fn base_resp_1004_is_auth_required() { + let json = serde_json::json!({ + "data": { + "base_resp": { "status_code": 1004 }, + "model_remains": [] + } + }); + let err = parse_coding_plan_value(&json, now()).unwrap_err(); + assert!(matches!(err, ProviderError::AuthRequired)); +} + +#[test] +fn base_resp_status_msg_login_is_auth_required() { + let json = serde_json::json!({ + "data": { + "base_resp": { "status_code": 2000, "status_msg": "please log in" }, + "model_remains": [] + } + }); + let err = parse_coding_plan_value(&json, now()).unwrap_err(); + assert!(matches!(err, ProviderError::AuthRequired)); +} + +#[test] +fn base_resp_other_status_is_other_error() { + let json = serde_json::json!({ + "data": { + "base_resp": { "status_code": 2000, "status_msg": "quota sync failed" }, + "model_remains": [] + } + }); + let err = parse_coding_plan_value(&json, now()).unwrap_err(); + assert!(matches!(err, ProviderError::Other(_))); +} + +#[test] +fn base_resp_without_status_code_parses_fine() { + let json = serde_json::json!({ + "data": { + "base_resp": {}, + "model_remains": [{ + "model_name": "General", + "current_interval_total_count": 100, + "current_interval_usage_count": 30, + "current_interval_remaining_percent": 70, + "start_time": 1785792000, + "end_time": 1785878400, + "current_interval_status": 0 + }] + } + }); + let snapshot = parse_coding_plan_value(&json, now()).unwrap(); + let MiniMaxCodingPlanSnapshot::Remains { rows, .. } = snapshot else { + panic!("expected Remains"); + }; + // remainingPercent 70 → used 30%; no error from absent status_code + assert!((rows[0].percent - 30.0).abs() < 0.01); +} + +#[test] +fn empty_model_remains_is_parse_error() { + let json = serde_json::json!({ + "data": { + "model_remains": [] + } + }); + let err = parse_coding_plan_value(&json, now()).unwrap_err(); + assert!(matches!(err, ProviderError::Parse(_))); +} + +#[test] +fn parses_multi_service_json() { + let json = serde_json::json!({ + "data": { + "services": [{ + "service_type": "Text Generation Pro", + "window_type": "Today", + "time_range": "2026/08/03 00:00 - 2026/08/04 00:00", + "usage": 250, + "limit": 1000 + }] + } + }); + let snapshot = parse_coding_plan_value(&json, now()).unwrap(); + match snapshot { + MiniMaxCodingPlanSnapshot::Services(rows) => { + assert_eq!(rows.len(), 1); + assert!((rows[0].percent - 25.0).abs() < 0.01); + assert!(rows[0].service_type.contains("Pro")); + } + _ => panic!("expected Services"), + } +} + +#[test] +fn token_plan_predicate_matches_reporter_2062_message() { + // Live reporter evidence (issue #254): the legacy remains endpoint 200s + // with base_resp 2062 for Token Plan accounts. + let json = serde_json::json!({ + "base_resp": { + "status_code": 2062, + "status_msg": "no active token plan subscription" + } + }); + let err = parse_coding_plan_value(&json, now()).unwrap_err(); + assert!(matches!(err, ProviderError::Other(_))); + assert!(is_token_plan_without_coding_plan(&err)); +} + +#[test] +fn token_plan_predicate_rejects_unrelated_errors() { + assert!(!is_token_plan_without_coding_plan( + &ProviderError::AuthRequired + )); + assert!(!is_token_plan_without_coding_plan(&ProviderError::Other( + "MiniMax coding plan status 2000".to_string() + ))); + assert!(!is_token_plan_without_coding_plan(&ProviderError::Parse( + "Missing coding plan data.".to_string() + ))); + // Case-insensitive match on the message itself. + assert!(is_token_plan_without_coding_plan(&ProviderError::Other( + "No Active Token Plan Subscription".to_string() + ))); +} diff --git a/rust/src/providers/synthetic/mod.rs b/rust/src/providers/synthetic/mod.rs index 52b857dc9a..018cbdd8b3 100644 --- a/rust/src/providers/synthetic/mod.rs +++ b/rust/src/providers/synthetic/mod.rs @@ -647,14 +647,15 @@ impl Quota { /// A window whose next regen tick replaces the pace line. /// - /// The window length is left out on purpose: the card derives a pace line - /// from `window_minutes` + `resets_at`, and measured against the next tick - /// time that line would be meaningless. Upstream shows the regen line - /// instead of pace. + /// The window keeps the payload length, as upstream synthetic.js does, so + /// the tray can still score its pace. The card hides the pace line for a + /// regenerating window (`with_regenerating_reset`) and shows the regen + /// line instead, as upstream `syntheticRollingRegenDetail` and + /// `syntheticRegenDetail` do. fn regen_window(&self, next_regen_percent: f64, ticks_to_full: f64) -> RateWindow { RateWindow::with_details( self.used_percent, - None, + self.window_minutes, self.resets_at, Some(regen_detail( self.used_percent, diff --git a/rust/src/providers/synthetic/tests.rs b/rust/src/providers/synthetic/tests.rs index 6baa168c12..03609e5cd3 100644 --- a/rust/src/providers/synthetic/tests.rs +++ b/rust/src/providers/synthetic/tests.rs @@ -81,8 +81,9 @@ fn pack_result_matches_the_mac_card() { assert!(usage.primary.description_is_detail); // The card says "Regenerates in 45m", not "Resets in 45m". assert!(usage.primary.reset_regenerates); - // No window length, so the card shows no pace line on the regen lane. - assert_eq!(usage.primary.window_minutes, None); + // The regen lane keeps its five-hour length (upstream synthetic.js); the + // card hides its pace line because the reset regenerates. + assert_eq!(usage.primary.window_minutes, Some(300)); // "Weekly tokens 42% left", "Resets in 4d 12h", pace from the 7-day window. let weekly = usage.secondary.as_ref().expect("weekly lane"); @@ -248,7 +249,8 @@ fn weekly_cost_with_next_regen_amount_drives_the_weekly_regen_line() { ); assert!(weekly.description_is_detail); assert!(weekly.reset_regenerates); - assert_eq!(weekly.window_minutes, None); + // windowDays 7 survives the regen line, as upstream keeps it. + assert_eq!(weekly.window_minutes, Some(10_080)); assert_eq!(result.usage.login_method.as_deref(), Some("Pro")); // The Mac card hides Synthetic's cost block, so the credits never reach // the result. diff --git a/rust/src/providers/zai/mod.rs b/rust/src/providers/zai/mod.rs index af089239b1..a22b823f10 100755 --- a/rust/src/providers/zai/mod.rs +++ b/rust/src/providers/zai/mod.rs @@ -497,20 +497,18 @@ impl ZaiProvider { ((used / limit) * 100.0).clamp(0.0, 100.0) } - // Upstream 0.48.0 `rateWindow`/`resetDescription`: only token-type - // windows keep duration minutes; TIME_LIMIT (MCP) carries the "MCP" - // label and no window duration, except upstream v0.70.0 zai.js's - // monthly MCP marker (unit 5, number 1), which gets the 30-day - // sentinel; 5-hour token windows are labeled "5-hour"; otherwise the - // explicit window label is used. + // Upstream zai.js `window` (v0.73.0 :85-99): every limit takes its + // length from the unit multipliers; the monthly MCP marker (TIME_LIMIT + // unit 5, number 1) gets the 30-day sentinel instead. TIME_LIMIT (MCP) + // carries the "MCP" label; 5-hour token windows are labeled "5-hour"; + // otherwise the explicit window label is used. let now = Utc::now(); let make_window = |l: &ZaiLimit| -> RateWindow { - let window_mins = if is_token_limit_type(l.limit_type.as_deref()) { - ZaiProvider::window_minutes(l) - } else if is_monthly_mcp_marker(l) { + let is_token_limit = is_token_limit_type(l.limit_type.as_deref()); + let window_mins = if is_monthly_mcp_marker(l) { Some(crate::core::MONTHLY_WINDOW_SENTINEL_MINUTES) } else { - None + ZaiProvider::window_minutes(l) }; let resets_at = l .next_reset_time @@ -521,8 +519,11 @@ impl ZaiProvider { .and_then(|value| DateTime::parse_from_rfc3339(value).ok()) .map(|timestamp| timestamp.with_timezone(&Utc)) }); - let resets_at = - resets_at.filter(|reset| is_plausible_five_hour_reset(window_mins, *reset, now)); + // The five-hour plausibility check is for Coding Plan token + // limits only; a five-hour TIME_LIMIT keeps its reset. + let resets_at = resets_at.filter(|reset| { + !is_token_limit || is_plausible_five_hour_reset(window_mins, *reset, now) + }); RateWindow::with_details( compute_percent(l), window_mins, @@ -586,7 +587,7 @@ impl ZaiProvider { 6 => 10080, // weeks _ => return None, }; - Some(number * minutes_per_unit) + number.checked_mul(minutes_per_unit) } } diff --git a/rust/src/providers/zai/tests.rs b/rust/src/providers/zai/tests.rs index 8a68039e6d..bc06c36a70 100644 --- a/rust/src/providers/zai/tests.rs +++ b/rust/src/providers/zai/tests.rs @@ -253,9 +253,12 @@ fn usage_signal_overrides_stale_percentage() { } #[test] -fn time_limit_primary_carries_mcp_label_without_duration() { - // Upstream 0.48.0: TIME_LIMIT (MCP) windows no longer keep explicit - // duration minutes and label as "MCP", not the old monthly sentinel. +fn time_limit_primary_takes_its_unit_length() { + // Upstream zai.js `window` (v0.73.0 :85-99): a TIME_LIMIT (MCP) window + // takes its length from the unit multipliers (unit 3 = hours) and keeps + // the "MCP" label. The five-hour reset plausibility check is for token + // limits only, so a reset 8 hours away is kept. + let reset = Utc::now() + chrono::Duration::hours(8); let provider = ZaiProvider::new(); let quota: ZaiQuotaResponse = serde_json::from_value(serde_json::json!({ "code": 200, @@ -268,15 +271,38 @@ fn time_limit_primary_carries_mcp_label_without_duration() { "currentValue": 20, "remaining": 80, "percentage": 25, - "nextResetTime": 123000_i64 + "nextResetTime": reset.timestamp_millis() }] } })) .unwrap(); let usage = provider.parse_quota_response("a).unwrap().usage; - assert_eq!(usage.primary.window_minutes, None); + assert_eq!(usage.primary.window_minutes, Some(300)); + assert_eq!(usage.primary.reset_description.as_deref(), Some("MCP")); + assert_eq!( + usage.primary.resets_at.map(|at| at.timestamp_millis()), + Some(reset.timestamp_millis()) + ); +} + +#[test] +fn weekly_time_limit_primary_is_seven_days() { + let provider = ZaiProvider::new(); + let quota: ZaiQuotaResponse = serde_json::from_value(serde_json::json!({ + "code": 200, + "data": { + "limits": [{ + "type": "TIME_LIMIT", + "unit": 6, + "number": 1, + "percentage": 40 + }] + } + })) + .unwrap(); + let usage = provider.parse_quota_response("a).unwrap().usage; + assert_eq!(usage.primary.window_minutes, Some(10_080)); assert_eq!(usage.primary.reset_description.as_deref(), Some("MCP")); - assert!(usage.primary.resets_at.is_some()); } #[test] @@ -605,3 +631,27 @@ fn monthly_mcp_marker_gets_the_sentinel_and_reset_window_pace() { ); assert_eq!(parsed.usage.primary.window_minutes, Some(300)); } + +#[test] +fn oversized_window_length_has_no_minutes() { + let provider = ZaiProvider::new(); + let quota: ZaiQuotaResponse = serde_json::from_value(serde_json::json!({ + "code": 200, + "data": { + "limits": [{ + "type": "TIME_LIMIT", + "unit": 6, + "number": i32::MAX, + "usage": 100, + "currentValue": 20, + "remaining": 80, + "percentage": 25, + "nextResetTime": 123000_i64 + }] + } + })) + .unwrap(); + let usage = provider.parse_quota_response("a).unwrap().usage; + assert_eq!(usage.primary.window_minutes, None); + assert_eq!(usage.primary.used_percent, 20.0); +}