Skip to main content

ai_usagebar/
notify.rs

1//! Quota-threshold desktop notifications.
2//!
3//! After a *fresh* fetch (never a cached, stale, or failed one — see
4//! [`crate::outcome::Outcome::off_the_wire`]), the metric rows a refresh
5//! produced are checked against a configurable threshold. A window that
6//! crosses the threshold raises one notification per crossing, and a banked
7//! reset credit that expires soon raises one per credit.
8//!
9//! Layout:
10//! - [`decide`] is the pure decision function: rows + threshold + previous
11//!   state + now → notifications, with two-band urgency and hysteresis
12//!   re-arming.
13//! - [`NotifyState`] is the persisted dedupe map
14//!   (`~/.cache/ai-usagebar/notifications.json`), written atomically under
15//!   the same flock discipline as the vendor caches.
16//! - [`NotifySink`] delivers through `notify-send` on Linux and Notification
17//!   Center on macOS, without changing the decision or persisted state.
18//!
19//! Everything here is best-effort: a missing notifier, an unwritable state
20//! file, or a contended lock is a silent skip — a missed notification beats
21//! a double one, and nothing in this module may change a caller's return
22//! value or exit code.
23//!
24//! No burn-rate extrapolation anywhere: reset times in bodies are only the
25//! vendor-reported absolute instants.
26
27use std::collections::BTreeMap;
28use std::path::{Path, PathBuf};
29use std::time::Duration;
30
31use chrono::{DateTime, SecondsFormat, Utc};
32use serde::{Deserialize, Serialize};
33
34use crate::countdown;
35use crate::display::sanitize_untrusted_line;
36use crate::format;
37
38/// Hysteresis band, in percentage points. A notified key re-arms only when
39/// usage drops `threshold - 7` or further, so a window hovering at the
40/// threshold (97 → 96 → 97) does not re-fire on every refresh.
41const HYSTERESIS_PCT: i32 = 7;
42
43/// How much later a reported reset instant has to be before it counts as a new
44/// window. Vendors report that instant with sub-second precision that jitters
45/// between fetches — Anthropic's five-hour window came back 0.7s apart on two
46/// fetches four minutes apart — and a rolling window slides its reset forward
47/// with the refresh interval, so a strict comparison re-armed the key and
48/// re-notified on refresh with nothing changed. Ninety minutes clears every
49/// refresh interval this ships with (the bar's default is five minutes and its
50/// maximum an hour; the tray refreshes every ten at most) and stays far below
51/// the shortest window, so a real new window still moves the instant by more
52/// than this.
53const RESET_MOVE_TOLERANCE_SECS: i64 = 90 * 60;
54
55/// How long before a banked reset credit's expiry the warning fires.
56const CREDIT_WARNING_SECS: i64 = 48 * 3600;
57
58/// How long to wait for the state lock before skipping the whole check.
59/// Deliberately short: notifications are never worth stalling a refresh for.
60const LOCK_TIMEOUT: Duration = Duration::from_secs(2);
61
62/// A notifier that hangs must not hang the bar with it.
63#[cfg(any(target_os = "linux", target_os = "macos"))]
64const SPAWN_KILL_AFTER: Duration = Duration::from_secs(5);
65
66/// Urgency band for one notification. `notify-send`'s `-u` maps directly.
67#[derive(Debug, Clone, Copy, PartialEq, Eq)]
68pub enum Urgency {
69    /// `threshold <= pct < 100`.
70    Normal,
71    /// The window is exhausted (`pct >= 100`).
72    Critical,
73}
74
75#[cfg(target_os = "linux")]
76impl Urgency {
77    fn as_arg(self) -> &'static str {
78        match self {
79            Self::Normal => "normal",
80            Self::Critical => "critical",
81        }
82    }
83}
84
85/// One notification to deliver. `key` is the dedupe identity the state map
86/// is keyed by; title/body are already sanitized for a subprocess boundary.
87#[derive(Debug, Clone, PartialEq, Eq)]
88pub struct Notification {
89    pub title: String,
90    pub body: String,
91    pub urgency: Urgency,
92    pub key: String,
93}
94
95/// One metric row of a refresh, as the shared panel projection shapes it.
96#[derive(Debug, Clone, PartialEq, Eq)]
97pub struct MetricRow {
98    /// Window label ("Session (5h)", "Weekly (7d)"). Stable per vendor, so it
99    /// is the window half of the dedupe key.
100    pub window: String,
101    pub percent: u16,
102    /// Vendor-reported absolute reset instant. `None` when the vendor does
103    /// not state one — then the body carries no ETA, by rule.
104    pub reset_at: Option<DateTime<Utc>>,
105}
106
107/// One banked reset credit with a known expiry (Codex/SuperGrok
108/// `reset_credits.credits[]`).
109#[derive(Debug, Clone, PartialEq, Eq)]
110pub struct CreditExpiry {
111    pub title: Option<String>,
112    pub expires_at: DateTime<Utc>,
113}
114
115/// The notification-relevant projection of one vendor entry's refresh.
116#[derive(Debug, Clone, PartialEq, Eq)]
117pub struct RefreshInput {
118    /// The report entry id (`vendor` or `vendor@account`) — the entry half of
119    /// the dedupe key.
120    pub entry_id: String,
121    /// Canonical vendor display name (`VendorId::display_name`, or the
122    /// configured name of a `[[custom]]` provider).
123    pub vendor: String,
124    pub account: Option<String>,
125    pub rows: Vec<MetricRow>,
126    pub credits: Vec<CreditExpiry>,
127}
128
129/// Decide which notifications one refresh raises.
130///
131/// `state` is mutated to the post-decision map; the caller persists it (see
132/// [`run_at`]) so re-arms survive restarts. Rules:
133///
134/// - Fire when `percent >= threshold` and the key is not marked notified.
135///   Band: `>= 100` is [`Urgency::Critical`], otherwise [`Urgency::Normal`].
136/// - A key re-arms when usage drops below `threshold - 7`, or when the
137///   window's `reset_at` moves to a later instant (a new window). The same
138///   crossing therefore never fires twice.
139/// - A credit expiring within 48h (and not yet expired) fires once per
140///   expiry instant, at [`Urgency::Normal`].
141pub fn decide(
142    input: &RefreshInput,
143    threshold: u8,
144    state: &mut NotifyState,
145    now: DateTime<Utc>,
146) -> Vec<Notification> {
147    let mut fired = Vec::new();
148    let threshold = threshold as i32;
149    let rearm_below = threshold - HYSTERESIS_PCT;
150    for row in &input.rows {
151        let key = format!("{}::{}", input.entry_id, row.window);
152        let pct = row.percent as i32;
153        if pct >= threshold {
154            let armed = match state.entries.get(&key) {
155                None => true,
156                Some(record) => reset_moved_later(row.reset_at, record.reset_at_snapshot),
157            };
158            if armed {
159                fired.push(threshold_notification(input, row, now));
160                state.entries.insert(
161                    key,
162                    KeyRecord {
163                        notified_at: now,
164                        reset_at_snapshot: row.reset_at,
165                    },
166                );
167            }
168        } else if pct < rearm_below {
169            // Drop below the hysteresis band: forget the crossing so the next
170            // one can fire. Persisted by the caller like any other change.
171            state.entries.remove(&key);
172        }
173    }
174    for credit in &input.credits {
175        let remaining = credit.expires_at.signed_duration_since(now).num_seconds();
176        if remaining <= 0 || remaining > CREDIT_WARNING_SECS {
177            continue;
178        }
179        let key = format!(
180            "{}::credit::{}",
181            input.entry_id,
182            credit
183                .expires_at
184                .to_rfc3339_opts(SecondsFormat::AutoSi, true)
185        );
186        if state.entries.contains_key(&key) {
187            continue;
188        }
189        fired.push(credit_notification(input, credit, now));
190        state.entries.insert(
191            key,
192            KeyRecord {
193                notified_at: now,
194                reset_at_snapshot: None,
195            },
196        );
197    }
198    fired
199}
200
201/// `true` when `current` is a later reset than `snapshot` — a `None → Some`
202/// change counts (the vendor started reporting a reset), `Some → None` and
203/// backwards moves do not, and so do not moves within
204/// [`RESET_MOVE_TOLERANCE_SECS`], which are the same window reported again.
205fn reset_moved_later(current: Option<DateTime<Utc>>, snapshot: Option<DateTime<Utc>>) -> bool {
206    match (current, snapshot) {
207        (Some(current), Some(snapshot)) => {
208            current.signed_duration_since(snapshot).num_seconds() > RESET_MOVE_TOLERANCE_SECS
209        }
210        (Some(_), None) => true,
211        _ => false,
212    }
213}
214
215fn threshold_notification(
216    input: &RefreshInput,
217    row: &MetricRow,
218    now: DateTime<Utc>,
219) -> Notification {
220    let who = match &input.account {
221        Some(account) => format!("{} · {}", input.vendor, account),
222        None => input.vendor.clone(),
223    };
224    let title = sanitize_untrusted_line(&format!("{} — {} at {}%", who, row.window, row.percent));
225    let mut body = format!("{}% of the {} used", row.percent, row.window);
226    if let Some(reset_at) = row.reset_at {
227        body.push_str(&format!(
228            " · resets {} ({})",
229            countdown::format(Some(reset_at), now),
230            format::local_time_hm(reset_at)
231        ));
232    }
233    Notification {
234        title,
235        body: sanitize_untrusted_line(&body),
236        urgency: if row.percent >= 100 {
237            Urgency::Critical
238        } else {
239            Urgency::Normal
240        },
241        key: format!("{}::{}", input.entry_id, row.window),
242    }
243}
244
245fn credit_notification(
246    input: &RefreshInput,
247    credit: &CreditExpiry,
248    now: DateTime<Utc>,
249) -> Notification {
250    let label = credit.title.as_deref().unwrap_or("Reset credit");
251    let title = sanitize_untrusted_line(&format!(
252        "{} — reset credit expires {}",
253        input.vendor,
254        format::local_date_hm(credit.expires_at)
255    ));
256    let body = sanitize_untrusted_line(&format!(
257        "{} — redeem within {} ({})",
258        label,
259        countdown::format(Some(credit.expires_at), now),
260        format::local_time_hm(credit.expires_at)
261    ));
262    Notification {
263        title,
264        body,
265        urgency: Urgency::Normal,
266        key: format!(
267            "{}::credit::{}",
268            input.entry_id,
269            credit
270                .expires_at
271                .to_rfc3339_opts(SecondsFormat::AutoSi, true)
272        ),
273    }
274}
275
276// ─── Persisted dedupe state ────────────────────────────────────────────────
277
278/// Map of dedupe key → when it was notified and the reset instant that was
279/// current at the time. `notified_at` is bookkeeping (inspectable state);
280/// re-arming reads `reset_at_snapshot`. On disk this is the bare map, not a
281/// wrapper object.
282#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
283#[serde(transparent)]
284pub struct NotifyState {
285    entries: BTreeMap<String, KeyRecord>,
286}
287
288#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
289struct KeyRecord {
290    notified_at: DateTime<Utc>,
291    reset_at_snapshot: Option<DateTime<Utc>>,
292}
293
294impl NotifyState {
295    #[cfg(test)]
296    fn is_notified(&self, key: &str) -> bool {
297        self.entries.contains_key(key)
298    }
299
300    /// Read the state file. Missing or corrupt counts as nothing notified —
301    /// a corrupt file must never pin a vendor silent or double-fire forever.
302    fn read_from(path: &Path) -> Self {
303        let Ok(raw) = std::fs::read_to_string(path) else {
304            return Self::default();
305        };
306        serde_json::from_str(&raw).unwrap_or_default()
307    }
308
309    /// Atomic write (tempfile + persist) with mode 0600, matching the cache
310    /// sidecar discipline.
311    fn write_to(&self, path: &Path) -> crate::error::Result<()> {
312        let bytes = serde_json::to_vec(&self.entries)?;
313        crate::cache::atomic_write(path, &bytes)?;
314        #[cfg(unix)]
315        {
316            use std::os::unix::fs::PermissionsExt;
317            if let Ok(meta) = std::fs::metadata(path) {
318                let mut perms = meta.permissions();
319                perms.set_mode(0o600);
320                let _ = std::fs::set_permissions(path, perms);
321            }
322        }
323        Ok(())
324    }
325}
326
327// ─── Delivery seam ─────────────────────────────────────────────────────────
328
329/// Delivery backend. Implementations own one platform each.
330///
331/// `deliver` returns nothing and must never panic: a failing or missing
332/// notifier is ALWAYS a silent no-op, and nothing in this module may change
333/// a caller's return value or exit code.
334pub trait NotifySink {
335    fn deliver(&mut self, notification: &Notification);
336}
337
338/// The do-nothing sink for platforms without a desktop delivery backend.
339#[cfg(any(not(any(target_os = "linux", target_os = "macos")), test))]
340#[derive(Debug, Default)]
341pub struct NoopSink;
342
343#[cfg(any(not(any(target_os = "linux", target_os = "macos")), test))]
344impl NotifySink for NoopSink {
345    fn deliver(&mut self, _notification: &Notification) {}
346}
347
348/// Linux `notify-send` sink. argv only — never a shell — with stderr nulled:
349/// the notification daemon's complaints are not the bar's problem. A missing
350/// binary or a failed spawn is swallowed; a hanging one is killed after
351/// [`SPAWN_KILL_AFTER`].
352#[cfg(target_os = "linux")]
353#[derive(Debug, Clone)]
354pub struct NotifySendSink {
355    program: String,
356}
357
358#[cfg(target_os = "linux")]
359impl Default for NotifySendSink {
360    fn default() -> Self {
361        Self::new()
362    }
363}
364
365#[cfg(target_os = "linux")]
366impl NotifySendSink {
367    pub fn new() -> Self {
368        Self {
369            program: "notify-send".to_string(),
370        }
371    }
372
373    /// Point at a specific executable — the seam the failing-notifier guard
374    /// test uses, and the way a future desktop integration pins a path.
375    pub fn with_program(program: impl Into<String>) -> Self {
376        Self {
377            program: program.into(),
378        }
379    }
380}
381
382#[cfg(target_os = "linux")]
383impl NotifySink for NotifySendSink {
384    fn deliver(&mut self, notification: &Notification) {
385        let spawned = std::process::Command::new(&self.program)
386            .arg("-a")
387            .arg("ai-usagebar")
388            .arg("-c")
389            .arg("quota")
390            .arg("-u")
391            .arg(notification.urgency.as_arg())
392            .arg(&notification.title)
393            .arg(&notification.body)
394            .stdin(std::process::Stdio::null())
395            .stdout(std::process::Stdio::null())
396            .stderr(std::process::Stdio::null())
397            .spawn();
398        if let Ok(child) = spawned {
399            reap(child);
400        }
401    }
402}
403
404/// Wait for the notifier, killing it at the cap. Desktop delivery normally
405/// returns in milliseconds; the cap only matters when the service is wedged.
406#[cfg(any(target_os = "linux", target_os = "macos"))]
407fn reap(mut child: std::process::Child) {
408    let deadline = std::time::Instant::now() + SPAWN_KILL_AFTER;
409    loop {
410        match child.try_wait() {
411            Ok(Some(_)) => return,
412            Ok(None) => {}
413            Err(_) => return,
414        }
415        if std::time::Instant::now() >= deadline {
416            let _ = child.kill();
417            let _ = child.wait();
418            return;
419        }
420        std::thread::sleep(Duration::from_millis(25));
421    }
422}
423
424fn production_sink() -> Box<dyn NotifySink + Send> {
425    #[cfg(target_os = "linux")]
426    return Box::new(NotifySendSink::new());
427    #[cfg(target_os = "macos")]
428    return Box::new(MacNotificationSink);
429    #[cfg(not(any(target_os = "linux", target_os = "macos")))]
430    return Box::new(NoopSink);
431}
432
433/// AppleScript's Standard Additions sends a native Notification Center banner.
434/// Passing title and body as argv keeps provider text out of the script source.
435#[cfg(target_os = "macos")]
436struct MacNotificationSink;
437
438#[cfg(target_os = "macos")]
439impl NotifySink for MacNotificationSink {
440    fn deliver(&mut self, notification: &Notification) {
441        const SCRIPT: &str = "on run argv\n display notification (item 2 of argv) with title (item 1 of argv)\nend run";
442        let spawned = std::process::Command::new("/usr/bin/osascript")
443            .arg("-e")
444            .arg(SCRIPT)
445            .arg("--")
446            .arg(&notification.title)
447            .arg(&notification.body)
448            .stdin(std::process::Stdio::null())
449            .stdout(std::process::Stdio::null())
450            .stderr(std::process::Stdio::null())
451            .spawn();
452        if let Ok(child) = spawned {
453            reap(child);
454        }
455    }
456}
457
458// ─── Orchestration ─────────────────────────────────────────────────────────
459
460/// Production entry point: the real state path and platform sink. Returns
461/// nothing, always — the caller wraps it in `let _ =`.
462pub async fn run(input: RefreshInput, threshold: u8) {
463    let Some(state_path) = default_state_path() else {
464        return;
465    };
466    // The lock wait and the bounded notifier spawn are blocking; the TUI runs
467    // a current-thread runtime, so neither belongs on the reactor thread.
468    let joined = tokio::task::spawn_blocking(move || {
469        let mut sink = production_sink();
470        run_at(
471            &input,
472            threshold,
473            &state_path,
474            LOCK_TIMEOUT,
475            sink.as_mut(),
476            Utc::now(),
477        );
478    });
479    let _ = joined.await;
480}
481
482/// `~/.cache/ai-usagebar/notifications.json` (or under `$XDG_CACHE_HOME`).
483/// Tests never call this — they pass their own path to [`run_at`].
484pub fn default_state_path() -> Option<PathBuf> {
485    crate::cache::xdg_cache_dir()
486        .ok()
487        .map(|base| base.join("ai-usagebar").join("notifications.json"))
488}
489
490/// The whole check, serialized against any concurrent writer by the same
491/// flock pattern the vendor caches use. On lock timeout the check is skipped
492/// entirely — a missed notification beats a double one. State is persisted
493/// BEFORE delivery: a write failure then costs a missed notification (the
494/// preferred failure) rather than a repeat.
495pub fn run_at(
496    input: &RefreshInput,
497    threshold: u8,
498    state_path: &Path,
499    lock_timeout: Duration,
500    sink: &mut dyn NotifySink,
501    now: DateTime<Utc>,
502) {
503    let lock_path = state_path.with_file_name(".notifications.lock");
504    let Ok(_guard) = crate::cache::acquire_lock(&lock_path, lock_timeout) else {
505        return;
506    };
507    let mut state = NotifyState::read_from(state_path);
508    let before = serde_json::to_string(&state.entries).unwrap_or_default();
509    let fired = decide(input, threshold, &mut state, now);
510    if serde_json::to_string(&state.entries).unwrap_or_default() != before
511        && state.write_to(state_path).is_err()
512    {
513        // Could not persist the marks: delivering anyway would repeat the
514        // notification on the next fresh fetch. Skip instead.
515        return;
516    }
517    for notification in &fired {
518        sink.deliver(notification);
519    }
520}
521
522// ─── Shared-assembly projection ────────────────────────────────────────────
523
524impl RefreshInput {
525    /// Project one tab's completed state into the notification input. Rows
526    /// come from the same panel projection every frontend reads; grouped
527    /// sub-rows (SuperGrok's product slices) are skipped — they share the
528    /// overall pool's window, and one notification per pool is the point.
529    /// Banked credits ride along only when the vendor reports them and the
530    /// report would surface them (`available > 0`).
531    pub(crate) fn from_tab(
532        tab: &crate::tui::app::TabId,
533        state: &crate::tui::app::TabState,
534        now: DateTime<Utc>,
535    ) -> Option<Self> {
536        use crate::tui::app::{TabSource, TabState};
537        use crate::tui::panels::sections_with_metadata_for;
538
539        let TabState::Ready(ready) = state else {
540            return None;
541        };
542        let (vendor, entry_id) = match &tab.source {
543            TabSource::Builtin(vendor) => (
544                vendor.display_name().to_string(),
545                crate::report::tab_id(tab),
546            ),
547            TabSource::Custom { name, .. } => (name.clone(), crate::report::tab_id(tab)),
548        };
549        // The tolerance only shapes pacing footnotes, which the notification
550        // body never reads; any value behaves identically here.
551        let rows = metric_rows(sections_with_metadata_for(state, now, 0));
552        let credits = ready
553            .snapshot
554            .reset_credits()
555            .filter(|credits| credits.available > 0)
556            .map(|credits| {
557                credits
558                    .credits
559                    .iter()
560                    .filter_map(|credit| {
561                        credit.expires_at.map(|expires_at| CreditExpiry {
562                            title: credit.title.clone(),
563                            expires_at,
564                        })
565                    })
566                    .collect()
567            })
568            .unwrap_or_default();
569        Some(Self {
570            entry_id,
571            vendor,
572            account: tab.account.clone(),
573            rows,
574            credits,
575        })
576    }
577}
578
579/// The metric rows one refresh checks, each named by the window it measures.
580///
581/// Grouped sub-rows are skipped (see [`RefreshInput::from_tab`]). A label is
582/// normally unique within an entry, but Antigravity and MiniMax lay a
583/// `Session` and a `Weekly` heading over the same pool labels, so "Gemini"
584/// named two windows. Sharing one dedupe key, the row under the re-arm band
585/// cleared the record its namesake over the threshold had just written, and
586/// the same notification fired again on every refresh. Metrics under a heading
587/// always include it in their name ("Weekly · Gemini"), even if no other row
588/// currently has the same label: a missing sibling must not change a window's
589/// identity. Metrics without a heading keep their bare names.
590fn metric_rows(
591    sections: impl IntoIterator<Item = crate::tui::panels::SectionProjection>,
592) -> Vec<MetricRow> {
593    use crate::tui::panels::Section;
594
595    // A heading is a text row with no value; it covers the metrics below it
596    // until any other row except a spacer.
597    let mut heading: Option<String> = None;
598    let mut rows = Vec::new();
599    for projected in sections {
600        if projected.group.is_some() {
601            continue;
602        }
603        match projected.section {
604            Section::Text { label, value } if value.is_empty() => heading = Some(label),
605            Section::Metric { label, pct, .. } => {
606                let window = match &heading {
607                    Some(heading) => format!("{heading} · {label}"),
608                    None => label,
609                };
610                rows.push(MetricRow {
611                    window,
612                    percent: pct,
613                    reset_at: projected.reset_at,
614                });
615            }
616            Section::Spacer => {}
617            _ => heading = None,
618        }
619    }
620    rows
621}
622
623#[cfg(test)]
624mod tests {
625    use super::*;
626    use chrono::TimeZone;
627    use tempfile::TempDir;
628
629    fn at(day: u32, hour: u32, min: u32) -> DateTime<Utc> {
630        Utc.with_ymd_and_hms(2026, 9, day, hour, min, 0).unwrap()
631    }
632
633    fn row(window: &str, percent: u16, reset_at: Option<DateTime<Utc>>) -> MetricRow {
634        MetricRow {
635            window: window.to_string(),
636            percent,
637            reset_at,
638        }
639    }
640
641    fn input(rows: Vec<MetricRow>) -> RefreshInput {
642        RefreshInput {
643            entry_id: "anthropic@gmail".into(),
644            vendor: "Claude".into(),
645            account: Some("gmail".into()),
646            rows,
647            credits: Vec::new(),
648        }
649    }
650
651    /// Sink that records deliveries instead of spawning anything.
652    #[derive(Default)]
653    struct RecorderSink {
654        delivered: Vec<Notification>,
655    }
656
657    impl NotifySink for RecorderSink {
658        fn deliver(&mut self, notification: &Notification) {
659            self.delivered.push(notification.clone());
660        }
661    }
662
663    fn state_path() -> (TempDir, PathBuf) {
664        crate::cache::closed_temp_file("notifications.json", None)
665    }
666
667    // — Decision: threshold crossing ————————————————
668
669    #[test]
670    fn crossing_at_exactly_the_threshold_fires_normal() {
671        let mut state = NotifyState::default();
672        let now = at(23, 12, 0);
673        let reset = at(24, 12, 0);
674        let fired = decide(
675            &input(vec![row("Session (5h)", 97, Some(reset))]),
676            97,
677            &mut state,
678            now,
679        );
680        assert_eq!(fired.len(), 1);
681        assert_eq!(fired[0].urgency, Urgency::Normal);
682        assert_eq!(fired[0].key, "anthropic@gmail::Session (5h)");
683        assert_eq!(fired[0].title, "Claude · gmail — Session (5h) at 97%");
684        // Sep 23 12:00 → Sep 24 12:00 is exactly one day: "1d 0h", with the
685        // reset's local clock time beside it (rendered by the shared helper,
686        // so the assertion holds on any machine timezone).
687        assert_eq!(
688            fired[0].body,
689            format!(
690                "97% of the Session (5h) used · resets 1d 0h ({})",
691                format::local_time_hm(reset)
692            )
693        );
694    }
695
696    #[test]
697    fn below_the_threshold_fires_nothing() {
698        let mut state = NotifyState::default();
699        let fired = decide(
700            &input(vec![row("Session (5h)", 96, None)]),
701            97,
702            &mut state,
703            at(23, 12, 0),
704        );
705        assert!(fired.is_empty());
706        assert!(state.entries.is_empty());
707    }
708
709    #[test]
710    fn exhausted_windows_fire_critical() {
711        let mut state = NotifyState::default();
712        let fired = decide(
713            &input(vec![row("Weekly (7d)", 100, None)]),
714            97,
715            &mut state,
716            at(23, 12, 0),
717        );
718        assert_eq!(fired.len(), 1);
719        assert_eq!(fired[0].urgency, Urgency::Critical);
720        assert_eq!(fired[0].title, "Claude · gmail — Weekly (7d) at 100%");
721    }
722
723    #[test]
724    fn the_same_crossing_never_fires_twice() {
725        let mut state = NotifyState::default();
726        let high = input(vec![row("Weekly (7d)", 98, Some(at(30, 0, 0)))]);
727        let now = at(23, 12, 0);
728        assert_eq!(decide(&high, 97, &mut state, now).len(), 1);
729        assert_eq!(decide(&high, 97, &mut state, now).len(), 0);
730        assert_eq!(decide(&high, 97, &mut state, now).len(), 0);
731    }
732
733    // — Decision: hysteresis re-arm ——————————————————
734
735    #[test]
736    fn hysteresis_rearms_only_below_threshold_minus_seven() {
737        let mut state = NotifyState::default();
738        let now = at(23, 12, 0);
739        let fires_at = |pct: u16, state: &mut NotifyState| {
740            let case = input(vec![row("Weekly (7d)", pct, None)]);
741            decide(&case, 97, state, now).len()
742        };
743
744        assert_eq!(fires_at(97, &mut state), 1, "first crossing fires");
745        assert_eq!(fires_at(96, &mut state), 0, "hovering does not re-fire");
746        // 90 == threshold - 7: still inside the band, still armed-out.
747        assert_eq!(fires_at(90, &mut state), 0);
748        assert!(state.is_notified("anthropic@gmail::Weekly (7d)"));
749        // Below the band: the mark is dropped…
750        assert_eq!(fires_at(89, &mut state), 0);
751        assert!(!state.is_notified("anthropic@gmail::Weekly (7d)"));
752        // …so the next crossing is a new crossing.
753        assert_eq!(fires_at(97, &mut state), 1, "re-armed crossing fires");
754    }
755
756    #[test]
757    fn a_jittered_reset_instant_is_the_same_window() {
758        // Observed live on Anthropic's five-hour window: two fetches four
759        // minutes apart reported the same window's reset 0.70s apart, and the
760        // strict comparison re-armed the key and re-notified on refresh with
761        // nothing changed.
762        let mut state = NotifyState::default();
763        let now = at(23, 12, 0);
764        let reset = at(23, 17, 0);
765        let first = input(vec![row("Session (5h)", 100, Some(reset))]);
766        assert_eq!(decide(&first, 97, &mut state, now).len(), 1);
767
768        for drift in [
769            chrono::Duration::milliseconds(488),
770            chrono::Duration::milliseconds(700),
771            chrono::Duration::seconds(59),
772        ] {
773            let jittered = input(vec![row("Session (5h)", 100, Some(reset + drift))]);
774            assert_eq!(
775                decide(&jittered, 97, &mut state, now).len(),
776                0,
777                "{drift:?} later is the same window"
778            );
779        }
780
781        // A window that really rolled over moves the instant by its length.
782        let next_window = input(vec![row(
783            "Session (5h)",
784            100,
785            Some(reset + chrono::Duration::hours(5)),
786        )]);
787        assert_eq!(decide(&next_window, 97, &mut state, now).len(), 1);
788    }
789
790    #[test]
791    fn a_later_reset_rearms_the_key() {
792        let mut state = NotifyState::default();
793        let now = at(23, 12, 0);
794        let first = input(vec![row("Session (5h)", 98, Some(at(23, 17, 0)))]);
795        assert_eq!(decide(&first, 97, &mut state, now).len(), 1);
796
797        // Same reset, still above the threshold: no re-fire.
798        assert_eq!(decide(&first, 97, &mut state, now).len(), 0);
799        // Earlier or absent resets are not new windows.
800        let earlier = input(vec![row("Session (5h)", 98, Some(at(23, 16, 0)))]);
801        assert_eq!(decide(&earlier, 97, &mut state, now).len(), 0);
802        let absent = input(vec![row("Session (5h)", 98, None)]);
803        assert_eq!(decide(&absent, 97, &mut state, now).len(), 0);
804        // A later reset is a new window: fires again.
805        let next_window = input(vec![row("Session (5h)", 98, Some(at(24, 2, 0)))]);
806        assert_eq!(decide(&next_window, 97, &mut state, now).len(), 1);
807    }
808
809    #[test]
810    fn a_reset_appearing_where_none_was_reported_rearms() {
811        let mut state = NotifyState::default();
812        let now = at(23, 12, 0);
813        let without = input(vec![row("Weekly quota", 99, None)]);
814        assert_eq!(decide(&without, 97, &mut state, now).len(), 1);
815        assert_eq!(decide(&without, 97, &mut state, now).len(), 0);
816        let with = input(vec![row("Weekly quota", 99, Some(at(30, 0, 0)))]);
817        assert_eq!(
818            decide(&with, 97, &mut state, now).len(),
819            1,
820            "the vendor starting to report a reset is a new window"
821        );
822    }
823
824    // — Decision: copy ——————————————————————————————
825
826    #[test]
827    fn absent_reset_at_omits_the_eta_from_the_body() {
828        let mut state = NotifyState::default();
829        let fired = decide(
830            &input(vec![row("Weekly (7d)", 97, None)]),
831            97,
832            &mut state,
833            at(23, 12, 0),
834        );
835        assert_eq!(fired[0].body, "97% of the Weekly (7d) used");
836        assert!(!fired[0].body.contains("resets"));
837    }
838
839    #[test]
840    fn account_label_is_omitted_from_the_title_when_absent() {
841        let mut state = NotifyState::default();
842        let mut plain = input(vec![row("Weekly (7d)", 97, None)]);
843        plain.entry_id = "zai".into();
844        plain.vendor = "Z.AI".into();
845        plain.account = None;
846        let fired = decide(&plain, 97, &mut state, at(23, 12, 0));
847        assert_eq!(fired[0].title, "Z.AI — Weekly (7d) at 97%");
848    }
849
850    #[test]
851    fn untrusted_labels_are_sanitized_at_the_subprocess_boundary() {
852        let mut state = NotifyState::default();
853        let mut hostile = input(vec![row("W\u{1b}[31m", 97, None)]);
854        hostile.account = Some("gmail\n\u{202E}evil".into());
855        let fired = decide(&hostile, 97, &mut state, at(23, 12, 0));
856        assert!(
857            !fired[0].title.chars().any(|c| c.is_control()),
858            "{}",
859            fired[0].title
860        );
861        assert!(!fired[0].title.contains('\u{202E}'), "{}", fired[0].title);
862        assert!(!fired[0].body.contains('\u{1B}'), "{}", fired[0].body);
863    }
864
865    // — Decision: banked credits ————————————————————
866
867    #[test]
868    fn banked_credit_fires_only_inside_the_48h_window() {
869        let now = at(23, 12, 0);
870        for (offset_secs, expected) in [(47 * 3600, 1), (48 * 3600, 1), (48 * 3600 + 1, 0), (-1, 0)]
871        {
872            let mut state = NotifyState::default();
873            let case = RefreshInput {
874                credits: vec![CreditExpiry {
875                    title: Some("Full reset".into()),
876                    expires_at: now + chrono::Duration::seconds(offset_secs),
877                }],
878                ..input(Vec::new())
879            };
880            let fired = decide(&case, 97, &mut state, now);
881            assert_eq!(fired.len(), expected, "offset {offset_secs}s");
882        }
883    }
884
885    #[test]
886    fn banked_credit_title_and_body_name_the_expiry() {
887        let now = at(23, 12, 0);
888        let mut state = NotifyState::default();
889        let case = RefreshInput {
890            credits: vec![CreditExpiry {
891                title: Some("Full reset (Weekly + 5 hr)".into()),
892                expires_at: now + chrono::Duration::hours(3),
893            }],
894            ..input(Vec::new())
895        };
896        let fired = decide(&case, 97, &mut state, now);
897        assert_eq!(fired.len(), 1);
898        assert_eq!(fired[0].urgency, Urgency::Normal);
899        assert!(
900            fired[0].title.starts_with("Claude — reset credit expires "),
901            "{}",
902            fired[0].title
903        );
904        assert!(
905            fired[0]
906                .body
907                .starts_with("Full reset (Weekly + 5 hr) — redeem within 3h"),
908            "{}",
909            fired[0].body
910        );
911    }
912
913    #[test]
914    fn banked_credit_dedupes_on_the_expiry_instant() {
915        let now = at(23, 12, 0);
916        let mut state = NotifyState::default();
917        let expiry = now + chrono::Duration::hours(10);
918        let case = RefreshInput {
919            credits: vec![CreditExpiry {
920                title: None,
921                expires_at: expiry,
922            }],
923            ..input(Vec::new())
924        };
925        assert_eq!(decide(&case, 97, &mut state, now).len(), 1);
926        assert_eq!(
927            decide(&case, 97, &mut state, now).len(),
928            0,
929            "same expiry is one notification"
930        );
931        assert!(
932            state
933                .entries
934                .keys()
935                .all(|key| key.starts_with("anthropic@gmail::credit::")),
936            "{:?}",
937            state.entries.keys().collect::<Vec<_>>()
938        );
939        // A different expiry instant is a different credit.
940        let later = RefreshInput {
941            credits: vec![CreditExpiry {
942                title: None,
943                expires_at: now + chrono::Duration::hours(20),
944            }],
945            ..input(Vec::new())
946        };
947        assert_eq!(decide(&later, 97, &mut state, now).len(), 1);
948    }
949
950    // — State persistence ———————————————————————————
951
952    #[test]
953    fn state_round_trips_through_disk_and_tolerates_corruption() {
954        let (dir, path) = state_path();
955        let mut state = NotifyState::default();
956        state.entries.insert(
957            "anthropic::Weekly (7d)".into(),
958            KeyRecord {
959                notified_at: at(23, 12, 0),
960                reset_at_snapshot: Some(at(30, 0, 0)),
961            },
962        );
963        state.write_to(&path).unwrap();
964        assert_eq!(NotifyState::read_from(&path), state);
965
966        std::fs::write(&path, "{ not json").unwrap();
967        assert_eq!(NotifyState::read_from(&path), NotifyState::default());
968        drop(dir);
969    }
970
971    #[test]
972    #[cfg(unix)]
973    fn state_file_is_mode_0600() {
974        use std::os::unix::fs::PermissionsExt;
975        let (dir, path) = state_path();
976        NotifyState::default().write_to(&path).unwrap();
977        let mode = std::fs::metadata(&path).unwrap().permissions().mode();
978        assert_eq!(mode & 0o777, 0o600);
979        drop(dir);
980    }
981
982    #[test]
983    fn rearm_survives_a_simulated_restart() {
984        let (dir, path) = state_path();
985        let now = at(23, 12, 0);
986        let mut state = NotifyState::read_from(&path);
987
988        let high = input(vec![row("Weekly (7d)", 97, None)]);
989        let low = input(vec![row("Weekly (7d)", 89, None)]);
990        assert_eq!(decide(&high, 97, &mut state, now).len(), 1);
991        state.write_to(&path).unwrap();
992
993        // New process: same crossing persists.
994        let mut state = NotifyState::read_from(&path);
995        assert_eq!(decide(&high, 97, &mut state, now).len(), 0);
996
997        // New process: usage drops below the band, mark cleared and written.
998        decide(&low, 97, &mut state, now);
999        state.write_to(&path).unwrap();
1000
1001        // New process: the crossing fires again.
1002        let mut state = NotifyState::read_from(&path);
1003        assert_eq!(decide(&high, 97, &mut state, now).len(), 1);
1004        drop(dir);
1005    }
1006
1007    // — Orchestration ————————————————————————————————
1008
1009    #[test]
1010    fn run_at_persists_marks_and_delivers_through_the_sink() {
1011        let (dir, path) = state_path();
1012        let mut sink = RecorderSink::default();
1013        let input = input(vec![row("Weekly (7d)", 97, None)]);
1014        run_at(
1015            &input,
1016            97,
1017            &path,
1018            Duration::from_secs(2),
1019            &mut sink,
1020            at(23, 12, 0),
1021        );
1022        assert_eq!(sink.delivered.len(), 1);
1023
1024        // Second run with a fresh sink: marked, nothing delivered.
1025        let mut second = RecorderSink::default();
1026        run_at(
1027            &input,
1028            97,
1029            &path,
1030            Duration::from_secs(2),
1031            &mut second,
1032            at(23, 12, 0),
1033        );
1034        assert!(second.delivered.is_empty());
1035        assert!(path.exists());
1036        drop(dir);
1037    }
1038
1039    #[test]
1040    fn lock_contention_skips_notifying_entirely() {
1041        let (dir, path) = state_path();
1042        let lock_path = path.with_file_name(".notifications.lock");
1043        let _held = crate::cache::acquire_lock(&lock_path, Duration::from_secs(5)).unwrap();
1044
1045        let mut sink = RecorderSink::default();
1046        let input = input(vec![row("Weekly (7d)", 100, None)]);
1047        run_at(
1048            &input,
1049            97,
1050            &path,
1051            Duration::from_millis(100),
1052            &mut sink,
1053            at(23, 12, 0),
1054        );
1055        assert!(
1056            sink.delivered.is_empty(),
1057            "a missed notification beats a double one"
1058        );
1059        assert!(!path.exists(), "no state without the lock");
1060        drop(dir);
1061    }
1062
1063    #[test]
1064    fn an_unwritable_state_file_skips_delivery_rather_than_repeating() {
1065        let dir = tempfile::TempDir::new().unwrap();
1066        // A directory where the state file should be makes the atomic write
1067        // fail: persisting the mark is a precondition of delivering.
1068        let state_path = dir.path().join("notifications.json");
1069        std::fs::create_dir(&state_path).unwrap();
1070        let mut sink = RecorderSink::default();
1071        let input = input(vec![row("Weekly (7d)", 97, None)]);
1072        run_at(
1073            &input,
1074            97,
1075            &state_path,
1076            Duration::from_secs(2),
1077            &mut sink,
1078            at(23, 12, 0),
1079        );
1080        assert!(sink.delivered.is_empty());
1081    }
1082
1083    /// The guard test for the whole feature: a notifier that cannot even be
1084    /// spawned must not make the check error, panic, or skip the state
1085    /// bookkeeping. `run_at` has no failure channel at all — this pins that.
1086    #[test]
1087    #[cfg(target_os = "linux")]
1088    fn a_missing_notifier_binary_is_a_silent_no_op() {
1089        let (dir, path) = state_path();
1090        let mut sink = NotifySendSink::with_program("/nonexistent/notify-send");
1091        let input = input(vec![row("Weekly (7d)", 97, None)]);
1092        run_at(
1093            &input,
1094            97,
1095            &path,
1096            Duration::from_secs(2),
1097            &mut sink,
1098            at(23, 12, 0),
1099        );
1100        assert!(
1101            path.exists(),
1102            "state bookkeeping is independent of delivery"
1103        );
1104        assert!(
1105            NotifyState::read_from(&path).is_notified("anthropic@gmail::Weekly (7d)"),
1106            "the crossing is marked even though nothing was delivered"
1107        );
1108        drop(dir);
1109    }
1110
1111    #[test]
1112    fn noop_sink_records_nothing_and_never_fails() {
1113        let (dir, path) = state_path();
1114        let mut sink = NoopSink;
1115        let input = input(vec![row("Weekly (7d)", 97, None)]);
1116        run_at(
1117            &input,
1118            97,
1119            &path,
1120            Duration::from_secs(2),
1121            &mut sink,
1122            at(23, 12, 0),
1123        );
1124        assert!(path.exists());
1125        drop(dir);
1126    }
1127
1128    // — Projection from the shared assembly ——————————
1129
1130    #[test]
1131    fn from_tab_projects_rows_account_and_credits() {
1132        use crate::tui::app::{ReadyTab, TabId, TabState};
1133        use crate::usage::{
1134            AnthropicSnapshot, ResetCredit, ResetCredits, UsageWindow, VendorSnapshot,
1135        };
1136
1137        let reset = at(24, 2, 0);
1138        let state = TabState::Ready(Box::new(ReadyTab {
1139            snapshot: VendorSnapshot::Anthropic(AnthropicSnapshot {
1140                plan: "Claude Max 20x".into(),
1141                session: UsageWindow {
1142                    utilization_pct: 98,
1143                    resets_at: Some(reset),
1144                    window_duration: chrono::Duration::hours(5),
1145                },
1146                weekly: UsageWindow {
1147                    utilization_pct: 42,
1148                    resets_at: None,
1149                    window_duration: chrono::Duration::days(7),
1150                },
1151                sonnet: None,
1152                scoped: vec![],
1153                extra: None,
1154                // Claude banks resets too, and the expiry warning is the
1155                // whole point of carrying them: a grant the sidebar lists but
1156                // the notifier ignores is a half-delivered feature.
1157                reset_credits: ResetCredits {
1158                    available: 1,
1159                    credits: vec![ResetCredit {
1160                        title: Some("Opus 5.5 launch reset".into()),
1161                        expires_at: Some(at(25, 0, 0)),
1162                    }],
1163                },
1164            }),
1165            stale: false,
1166            last_error: None,
1167            fetched_at: None,
1168            display: Default::default(),
1169        }));
1170
1171        let tab = TabId::account("gmail");
1172        let projected =
1173            RefreshInput::from_tab(&tab, &state, at(23, 12, 0)).expect("ready tab projects");
1174        assert_eq!(projected.entry_id, "anthropic@gmail");
1175        assert_eq!(projected.vendor, "Claude");
1176        assert_eq!(projected.account.as_deref(), Some("gmail"));
1177        assert_eq!(
1178            projected.rows,
1179            vec![
1180                row("Session (5h)", 98, Some(reset)),
1181                row("Weekly (7d)", 42, None),
1182            ]
1183        );
1184        assert_eq!(
1185            projected.credits,
1186            vec![CreditExpiry {
1187                title: Some("Opus 5.5 launch reset".into()),
1188                expires_at: at(25, 0, 0),
1189            }]
1190        );
1191
1192        // A Codex snapshot carries its banked credits through.
1193        let state = TabState::Ready(Box::new(ReadyTab {
1194            snapshot: VendorSnapshot::Openai(crate::usage::OpenAiSnapshot {
1195                plan: "ChatGPT Pro".into(),
1196                session: None,
1197                weekly: None,
1198                code_review: None,
1199                additional_limits: Vec::new(),
1200                unavailable_models: Vec::new(),
1201                credits: None,
1202                reset_credits: ResetCredits {
1203                    available: 2,
1204                    credits: vec![ResetCredit {
1205                        title: Some("Full reset".into()),
1206                        expires_at: Some(at(25, 0, 0)),
1207                    }],
1208                },
1209                source: crate::usage::OpenAiSource::CodexOauth,
1210            }),
1211            stale: false,
1212            last_error: None,
1213            fetched_at: None,
1214            display: Default::default(),
1215        }));
1216        let projected = RefreshInput::from_tab(
1217            &TabId::vendor(crate::vendor::VendorId::Openai),
1218            &state,
1219            at(23, 12, 0),
1220        )
1221        .expect("ready tab projects");
1222        assert_eq!(projected.entry_id, "openai");
1223        assert_eq!(
1224            projected.credits,
1225            vec![CreditExpiry {
1226                title: Some("Full reset".into()),
1227                expires_at: at(25, 0, 0),
1228            }]
1229        );
1230    }
1231
1232    #[test]
1233    fn from_tab_skips_grouped_slices_and_unavailable_credits() {
1234        use crate::tui::app::{ReadyTab, TabId, TabState};
1235        use crate::usage::{
1236            ResetCredit, ResetCredits, SuperGrokPeriod, SuperGrokProduct, SuperGrokSnapshot,
1237            VendorSnapshot,
1238        };
1239
1240        let state = TabState::Ready(Box::new(ReadyTab {
1241            snapshot: VendorSnapshot::SuperGrok(SuperGrokSnapshot {
1242                plan: "SuperGrok Heavy".into(),
1243                account: "scope".into(),
1244                weekly_pct: 97,
1245                period: SuperGrokPeriod::Weekly,
1246                reset_at: Some(at(26, 0, 0)),
1247                prepaid_balance: None,
1248                // available == 0: the report would not surface these either.
1249                reset_credits: ResetCredits {
1250                    available: 0,
1251                    credits: vec![ResetCredit {
1252                        title: None,
1253                        expires_at: Some(at(25, 0, 0)),
1254                    }],
1255                },
1256                products: vec![SuperGrokProduct {
1257                    label: "Grok Build".into(),
1258                    percent: 94,
1259                }],
1260            }),
1261            stale: false,
1262            last_error: None,
1263            fetched_at: None,
1264            display: Default::default(),
1265        }));
1266        let projected = RefreshInput::from_tab(
1267            &TabId::vendor(crate::vendor::VendorId::Supergrok),
1268            &state,
1269            at(23, 12, 0),
1270        )
1271        .expect("ready tab projects");
1272        // Only the overall meter — the grouped Grok Build slice shares its
1273        // window and must not raise a second notification.
1274        assert_eq!(projected.rows.len(), 1, "{:?}", projected.rows);
1275        assert!(projected.credits.is_empty());
1276    }
1277
1278    fn antigravity_ready(
1279        session: i32,
1280        third_party_session: i32,
1281        weekly: i32,
1282        third_party_weekly: i32,
1283    ) -> crate::tui::app::TabState {
1284        use crate::tui::app::{ReadyTab, TabState};
1285        use crate::usage::{AntigravitySnapshot, AntigravitySource, UsageWindow, VendorSnapshot};
1286
1287        let window = |pct: i32, resets_at: DateTime<Utc>, hours: i64| {
1288            Some(UsageWindow {
1289                utilization_pct: pct,
1290                resets_at: Some(resets_at),
1291                window_duration: chrono::Duration::hours(hours),
1292            })
1293        };
1294        TabState::Ready(Box::new(ReadyTab {
1295            snapshot: VendorSnapshot::Antigravity(AntigravitySnapshot {
1296                plan: "Google AI Pro".into(),
1297                account: "acct:test".into(),
1298                source: AntigravitySource::Local,
1299                session: window(session, at(24, 2, 21), 5),
1300                weekly: window(weekly, at(26, 1, 34), 168),
1301                third_party_session: window(third_party_session, at(24, 2, 19), 5),
1302                third_party_weekly: window(third_party_weekly, at(30, 19, 19), 168),
1303            }),
1304            stale: false,
1305            last_error: None,
1306            fetched_at: None,
1307            display: Default::default(),
1308        }))
1309    }
1310
1311    /// Antigravity lays "Session" and "Weekly" headings over the same two
1312    /// pool labels; each window needs its own name, or the four rows share
1313    /// two dedupe keys.
1314    #[test]
1315    fn from_tab_names_repeated_labels_by_their_heading() {
1316        use crate::tui::app::TabId;
1317
1318        let projected = RefreshInput::from_tab(
1319            &TabId::vendor(crate::vendor::VendorId::Antigravity),
1320            &antigravity_ready(3, 100, 98, 56),
1321            at(23, 22, 0),
1322        )
1323        .expect("ready tab projects");
1324        let windows: Vec<_> = projected
1325            .rows
1326            .iter()
1327            .map(|row| row.window.as_str())
1328            .collect();
1329        assert_eq!(
1330            windows,
1331            [
1332                "Session · Gemini",
1333                "Session · Claude & GPT OSS",
1334                "Weekly · Gemini",
1335                "Weekly · Claude & GPT OSS",
1336            ]
1337        );
1338    }
1339
1340    /// The reported bug: a weekly Gemini window at 98% beside a session one at
1341    /// 3%, and a session Claude window at 100% beside a weekly one at 56%. Under
1342    /// one key per label, the low twin cleared the record its high twin had
1343    /// just written, so both notifications fired again on every refresh.
1344    #[test]
1345    fn a_window_whose_label_repeats_notifies_once() {
1346        use crate::tui::app::TabId;
1347
1348        let tab = TabId::vendor(crate::vendor::VendorId::Antigravity);
1349        let state = antigravity_ready(3, 100, 98, 56);
1350        let mut notify_state = NotifyState::default();
1351        let fired: Vec<Vec<String>> = (0..4)
1352            .map(|refresh| {
1353                let now = at(23, 22, 0) + chrono::Duration::minutes(5 * refresh);
1354                let input = RefreshInput::from_tab(&tab, &state, now).expect("ready tab projects");
1355                decide(&input, 97, &mut notify_state, now)
1356                    .into_iter()
1357                    .map(|notification| notification.title)
1358                    .collect()
1359            })
1360            .collect();
1361        assert_eq!(
1362            fired[0],
1363            [
1364                "Antigravity — Session · Claude & GPT OSS at 100%",
1365                "Antigravity — Weekly · Gemini at 98%",
1366            ]
1367        );
1368        assert!(
1369            fired[1..].iter().all(Vec::is_empty),
1370            "later refreshes must stay quiet: {fired:?}"
1371        );
1372    }
1373
1374    /// A window's identity must not depend on whether its sibling arrived.
1375    #[test]
1376    fn a_window_keeps_its_key_when_its_sibling_disappears_or_returns() {
1377        use crate::tui::app::{TabId, TabState};
1378        use crate::usage::VendorSnapshot;
1379
1380        let tab = TabId::vendor(crate::vendor::VendorId::Antigravity);
1381        for starts_with_session in [false, true] {
1382            let mut notify_state = NotifyState::default();
1383            // The unchanged weekly quota fires only once, regardless of
1384            // which snapshot shape arrived first. A low reading while the
1385            // session is absent must also re-arm that same weekly key.
1386            let steps = [
1387                (starts_with_session, 98, 1),
1388                (!starts_with_session, 98, 0),
1389                (starts_with_session, 98, 0),
1390                (false, 20, 0),
1391                (true, 98, 1),
1392                (false, 98, 0),
1393            ];
1394            for (refresh, (has_session, weekly_pct, expected)) in steps.into_iter().enumerate() {
1395                let mut state = antigravity_ready(3, 10, weekly_pct, 56);
1396                let TabState::Ready(ready) = &mut state else {
1397                    unreachable!();
1398                };
1399                let VendorSnapshot::Antigravity(snapshot) = &mut ready.snapshot else {
1400                    unreachable!();
1401                };
1402                if !has_session {
1403                    snapshot.session = None;
1404                    snapshot.third_party_session = None;
1405                }
1406                let now = at(23, 22, 0) + chrono::Duration::minutes(5 * refresh as i64);
1407                let input = RefreshInput::from_tab(&tab, &state, now).expect("ready tab projects");
1408                let fired = decide(&input, 97, &mut notify_state, now);
1409                assert_eq!(
1410                    fired.len(),
1411                    expected,
1412                    "starts_with_session={starts_with_session}, refresh={refresh}: {fired:?}"
1413                );
1414                if let Some(notification) = fired.first() {
1415                    assert_eq!(notification.key, "antigravity::Weekly · Gemini");
1416                }
1417            }
1418        }
1419    }
1420
1421    /// Heading context applies to unique labels too, survives spacers, and
1422    /// ends at a non-heading row. Unscoped metrics keep their bare names.
1423    #[test]
1424    fn metric_names_follow_heading_scope_even_for_unique_labels() {
1425        use crate::balance::MetricHeadline;
1426        use crate::pacing::PaceSeverity;
1427        use crate::tui::panels::{Section, SectionProjection};
1428
1429        let projection = |section| SectionProjection {
1430            section,
1431            reset_at: None,
1432            window: None,
1433            group: None,
1434            headline: MetricHeadline::Percent,
1435            used_cents: None,
1436            limit_cents: None,
1437        };
1438        let metric = |label: &str, pct| {
1439            projection(Section::Metric {
1440                label: label.into(),
1441                pct,
1442                severity: PaceSeverity::Low,
1443                value_label: format!("{pct}%"),
1444                footnote: String::new(),
1445            })
1446        };
1447        let heading = |label: &str| {
1448            projection(Section::Text {
1449                label: label.into(),
1450                value: String::new(),
1451            })
1452        };
1453        let rows = metric_rows([
1454            metric("Unscoped", 20),
1455            heading("Session"),
1456            projection(Section::Spacer),
1457            metric("General", 40),
1458            metric("Video", 10),
1459            projection(Section::Spacer),
1460            heading("Weekly"),
1461            metric("General", 60),
1462            projection(Section::Text {
1463                label: "Info".into(),
1464                value: "Details".into(),
1465            }),
1466            metric("Other", 30),
1467        ]);
1468        let windows: Vec<_> = rows.iter().map(|row| row.window.as_str()).collect();
1469        assert_eq!(
1470            windows,
1471            [
1472                "Unscoped",
1473                "Session · General",
1474                "Session · Video",
1475                "Weekly · General",
1476                "Other",
1477            ]
1478        );
1479    }
1480
1481    #[test]
1482    fn from_tab_returns_none_for_unready_states() {
1483        use crate::tui::app::{TabId, TabState};
1484        assert!(
1485            RefreshInput::from_tab(
1486                &TabId::vendor(crate::vendor::VendorId::Zai),
1487                &TabState::Loading,
1488                at(23, 12, 0)
1489            )
1490            .is_none()
1491        );
1492        assert!(
1493            RefreshInput::from_tab(
1494                &TabId::vendor(crate::vendor::VendorId::Zai),
1495                &TabState::error("not signed in"),
1496                at(23, 12, 0)
1497            )
1498            .is_none()
1499        );
1500    }
1501}