From 74476b88636743dee30c05471c041e90089b69fd Mon Sep 17 00:00:00 2001 From: NessZerra Date: Fri, 17 Jul 2026 10:55:32 +0700 Subject: [PATCH] Fix weekly toast spam, OpenCode parse, and floatbar z-order guard - #198: key threshold notifications by provider+window so a cool session cannot re-arm a hot weekly toast on every refresh - #193: accept rolling-only or weekly-only OpenCode usage and widen regex for JSON-quoted percent fields - #199: replace system-wide WinEvent hooks with a visibility-scoped timer; clear active state on Destroyed --- .../src-tauri/src/floatbar/mod.rs | 13 +- .../src-tauri/src/floatbar/topmost_guard.rs | 153 ++++++----------- rust/src/notifications.rs | 149 +++++++++++++--- rust/src/providers/opencode/mod.rs | 159 ++++++++++++------ 4 files changed, 292 insertions(+), 182 deletions(-) diff --git a/apps/desktop-tauri/src-tauri/src/floatbar/mod.rs b/apps/desktop-tauri/src-tauri/src/floatbar/mod.rs index 52fa8cc3f5..85b301b441 100644 --- a/apps/desktop-tauri/src-tauri/src/floatbar/mod.rs +++ b/apps/desktop-tauri/src-tauri/src/floatbar/mod.rs @@ -19,11 +19,9 @@ use tauri::{Emitter, Manager}; /// Install the native z-order guard and reopen the floating bar on app start /// if it was enabled previously. /// -/// Called once from `main.rs::setup` so the Windows event hooks are registered -/// on the thread that owns Tauri's message loop. +/// Called once from `main.rs::setup` so the guard has the app handle before +/// the floatbar is shown. pub fn install(app: &tauri::AppHandle) { - // Install the Windows shell hooks from Tauri's setup thread, which owns a - // message loop and therefore can receive out-of-context WinEvents. topmost_guard::install(app); let persisted = Settings::load(); if persisted.float_bar_enabled { @@ -47,8 +45,8 @@ pub fn handle_window_event(window: &tauri::Window, event: &tauri::WindowEvent) - match event { tauri::WindowEvent::Moved(_) | tauri::WindowEvent::Resized(_) => { // Probe first (no I/O). Load style only when recovery runs. - // Always reassert topmost on geometry change; shell-event path - // in topmost_guard stays overlap-gated. + // Always reassert topmost on geometry change; the visibility-scoped + // topmost_guard timer stays overlap-gated. let relocated = if window::is_off_all_monitors(window) { let style = Settings::load().float_bar_style; window::recover_onto_primary(window, &style) @@ -64,6 +62,9 @@ pub fn handle_window_event(window: &tauri::Window, event: &tauri::WindowEvent) - } } tauri::WindowEvent::CloseRequested { .. } => window::remember_geometry(window), + tauri::WindowEvent::Destroyed => { + topmost_guard::set_active(false); + } _ => {} } true diff --git a/apps/desktop-tauri/src-tauri/src/floatbar/topmost_guard.rs b/apps/desktop-tauri/src-tauri/src/floatbar/topmost_guard.rs index d3774fb48f..8e5e04e2fe 100644 --- a/apps/desktop-tauri/src-tauri/src/floatbar/topmost_guard.rs +++ b/apps/desktop-tauri/src-tauri/src/floatbar/topmost_guard.rs @@ -1,15 +1,10 @@ -//! Event-driven Windows z-order repair for a floatbar placed over the taskbar. +//! Keep a taskbar-overlapping floatbar above the Windows taskbar. //! -//! The Windows taskbar is itself a topmost shell window. Activating or -//! reordering it can therefore put it in front of another topmost window -//! without removing that other window's `WS_EX_TOPMOST` style. We listen for -//! the two shell events that can produce that ordering change and re-assert the -//! floatbar only when it actually overlaps a taskbar. - -#[cfg(any(windows, test))] -const EVENT_SYSTEM_FOREGROUND: u32 = 0x0003; -#[cfg(any(windows, test))] -const EVENT_OBJECT_REORDER: u32 = 0x8004; +//! The taskbar is itself topmost. Activating it can reorder the topmost band +//! without clearing `WS_EX_TOPMOST` on the floatbar. While the floatbar is +//! shown we run a short visibility-scoped timer and reassert topmost only when +//! the bar is visible and overlaps a taskbar. Move/resize always reasserts in +//! `floatbar::handle_window_event` (not overlap-gated). #[cfg(any(windows, test))] #[repr(C)] @@ -26,110 +21,82 @@ fn rects_overlap(a: Rect, b: Rect) -> bool { a.left < b.right && a.right > b.left && a.top < b.bottom && a.bottom > b.top } -#[cfg(any(windows, test))] -fn is_relevant_event(event: u32) -> bool { - event == EVENT_SYSTEM_FOREGROUND || event == EVENT_OBJECT_REORDER -} - #[cfg(windows)] mod platform { - use super::{ - EVENT_OBJECT_REORDER, EVENT_SYSTEM_FOREGROUND, Rect, is_relevant_event, rects_overlap, - }; + use super::{Rect, rects_overlap}; use raw_window_handle::HasWindowHandle; + use std::sync::OnceLock; use std::sync::atomic::{AtomicBool, Ordering}; - use std::sync::{Mutex, OnceLock}; use std::time::Duration; use tauri::Manager; use super::super::window::{self, FLOATBAR_LABEL}; - const WINEVENT_OUTOFCONTEXT: u32 = 0x0000; - const WINEVENT_SKIPOWNPROCESS: u32 = 0x0002; - const REASSERT_DELAY: Duration = Duration::from_millis(40); + /// Interval while the floatbar is active. Matches the ~150ms recovery + /// budget validated for the original hook-based fix. + const POLL_INTERVAL: Duration = Duration::from_millis(120); static APP_HANDLE: OnceLock = OnceLock::new(); - static HOOKS: OnceLock>> = OnceLock::new(); static FLOATBAR_ACTIVE: AtomicBool = AtomicBool::new(false); - static REASSERT_PENDING: AtomicBool = AtomicBool::new(false); + static LOOP_RUNNING: AtomicBool = AtomicBool::new(false); + + static PRIMARY_CLASS: OnceLock> = OnceLock::new(); + static SECONDARY_CLASS: OnceLock> = OnceLock::new(); pub fn install(app: &tauri::AppHandle) { let _ = APP_HANDLE.set(app.clone()); - let hooks = HOOKS.get_or_init(|| Mutex::new(Vec::new())); - let mut hooks = hooks.lock().unwrap(); - if !hooks.is_empty() { - return; - } - - for event in [EVENT_SYSTEM_FOREGROUND, EVENT_OBJECT_REORDER] { - let hook = unsafe { - SetWinEventHook( - event, - event, - 0, - Some(win_event_proc), - 0, - 0, - WINEVENT_OUTOFCONTEXT | WINEVENT_SKIPOWNPROCESS, - ) - }; - if hook == 0 { - tracing::warn!( - event, - error = %std::io::Error::last_os_error(), - "failed to install floatbar z-order event hook" - ); - } else { - hooks.push(hook); - } - } - } - - unsafe extern "system" fn win_event_proc( - _hook: isize, - event: u32, - _hwnd: isize, - _object_id: i32, - _child_id: i32, - _event_thread: u32, - _event_time: u32, - ) { - if FLOATBAR_ACTIVE.load(Ordering::Acquire) && is_relevant_event(event) { - schedule_reassert(); - } } pub fn set_active(active: bool) { FLOATBAR_ACTIVE.store(active, Ordering::Release); + if active { + ensure_loop(); + } } - fn schedule_reassert() { - if REASSERT_PENDING.swap(true, Ordering::AcqRel) { + fn ensure_loop() { + if LOOP_RUNNING.swap(true, Ordering::AcqRel) { return; } let Some(app) = APP_HANDLE.get().cloned() else { - REASSERT_PENDING.store(false, Ordering::Release); + LOOP_RUNNING.store(false, Ordering::Release); return; }; tauri::async_runtime::spawn(async move { - tokio::time::sleep(REASSERT_DELAY).await; - let dispatcher = app.clone(); - let result = dispatcher.run_on_main_thread(move || { - if let Some(floatbar) = app.get_webview_window(FLOATBAR_LABEL) - && floatbar.is_visible().unwrap_or(false) - && overlaps_taskbar(&floatbar) - { - window::apply_always_on_top(&floatbar); + while FLOATBAR_ACTIVE.load(Ordering::Acquire) { + tokio::time::sleep(POLL_INTERVAL).await; + if !FLOATBAR_ACTIVE.load(Ordering::Acquire) { + break; } - REASSERT_PENDING.store(false, Ordering::Release); - }); - if result.is_err() { - REASSERT_PENDING.store(false, Ordering::Release); + let dispatcher = app.clone(); + let app_for_work = app.clone(); + let _ = dispatcher.run_on_main_thread(move || { + reassert_if_needed(&app_for_work); + }); + } + LOOP_RUNNING.store(false, Ordering::Release); + // If set_active(true) raced with loop exit, start again. + if FLOATBAR_ACTIVE.load(Ordering::Acquire) { + ensure_loop(); } }); } + fn reassert_if_needed(app: &tauri::AppHandle) { + let Some(floatbar) = app.get_webview_window(FLOATBAR_LABEL) else { + // Webview gone without hide — stop the guard. + FLOATBAR_ACTIVE.store(false, Ordering::Release); + return; + }; + if !floatbar.is_visible().unwrap_or(false) { + return; + } + if overlaps_taskbar(&floatbar) { + window::apply_always_on_top(&floatbar); + } + } + fn overlaps_taskbar(window: &tauri::WebviewWindow) -> bool { let Ok(handle) = window.window_handle() else { return false; @@ -149,8 +116,8 @@ mod platform { } fn taskbar_rects() -> Vec { - let primary_class = wide("Shell_TrayWnd"); - let secondary_class = wide("Shell_SecondaryTrayWnd"); + let primary_class = PRIMARY_CLASS.get_or_init(|| wide("Shell_TrayWnd")); + let secondary_class = SECONDARY_CLASS.get_or_init(|| wide("Shell_SecondaryTrayWnd")); let mut rects = Vec::new(); let primary = unsafe { FindWindowW(primary_class.as_ptr(), std::ptr::null()) }; @@ -184,19 +151,8 @@ mod platform { value.encode_utf16().chain(std::iter::once(0)).collect() } - type WinEventProc = unsafe extern "system" fn(isize, u32, isize, i32, i32, u32, u32); - #[link(name = "user32")] unsafe extern "system" { - fn SetWinEventHook( - event_min: u32, - event_max: u32, - module: isize, - callback: Option, - process_id: u32, - thread_id: u32, - flags: u32, - ) -> isize; fn FindWindowW(class_name: *const u16, window_name: *const u16) -> isize; fn FindWindowExW( parent: isize, @@ -256,11 +212,4 @@ mod tests { taskbar )); } - - #[test] - fn foreground_and_window_reorder_events_are_relevant() { - assert!(is_relevant_event(EVENT_SYSTEM_FOREGROUND)); - assert!(is_relevant_event(EVENT_OBJECT_REORDER)); - assert!(!is_relevant_event(0x800B)); - } } diff --git a/rust/src/notifications.rs b/rust/src/notifications.rs index b7462349a9..6518c7c187 100755 --- a/rust/src/notifications.rs +++ b/rust/src/notifications.rs @@ -96,12 +96,25 @@ impl NotificationType { NotificationType::SessionRestored => "✅", } } + + fn is_threshold_toast(self) -> bool { + matches!( + self, + NotificationType::HighUsage + | NotificationType::CriticalUsage + | NotificationType::Exhausted + ) + } } +/// Dedupe identity for threshold toasts. `window` is the rate window id +/// (`"session"`, `"weekly"`, …) so session and weekly budgets arm independently. +type ThresholdKey = (ProviderId, String, NotificationType); + /// Notification manager pub struct NotificationManager { /// Track which notifications have been sent to avoid spam - sent_notifications: std::collections::HashSet<(ProviderId, NotificationType)>, + sent_notifications: std::collections::HashSet, /// Track previous session percent for depleted/restored transitions previous_session_percent: std::collections::HashMap, predictive_warning_keys: std::collections::HashSet, @@ -236,15 +249,17 @@ impl NotificationManager { } else if used_percent >= thresholds.high { Some(NotificationType::HighUsage) } else { - // Reset notifications if usage dropped - self.sent_notifications.retain(|(p, _)| *p != provider); + // Clear only this window's threshold toasts so a cool session cannot + // re-arm a still-hot weekly (or vice versa) on the next poll. + self.sent_notifications + .retain(|(p, w, t)| *p != provider || w != window || !t.is_threshold_toast()); None }; if let Some(notif_type) = notification_type { - let key = (provider, notif_type); + let key = (provider, window.to_string(), notif_type); if !self.sent_notifications.contains(&key) { - self.send_notification(provider, used_percent, notif_type, settings); + self.send_notification(provider, window, used_percent, notif_type, settings); self.sent_notifications.insert(key); } } @@ -257,7 +272,7 @@ impl NotificationManager { description: &str, settings: &Settings, ) { - let key = (provider, NotificationType::StatusIssue); + let key = (provider, String::new(), NotificationType::StatusIssue); if !self.sent_notifications.contains(&key) { self.send_status_notification(provider, description, settings); self.sent_notifications.insert(key); @@ -267,7 +282,7 @@ impl NotificationManager { /// Clear status issue notification (when resolved) pub fn clear_status_issue(&mut self, provider: ProviderId) { self.sent_notifications - .remove(&(provider, NotificationType::StatusIssue)); + .remove(&(provider, String::new(), NotificationType::StatusIssue)); } /// Check session quota transitions (depleted/restored) @@ -299,16 +314,21 @@ impl NotificationManager { ); self.show_toast(title, &body); play_alert(AlertSound::Error, settings); - self.sent_notifications - .insert((provider, NotificationType::SessionDepleted)); + self.sent_notifications.insert(( + provider, + "session".to_string(), + NotificationType::SessionDepleted, + )); } // Check for restored transition: was depleted, now is not else if previous_percent >= DEPLETED_THRESHOLD && current_percent < DEPLETED_THRESHOLD { // Only notify restored if we previously sent a depleted notification - if self - .sent_notifications - .contains(&(provider, NotificationType::SessionDepleted)) - { + let depleted_key = ( + provider, + "session".to_string(), + NotificationType::SessionDepleted, + ); + if self.sent_notifications.contains(&depleted_key) { let title = NotificationType::SessionRestored.title(); let body = format!( "{} session restored. Session quota is available again.", @@ -316,8 +336,7 @@ impl NotificationManager { ); self.show_toast(title, &body); play_alert(AlertSound::Success, settings); - self.sent_notifications - .remove(&(provider, NotificationType::SessionDepleted)); + self.sent_notifications.remove(&depleted_key); } } @@ -330,31 +349,49 @@ impl NotificationManager { fn send_notification( &self, provider: ProviderId, + window: &str, used_percent: f64, notif_type: NotificationType, settings: &Settings, ) { let title = notif_type.title(); - let body = Self::notification_body(provider, used_percent, notif_type); + let body = Self::notification_body(provider, window, used_percent, notif_type); self.show_toast(title, &body); play_alert(Self::alert_sound_for(notif_type), settings); } + fn window_label(window: &str) -> &str { + match window { + "session" => "session", + "weekly" => "weekly", + other if !other.is_empty() => other, + _ => "usage", + } + } + fn notification_body( provider: ProviderId, + window: &str, used_percent: f64, notif_type: NotificationType, ) -> String { let provider_name = provider.display_name(); + let window_label = Self::window_label(window); match notif_type { NotificationType::HighUsage => { - format!("{provider_name} usage at {used_percent:.0}% - approaching limit") + format!( + "{provider_name} {window_label} usage at {used_percent:.0}% - approaching limit" + ) } NotificationType::CriticalUsage => { - format!("{provider_name} usage at {used_percent:.0}% - critically high!") + format!( + "{provider_name} {window_label} usage at {used_percent:.0}% - critically high!" + ) } NotificationType::Exhausted => { - format!("{provider_name} usage limit exhausted ({used_percent:.0}%)") + format!( + "{provider_name} {window_label} usage limit exhausted ({used_percent:.0}%)" + ) } NotificationType::StatusIssue => format!("{provider_name} is experiencing issues"), NotificationType::SessionDepleted => { @@ -686,4 +723,78 @@ mod tests { &pace(false, Some(3600.0)), )); } + + #[test] + fn session_below_high_does_not_rearm_weekly_high_toast() { + // Repro for #198: session cool + weekly hot on every refresh used to + // clear all provider keys on the session call, then re-fire weekly. + let mut manager = NotificationManager::new(); + let settings = Settings::default(); + assert!(settings.show_notifications); + assert!((settings.high_usage_threshold - 70.0).abs() < f64::EPSILON); + + let weekly_key = ( + ProviderId::Claude, + "weekly".to_string(), + NotificationType::HighUsage, + ); + + manager.check_and_notify(ProviderId::Claude, "session", 20.0, &settings); + manager.check_and_notify(ProviderId::Claude, "weekly", 76.0, &settings); + assert!(manager.sent_notifications.contains(&weekly_key)); + + // Simulate several refresh cycles: session still cool, weekly still hot. + for _ in 0..5 { + manager.check_and_notify(ProviderId::Claude, "session", 20.0, &settings); + manager.check_and_notify(ProviderId::Claude, "weekly", 76.0, &settings); + } + assert_eq!( + manager + .sent_notifications + .iter() + .filter(|key| key == &&weekly_key) + .count(), + 1, + "weekly high toast must arm only once while still above threshold" + ); + + // Drop weekly below high → re-arm allowed on next climb. + manager.check_and_notify(ProviderId::Claude, "weekly", 50.0, &settings); + assert!(!manager.sent_notifications.contains(&weekly_key)); + manager.check_and_notify(ProviderId::Claude, "weekly", 76.0, &settings); + assert!(manager.sent_notifications.contains(&weekly_key)); + } + + #[test] + fn threshold_keys_isolate_session_and_weekly() { + let mut manager = NotificationManager::new(); + let settings = Settings::default(); + + manager.check_and_notify(ProviderId::Claude, "session", 75.0, &settings); + manager.check_and_notify(ProviderId::Claude, "weekly", 75.0, &settings); + + assert!(manager.sent_notifications.contains(&( + ProviderId::Claude, + "session".to_string(), + NotificationType::HighUsage, + ))); + assert!(manager.sent_notifications.contains(&( + ProviderId::Claude, + "weekly".to_string(), + NotificationType::HighUsage, + ))); + + // Cool only session; weekly stays armed. + manager.check_and_notify(ProviderId::Claude, "session", 10.0, &settings); + assert!(!manager.sent_notifications.contains(&( + ProviderId::Claude, + "session".to_string(), + NotificationType::HighUsage, + ))); + assert!(manager.sent_notifications.contains(&( + ProviderId::Claude, + "weekly".to_string(), + NotificationType::HighUsage, + ))); + } } diff --git a/rust/src/providers/opencode/mod.rs b/rust/src/providers/opencode/mod.rs index 20073f1198..ddbeba4e15 100755 --- a/rust/src/providers/opencode/mod.rs +++ b/rust/src/providers/opencode/mod.rs @@ -185,73 +185,72 @@ impl OpenCodeProvider { return Ok(snapshot); } - // Fall back to regex-based parsing - let rolling = self.extract_usage_regex(text, "rollingUsage")?; - let weekly = self.extract_usage_regex(text, "weeklyUsage")?; - - let primary = RateWindow::with_details( - rolling.0, - Some(300), // 5 hours - Some(now + chrono::Duration::seconds(rolling.1)), - None, - ); - - let secondary = RateWindow::with_details( - weekly.0, - Some(10080), // 7 days - Some(now + chrono::Duration::seconds(weekly.1)), - None, - ); + // Fall back to regex-based parsing. Require at least one window — + // plans may omit rolling or weekly without being unparseable. + let rolling = self.extract_usage_regex(text, "rollingUsage").ok(); + let weekly = self.extract_usage_regex(text, "weeklyUsage").ok(); + self.snapshot_from_windows(rolling, weekly, now, self.extract_renewal_regex(text)) + .ok_or_else(|| { + ProviderError::Parse("Missing usage percent (rolling or weekly)".into()) + }) + } + + /// Build a snapshot from optional rolling/weekly windows. + /// Rolling is preferred as primary; weekly-only becomes primary. + fn snapshot_from_windows( + &self, + rolling: Option<(f64, i64)>, + weekly: Option<(f64, i64)>, + now: DateTime, + renews_at: Option>, + ) -> Option { + let primary = match (rolling, weekly) { + (Some(r), _) => RateWindow::with_details( + r.0, + Some(300), // 5 hours rolling + Some(now + chrono::Duration::seconds(r.1)), + None, + ), + (None, Some(w)) => RateWindow::with_details( + w.0, + Some(10080), // weekly used as sole window + Some(now + chrono::Duration::seconds(w.1)), + None, + ), + (None, None) => return None, + }; - let mut usage = UsageSnapshot::new(primary) - .with_secondary(secondary) - .with_login_method("OpenCode"); - if let Some(renews_at) = self.extract_renewal_regex(text) { + let mut usage = UsageSnapshot::new(primary).with_login_method("OpenCode"); + if rolling.is_some() + && let Some(w) = weekly + { + usage = usage.with_secondary(RateWindow::with_details( + w.0, + Some(10080), + Some(now + chrono::Duration::seconds(w.1)), + None, + )); + } + if let Some(renews_at) = renews_at { usage = usage.with_extra_rate_window( "renewal", "Renews", RateWindow::with_details(0.0, None, Some(renews_at), None), ); } - - Ok(usage) + Some(usage) } /// Parse usage from JSON response fn parse_usage_json(&self, json: &Value, now: DateTime) -> Option { let renews_at = self.find_datetime(json, &["renewAt", "renew_at"]); - // Look for rollingUsage and weeklyUsage let rolling = - self.find_usage_window(json, &["rollingUsage", "rolling", "rolling_usage"])?; - let weekly = self.find_usage_window(json, &["weeklyUsage", "weekly", "weekly_usage"])?; - - let primary = RateWindow::with_details( - rolling.0, - Some(300), - Some(now + chrono::Duration::seconds(rolling.1)), - None, - ); - - let secondary = RateWindow::with_details( - weekly.0, - Some(10080), - Some(now + chrono::Duration::seconds(weekly.1)), - None, - ); + self.find_usage_window(json, &["rollingUsage", "rolling", "rolling_usage"]); + let weekly = + self.find_usage_window(json, &["weeklyUsage", "weekly", "weekly_usage"]); - let mut usage = UsageSnapshot::new(primary) - .with_secondary(secondary) - .with_login_method("OpenCode"); - if let Some(renews_at) = renews_at { - usage = usage.with_extra_rate_window( - "renewal", - "Renews", - RateWindow::with_details(0.0, None, Some(renews_at), None), - ); - } - - Some(usage) + self.snapshot_from_windows(rolling, weekly, now, renews_at) } /// Find usage window in JSON by keys @@ -419,10 +418,16 @@ impl OpenCodeProvider { Self::date_from_value(&Value::String(raw.to_string())) } - /// Extract usage via regex patterns + /// Extract usage via regex patterns (JS-object and JSON-ish payloads). fn extract_usage_regex(&self, text: &str, prefix: &str) -> Result<(f64, i64), ProviderError> { - let percent_pattern = format!(r"{}[^}}]*?usagePercent\s*:\s*([0-9]+(?:\.[0-9]+)?)", prefix); - let reset_pattern = format!(r"{}[^}}]*?resetInSec\s*:\s*([0-9]+)", prefix); + // Allow optional quotes around the window key and percent field names, + // and accept the same percent aliases used by `window_percent`. + let percent_pattern = format!( + r#"{prefix}[^}}]{{0,500}}?(?:"usagePercent"|usagePercent|"usedPercent"|usedPercent|"percent"|percent)\s*[:=]\s*"?([0-9]+(?:\.[0-9]+)?)"?"# + ); + let reset_pattern = format!( + r#"{prefix}[^}}]{{0,500}}?(?:"resetInSec"|resetInSec|"resetInSeconds"|resetInSeconds)\s*[:=]\s*"?([0-9]+)"?"# + ); let percent = self .extract_number(&percent_pattern, text) @@ -571,4 +576,48 @@ mod tests { assert_eq!(OpenCodeProvider::window_reset_seconds(&payload), None); } + + #[test] + fn parses_weekly_only_json_without_rolling() { + let provider = OpenCodeProvider::new(); + let now = DateTime::::from_timestamp(1_700_000_000, 0).unwrap(); + let payload = serde_json::json!({ + "weeklyUsage": { "usagePercent": 76.0, "resetInSec": 86400 } + }); + + let snap = provider.parse_usage_json(&payload, now).expect("snapshot"); + assert!((snap.primary.used_percent - 76.0).abs() < f64::EPSILON); + assert!(snap.secondary.is_none()); + } + + #[test] + fn parses_rolling_only_json_without_weekly() { + let provider = OpenCodeProvider::new(); + let now = DateTime::::from_timestamp(1_700_000_000, 0).unwrap(); + let payload = serde_json::json!({ + "rollingUsage": { "usagePercent": 12.5, "resetInSec": 600 } + }); + + let snap = provider.parse_usage_json(&payload, now).expect("snapshot"); + assert!((snap.primary.used_percent - 12.5).abs() < f64::EPSILON); + assert!(snap.secondary.is_none()); + } + + #[test] + fn regex_accepts_json_quoted_usage_percent() { + let provider = OpenCodeProvider::new(); + let text = r#"{"rollingUsage":{"usagePercent":33.0,"resetInSec":120},"weeklyUsage":{"usagePercent":80}}"#; + let snap = provider.parse_subscription(text).expect("snapshot"); + assert!((snap.primary.used_percent - 33.0).abs() < f64::EPSILON); + assert!((snap.secondary.as_ref().unwrap().used_percent - 80.0).abs() < f64::EPSILON); + } + + #[test] + fn regex_weekly_only_does_not_error_on_missing_rolling() { + let provider = OpenCodeProvider::new(); + // Not valid JSON for serde (trailing noise) so we exercise regex path. + let text = r#"weeklyUsage: { usagePercent: 55, resetInSec: 99 } not-json"#; + let snap = provider.parse_subscription(text).expect("snapshot"); + assert!((snap.primary.used_percent - 55.0).abs() < f64::EPSILON); + } }