1use 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
38const HYSTERESIS_PCT: i32 = 7;
42
43const RESET_MOVE_TOLERANCE_SECS: i64 = 90 * 60;
54
55const CREDIT_WARNING_SECS: i64 = 48 * 3600;
57
58const LOCK_TIMEOUT: Duration = Duration::from_secs(2);
61
62#[cfg(any(target_os = "linux", target_os = "macos"))]
64const SPAWN_KILL_AFTER: Duration = Duration::from_secs(5);
65
66#[cfg(target_os = "linux")]
74const CRITICAL_TIMEOUT_MS: &str = "30000";
75
76#[derive(Debug, Clone, Copy, PartialEq, Eq)]
78pub enum Urgency {
79 Normal,
81 Critical,
83}
84
85#[cfg(target_os = "linux")]
86impl Urgency {
87 fn as_arg(self) -> &'static str {
88 match self {
89 Self::Normal => "normal",
90 Self::Critical => "critical",
91 }
92 }
93}
94
95#[derive(Debug, Clone, PartialEq, Eq)]
98pub struct Notification {
99 pub title: String,
100 pub body: String,
101 pub urgency: Urgency,
102 pub key: String,
103}
104
105#[derive(Debug, Clone, PartialEq, Eq)]
107pub struct MetricRow {
108 pub window: String,
111 pub percent: u16,
112 pub reset_at: Option<DateTime<Utc>>,
115}
116
117#[derive(Debug, Clone, PartialEq, Eq)]
120pub struct CreditExpiry {
121 pub title: Option<String>,
122 pub expires_at: DateTime<Utc>,
123}
124
125#[derive(Debug, Clone, PartialEq, Eq)]
127pub struct RefreshInput {
128 pub entry_id: String,
131 pub vendor: String,
134 pub account: Option<String>,
135 pub rows: Vec<MetricRow>,
136 pub credits: Vec<CreditExpiry>,
137}
138
139pub fn decide(
152 input: &RefreshInput,
153 threshold: u8,
154 state: &mut NotifyState,
155 now: DateTime<Utc>,
156) -> Vec<Notification> {
157 migrate_anthropic_api_spend_key(input, state);
158 let mut fired = Vec::new();
159 let threshold = threshold as i32;
160 let rearm_below = threshold - HYSTERESIS_PCT;
161 for row in &input.rows {
162 let key = format!("{}::{}", input.entry_id, row.window);
163 let pct = row.percent as i32;
164 if pct >= threshold {
165 let armed = match state.entries.get(&key) {
166 None => true,
167 Some(record) => reset_moved_later(row.reset_at, record.reset_at_snapshot),
168 };
169 if armed {
170 fired.push(threshold_notification(input, row, now));
171 state.entries.insert(
172 key,
173 KeyRecord {
174 notified_at: now,
175 reset_at_snapshot: row.reset_at,
176 },
177 );
178 }
179 } else if pct < rearm_below {
180 state.entries.remove(&key);
183 }
184 }
185 for credit in &input.credits {
186 let remaining = credit.expires_at.signed_duration_since(now).num_seconds();
187 if remaining <= 0 || remaining > CREDIT_WARNING_SECS {
188 continue;
189 }
190 let key = format!(
191 "{}::credit::{}",
192 input.entry_id,
193 credit
194 .expires_at
195 .to_rfc3339_opts(SecondsFormat::AutoSi, true)
196 );
197 if state.entries.contains_key(&key) {
198 continue;
199 }
200 fired.push(credit_notification(input, credit, now));
201 state.entries.insert(
202 key,
203 KeyRecord {
204 notified_at: now,
205 reset_at_snapshot: None,
206 },
207 );
208 }
209 fired
210}
211
212fn migrate_anthropic_api_spend_key(input: &RefreshInput, state: &mut NotifyState) {
216 if input.entry_id != "anthropic_api" && !input.entry_id.starts_with("anthropic_api@") {
217 return;
218 }
219 let prefix = format!("{}::", input.entry_id);
220 let stable = format!("{prefix}Spend (mo)");
221 let legacy: Vec<String> = state
222 .entries
223 .keys()
224 .filter(|key| {
225 let Some(label) = key.strip_prefix(&prefix) else {
226 return false;
227 };
228 label == "Completed daily buckets this month"
229 || (label.starts_with("Month to date (through ") && label.ends_with(')'))
230 })
231 .cloned()
232 .collect();
233 if legacy.is_empty() {
234 return;
235 }
236 let mut chosen = state.entries.get(&stable).cloned();
239 if chosen.is_none() {
240 chosen = legacy
241 .iter()
242 .filter_map(|key| state.entries.get(key).cloned())
243 .max_by_key(|record| record.notified_at);
244 }
245 for key in legacy {
246 state.entries.remove(&key);
247 }
248 if let Some(record) = chosen {
249 state.entries.insert(stable, record);
250 }
251}
252
253fn reset_moved_later(current: Option<DateTime<Utc>>, snapshot: Option<DateTime<Utc>>) -> bool {
258 match (current, snapshot) {
259 (Some(current), Some(snapshot)) => {
260 current.signed_duration_since(snapshot).num_seconds() > RESET_MOVE_TOLERANCE_SECS
261 }
262 (Some(_), None) => true,
263 _ => false,
264 }
265}
266
267fn threshold_notification(
268 input: &RefreshInput,
269 row: &MetricRow,
270 now: DateTime<Utc>,
271) -> Notification {
272 let who = match &input.account {
273 Some(account) => format!("{} · {}", input.vendor, account),
274 None => input.vendor.clone(),
275 };
276 let title = sanitize_untrusted_line(&format!("{} — {} at {}%", who, row.window, row.percent));
277 let mut body = format!("{}% of the {} used", row.percent, row.window);
278 if let Some(reset_at) = row.reset_at {
279 body.push_str(&format!(
280 " · resets {} ({})",
281 countdown::format(Some(reset_at), now),
282 format::local_time_hm(reset_at)
283 ));
284 }
285 Notification {
286 title,
287 body: sanitize_untrusted_line(&body),
288 urgency: if row.percent >= 100 {
289 Urgency::Critical
290 } else {
291 Urgency::Normal
292 },
293 key: format!("{}::{}", input.entry_id, row.window),
294 }
295}
296
297fn credit_notification(
298 input: &RefreshInput,
299 credit: &CreditExpiry,
300 now: DateTime<Utc>,
301) -> Notification {
302 let label = credit.title.as_deref().unwrap_or("Reset credit");
303 let title = sanitize_untrusted_line(&format!(
304 "{} — reset credit expires {}",
305 input.vendor,
306 format::local_date_hm(credit.expires_at)
307 ));
308 let body = sanitize_untrusted_line(&format!(
309 "{} — redeem within {} ({})",
310 label,
311 countdown::format(Some(credit.expires_at), now),
312 format::local_time_hm(credit.expires_at)
313 ));
314 Notification {
315 title,
316 body,
317 urgency: Urgency::Normal,
318 key: format!(
319 "{}::credit::{}",
320 input.entry_id,
321 credit
322 .expires_at
323 .to_rfc3339_opts(SecondsFormat::AutoSi, true)
324 ),
325 }
326}
327
328#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
335#[serde(transparent)]
336pub struct NotifyState {
337 entries: BTreeMap<String, KeyRecord>,
338}
339
340#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
341struct KeyRecord {
342 notified_at: DateTime<Utc>,
343 reset_at_snapshot: Option<DateTime<Utc>>,
344}
345
346impl NotifyState {
347 #[cfg(test)]
348 fn is_notified(&self, key: &str) -> bool {
349 self.entries.contains_key(key)
350 }
351
352 fn read_from(path: &Path) -> Self {
355 let Ok(raw) = std::fs::read_to_string(path) else {
356 return Self::default();
357 };
358 serde_json::from_str(&raw).unwrap_or_default()
359 }
360
361 fn write_to(&self, path: &Path) -> crate::error::Result<()> {
364 let bytes = serde_json::to_vec(&self.entries)?;
365 crate::cache::atomic_write(path, &bytes)?;
366 #[cfg(unix)]
367 {
368 use std::os::unix::fs::PermissionsExt;
369 if let Ok(meta) = std::fs::metadata(path) {
370 let mut perms = meta.permissions();
371 perms.set_mode(0o600);
372 let _ = std::fs::set_permissions(path, perms);
373 }
374 }
375 Ok(())
376 }
377}
378
379pub trait NotifySink {
387 fn deliver(&mut self, notification: &Notification);
388}
389
390#[cfg(any(not(any(target_os = "linux", target_os = "macos")), test))]
392#[derive(Debug, Default)]
393pub struct NoopSink;
394
395#[cfg(any(not(any(target_os = "linux", target_os = "macos")), test))]
396impl NotifySink for NoopSink {
397 fn deliver(&mut self, _notification: &Notification) {}
398}
399
400#[cfg(target_os = "linux")]
405#[derive(Debug, Clone)]
406pub struct NotifySendSink {
407 program: String,
408}
409
410#[cfg(target_os = "linux")]
411impl Default for NotifySendSink {
412 fn default() -> Self {
413 Self::new()
414 }
415}
416
417#[cfg(target_os = "linux")]
418impl NotifySendSink {
419 pub fn new() -> Self {
420 Self {
421 program: "notify-send".to_string(),
422 }
423 }
424
425 pub fn with_program(program: impl Into<String>) -> Self {
428 Self {
429 program: program.into(),
430 }
431 }
432
433 fn argv(&self, notification: &Notification) -> Vec<String> {
438 let mut args = vec![
439 "-a".to_string(),
440 "ai-usagebar".to_string(),
441 "-c".to_string(),
442 "quota".to_string(),
443 "-u".to_string(),
444 notification.urgency.as_arg().to_string(),
445 ];
446 if notification.urgency == Urgency::Critical {
447 args.push("-t".to_string());
448 args.push(CRITICAL_TIMEOUT_MS.to_string());
449 }
450 args.push(notification.title.clone());
451 args.push(notification.body.clone());
452 args
453 }
454}
455
456#[cfg(target_os = "linux")]
457impl NotifySink for NotifySendSink {
458 fn deliver(&mut self, notification: &Notification) {
459 let spawned = std::process::Command::new(&self.program)
460 .args(self.argv(notification))
461 .stdin(std::process::Stdio::null())
462 .stdout(std::process::Stdio::null())
463 .stderr(std::process::Stdio::null())
464 .spawn();
465 if let Ok(child) = spawned {
466 reap(child);
467 }
468 }
469}
470
471#[cfg(any(target_os = "linux", target_os = "macos"))]
474fn reap(mut child: std::process::Child) {
475 let deadline = std::time::Instant::now() + SPAWN_KILL_AFTER;
476 loop {
477 match child.try_wait() {
478 Ok(Some(_)) => return,
479 Ok(None) => {}
480 Err(_) => return,
481 }
482 if std::time::Instant::now() >= deadline {
483 let _ = child.kill();
484 let _ = child.wait();
485 return;
486 }
487 std::thread::sleep(Duration::from_millis(25));
488 }
489}
490
491fn production_sink() -> Box<dyn NotifySink + Send> {
492 #[cfg(target_os = "linux")]
493 return Box::new(NotifySendSink::new());
494 #[cfg(target_os = "macos")]
495 return Box::new(MacNotificationSink);
496 #[cfg(not(any(target_os = "linux", target_os = "macos")))]
497 return Box::new(NoopSink);
498}
499
500#[cfg(target_os = "macos")]
503struct MacNotificationSink;
504
505#[cfg(target_os = "macos")]
506impl NotifySink for MacNotificationSink {
507 fn deliver(&mut self, notification: &Notification) {
508 const SCRIPT: &str = "on run argv\n display notification (item 2 of argv) with title (item 1 of argv)\nend run";
509 let spawned = std::process::Command::new("/usr/bin/osascript")
510 .arg("-e")
511 .arg(SCRIPT)
512 .arg("--")
513 .arg(¬ification.title)
514 .arg(¬ification.body)
515 .stdin(std::process::Stdio::null())
516 .stdout(std::process::Stdio::null())
517 .stderr(std::process::Stdio::null())
518 .spawn();
519 if let Ok(child) = spawned {
520 reap(child);
521 }
522 }
523}
524
525pub async fn run(input: RefreshInput, threshold: u8) {
530 let Some(state_path) = default_state_path() else {
531 return;
532 };
533 let joined = tokio::task::spawn_blocking(move || {
536 let mut sink = production_sink();
537 run_at(
538 &input,
539 threshold,
540 &state_path,
541 LOCK_TIMEOUT,
542 sink.as_mut(),
543 Utc::now(),
544 );
545 });
546 let _ = joined.await;
547}
548
549pub fn default_state_path() -> Option<PathBuf> {
552 crate::cache::xdg_cache_dir()
553 .ok()
554 .map(|base| base.join("ai-usagebar").join("notifications.json"))
555}
556
557pub fn run_at(
563 input: &RefreshInput,
564 threshold: u8,
565 state_path: &Path,
566 lock_timeout: Duration,
567 sink: &mut dyn NotifySink,
568 now: DateTime<Utc>,
569) {
570 let lock_path = state_path.with_file_name(".notifications.lock");
571 let Ok(_guard) = crate::cache::acquire_lock(&lock_path, lock_timeout) else {
572 return;
573 };
574 let mut state = NotifyState::read_from(state_path);
575 let before = serde_json::to_string(&state.entries).unwrap_or_default();
576 let fired = decide(input, threshold, &mut state, now);
577 if serde_json::to_string(&state.entries).unwrap_or_default() != before
578 && state.write_to(state_path).is_err()
579 {
580 return;
583 }
584 for notification in &fired {
585 sink.deliver(notification);
586 }
587}
588
589impl RefreshInput {
592 pub(crate) fn from_tab(
599 tab: &crate::tui::app::TabId,
600 state: &crate::tui::app::TabState,
601 now: DateTime<Utc>,
602 ) -> Option<Self> {
603 use crate::tui::app::{TabSource, TabState};
604 use crate::tui::panels::sections_with_metadata_for;
605
606 let TabState::Ready(ready) = state else {
607 return None;
608 };
609 let (vendor, entry_id) = match &tab.source {
610 TabSource::Builtin(vendor) => (
611 vendor.display_name().to_string(),
612 crate::report::tab_id(tab),
613 ),
614 TabSource::Custom { name, .. } => (name.clone(), crate::report::tab_id(tab)),
615 };
616 let rows = metric_rows(sections_with_metadata_for(state, now, 0));
619 let credits = ready
620 .snapshot
621 .reset_credits()
622 .filter(|credits| credits.available > 0)
623 .map(|credits| {
624 credits
625 .credits
626 .iter()
627 .filter_map(|credit| {
628 credit.expires_at.map(|expires_at| CreditExpiry {
629 title: credit.title.clone(),
630 expires_at,
631 })
632 })
633 .collect()
634 })
635 .unwrap_or_default();
636 Some(Self {
637 entry_id,
638 vendor,
639 account: tab.account.clone(),
640 rows,
641 credits,
642 })
643 }
644}
645
646fn metric_rows(
658 sections: impl IntoIterator<Item = crate::tui::panels::SectionProjection>,
659) -> Vec<MetricRow> {
660 use crate::tui::panels::Section;
661
662 let mut heading: Option<String> = None;
665 let mut rows = Vec::new();
666 for projected in sections {
667 if projected.group.is_some() {
668 continue;
669 }
670 match projected.section {
671 Section::Text { label, value } if value.is_empty() => heading = Some(label),
672 Section::Metric { label, pct, .. } => {
673 let window = match &heading {
674 Some(heading) => format!("{heading} · {label}"),
675 None => label,
676 };
677 rows.push(MetricRow {
678 window,
679 percent: pct,
680 reset_at: projected.reset_at,
681 });
682 }
683 Section::Spacer => {}
684 _ => heading = None,
685 }
686 }
687 rows
688}
689
690#[cfg(test)]
691mod tests {
692 use super::*;
693 use chrono::TimeZone;
694 use tempfile::TempDir;
695
696 fn at(day: u32, hour: u32, min: u32) -> DateTime<Utc> {
697 Utc.with_ymd_and_hms(2026, 9, day, hour, min, 0).unwrap()
698 }
699
700 fn row(window: &str, percent: u16, reset_at: Option<DateTime<Utc>>) -> MetricRow {
701 MetricRow {
702 window: window.to_string(),
703 percent,
704 reset_at,
705 }
706 }
707
708 fn input(rows: Vec<MetricRow>) -> RefreshInput {
709 RefreshInput {
710 entry_id: "anthropic@gmail".into(),
711 vendor: "Claude".into(),
712 account: Some("gmail".into()),
713 rows,
714 credits: Vec::new(),
715 }
716 }
717
718 #[derive(Default)]
720 struct RecorderSink {
721 delivered: Vec<Notification>,
722 }
723
724 impl NotifySink for RecorderSink {
725 fn deliver(&mut self, notification: &Notification) {
726 self.delivered.push(notification.clone());
727 }
728 }
729
730 fn state_path() -> (TempDir, PathBuf) {
731 crate::cache::closed_temp_file("notifications.json", None)
732 }
733
734 #[test]
737 fn crossing_at_exactly_the_threshold_fires_normal() {
738 let mut state = NotifyState::default();
739 let now = at(23, 12, 0);
740 let reset = at(24, 12, 0);
741 let fired = decide(
742 &input(vec![row("Session (5h)", 97, Some(reset))]),
743 97,
744 &mut state,
745 now,
746 );
747 assert_eq!(fired.len(), 1);
748 assert_eq!(fired[0].urgency, Urgency::Normal);
749 assert_eq!(fired[0].key, "anthropic@gmail::Session (5h)");
750 assert_eq!(fired[0].title, "Claude · gmail — Session (5h) at 97%");
751 assert_eq!(
755 fired[0].body,
756 format!(
757 "97% of the Session (5h) used · resets 1d 0h ({})",
758 format::local_time_hm(reset)
759 )
760 );
761 }
762
763 #[test]
764 fn below_the_threshold_fires_nothing() {
765 let mut state = NotifyState::default();
766 let fired = decide(
767 &input(vec![row("Session (5h)", 96, None)]),
768 97,
769 &mut state,
770 at(23, 12, 0),
771 );
772 assert!(fired.is_empty());
773 assert!(state.entries.is_empty());
774 }
775
776 #[test]
777 fn exhausted_windows_fire_critical() {
778 let mut state = NotifyState::default();
779 let fired = decide(
780 &input(vec![row("Weekly (7d)", 100, None)]),
781 97,
782 &mut state,
783 at(23, 12, 0),
784 );
785 assert_eq!(fired.len(), 1);
786 assert_eq!(fired[0].urgency, Urgency::Critical);
787 assert_eq!(fired[0].title, "Claude · gmail — Weekly (7d) at 100%");
788 }
789
790 #[test]
791 fn the_same_crossing_never_fires_twice() {
792 let mut state = NotifyState::default();
793 let high = input(vec![row("Weekly (7d)", 98, Some(at(30, 0, 0)))]);
794 let now = at(23, 12, 0);
795 assert_eq!(decide(&high, 97, &mut state, now).len(), 1);
796 assert_eq!(decide(&high, 97, &mut state, now).len(), 0);
797 assert_eq!(decide(&high, 97, &mut state, now).len(), 0);
798 }
799
800 #[test]
803 fn hysteresis_rearms_only_below_threshold_minus_seven() {
804 let mut state = NotifyState::default();
805 let now = at(23, 12, 0);
806 let fires_at = |pct: u16, state: &mut NotifyState| {
807 let case = input(vec![row("Weekly (7d)", pct, None)]);
808 decide(&case, 97, state, now).len()
809 };
810
811 assert_eq!(fires_at(97, &mut state), 1, "first crossing fires");
812 assert_eq!(fires_at(96, &mut state), 0, "hovering does not re-fire");
813 assert_eq!(fires_at(90, &mut state), 0);
815 assert!(state.is_notified("anthropic@gmail::Weekly (7d)"));
816 assert_eq!(fires_at(89, &mut state), 0);
818 assert!(!state.is_notified("anthropic@gmail::Weekly (7d)"));
819 assert_eq!(fires_at(97, &mut state), 1, "re-armed crossing fires");
821 }
822
823 #[test]
824 fn a_jittered_reset_instant_is_the_same_window() {
825 let mut state = NotifyState::default();
830 let now = at(23, 12, 0);
831 let reset = at(23, 17, 0);
832 let first = input(vec![row("Session (5h)", 100, Some(reset))]);
833 assert_eq!(decide(&first, 97, &mut state, now).len(), 1);
834
835 for drift in [
836 chrono::Duration::milliseconds(488),
837 chrono::Duration::milliseconds(700),
838 chrono::Duration::seconds(59),
839 ] {
840 let jittered = input(vec![row("Session (5h)", 100, Some(reset + drift))]);
841 assert_eq!(
842 decide(&jittered, 97, &mut state, now).len(),
843 0,
844 "{drift:?} later is the same window"
845 );
846 }
847
848 let next_window = input(vec![row(
850 "Session (5h)",
851 100,
852 Some(reset + chrono::Duration::hours(5)),
853 )]);
854 assert_eq!(decide(&next_window, 97, &mut state, now).len(), 1);
855 }
856
857 #[test]
858 fn a_later_reset_rearms_the_key() {
859 let mut state = NotifyState::default();
860 let now = at(23, 12, 0);
861 let first = input(vec![row("Session (5h)", 98, Some(at(23, 17, 0)))]);
862 assert_eq!(decide(&first, 97, &mut state, now).len(), 1);
863
864 assert_eq!(decide(&first, 97, &mut state, now).len(), 0);
866 let earlier = input(vec![row("Session (5h)", 98, Some(at(23, 16, 0)))]);
868 assert_eq!(decide(&earlier, 97, &mut state, now).len(), 0);
869 let absent = input(vec![row("Session (5h)", 98, None)]);
870 assert_eq!(decide(&absent, 97, &mut state, now).len(), 0);
871 let next_window = input(vec![row("Session (5h)", 98, Some(at(24, 2, 0)))]);
873 assert_eq!(decide(&next_window, 97, &mut state, now).len(), 1);
874 }
875
876 #[test]
877 fn a_reset_appearing_where_none_was_reported_rearms() {
878 let mut state = NotifyState::default();
879 let now = at(23, 12, 0);
880 let without = input(vec![row("Weekly quota", 99, None)]);
881 assert_eq!(decide(&without, 97, &mut state, now).len(), 1);
882 assert_eq!(decide(&without, 97, &mut state, now).len(), 0);
883 let with = input(vec![row("Weekly quota", 99, Some(at(30, 0, 0)))]);
884 assert_eq!(
885 decide(&with, 97, &mut state, now).len(),
886 1,
887 "the vendor starting to report a reset is a new window"
888 );
889 }
890
891 #[test]
894 fn absent_reset_at_omits_the_eta_from_the_body() {
895 let mut state = NotifyState::default();
896 let fired = decide(
897 &input(vec![row("Weekly (7d)", 97, None)]),
898 97,
899 &mut state,
900 at(23, 12, 0),
901 );
902 assert_eq!(fired[0].body, "97% of the Weekly (7d) used");
903 assert!(!fired[0].body.contains("resets"));
904 }
905
906 #[test]
907 fn account_label_is_omitted_from_the_title_when_absent() {
908 let mut state = NotifyState::default();
909 let mut plain = input(vec![row("Weekly (7d)", 97, None)]);
910 plain.entry_id = "zai".into();
911 plain.vendor = "Z.AI".into();
912 plain.account = None;
913 let fired = decide(&plain, 97, &mut state, at(23, 12, 0));
914 assert_eq!(fired[0].title, "Z.AI — Weekly (7d) at 97%");
915 }
916
917 #[test]
918 fn untrusted_labels_are_sanitized_at_the_subprocess_boundary() {
919 let mut state = NotifyState::default();
920 let mut hostile = input(vec![row("W\u{1b}[31m", 97, None)]);
921 hostile.account = Some("gmail\n\u{202E}evil".into());
922 let fired = decide(&hostile, 97, &mut state, at(23, 12, 0));
923 assert!(
924 !fired[0].title.chars().any(|c| c.is_control()),
925 "{}",
926 fired[0].title
927 );
928 assert!(!fired[0].title.contains('\u{202E}'), "{}", fired[0].title);
929 assert!(!fired[0].body.contains('\u{1B}'), "{}", fired[0].body);
930 }
931
932 #[test]
935 fn banked_credit_fires_only_inside_the_48h_window() {
936 let now = at(23, 12, 0);
937 for (offset_secs, expected) in [(47 * 3600, 1), (48 * 3600, 1), (48 * 3600 + 1, 0), (-1, 0)]
938 {
939 let mut state = NotifyState::default();
940 let case = RefreshInput {
941 credits: vec![CreditExpiry {
942 title: Some("Full reset".into()),
943 expires_at: now + chrono::Duration::seconds(offset_secs),
944 }],
945 ..input(Vec::new())
946 };
947 let fired = decide(&case, 97, &mut state, now);
948 assert_eq!(fired.len(), expected, "offset {offset_secs}s");
949 }
950 }
951
952 #[test]
953 fn banked_credit_title_and_body_name_the_expiry() {
954 let now = at(23, 12, 0);
955 let mut state = NotifyState::default();
956 let case = RefreshInput {
957 credits: vec![CreditExpiry {
958 title: Some("Full reset (Weekly + 5 hr)".into()),
959 expires_at: now + chrono::Duration::hours(3),
960 }],
961 ..input(Vec::new())
962 };
963 let fired = decide(&case, 97, &mut state, now);
964 assert_eq!(fired.len(), 1);
965 assert_eq!(fired[0].urgency, Urgency::Normal);
966 assert!(
967 fired[0].title.starts_with("Claude — reset credit expires "),
968 "{}",
969 fired[0].title
970 );
971 assert!(
972 fired[0]
973 .body
974 .starts_with("Full reset (Weekly + 5 hr) — redeem within 3h"),
975 "{}",
976 fired[0].body
977 );
978 }
979
980 #[test]
981 fn banked_credit_dedupes_on_the_expiry_instant() {
982 let now = at(23, 12, 0);
983 let mut state = NotifyState::default();
984 let expiry = now + chrono::Duration::hours(10);
985 let case = RefreshInput {
986 credits: vec![CreditExpiry {
987 title: None,
988 expires_at: expiry,
989 }],
990 ..input(Vec::new())
991 };
992 assert_eq!(decide(&case, 97, &mut state, now).len(), 1);
993 assert_eq!(
994 decide(&case, 97, &mut state, now).len(),
995 0,
996 "same expiry is one notification"
997 );
998 assert!(
999 state
1000 .entries
1001 .keys()
1002 .all(|key| key.starts_with("anthropic@gmail::credit::")),
1003 "{:?}",
1004 state.entries.keys().collect::<Vec<_>>()
1005 );
1006 let later = RefreshInput {
1008 credits: vec![CreditExpiry {
1009 title: None,
1010 expires_at: now + chrono::Duration::hours(20),
1011 }],
1012 ..input(Vec::new())
1013 };
1014 assert_eq!(decide(&later, 97, &mut state, now).len(), 1);
1015 }
1016
1017 #[test]
1020 fn state_round_trips_through_disk_and_tolerates_corruption() {
1021 let (dir, path) = state_path();
1022 let mut state = NotifyState::default();
1023 state.entries.insert(
1024 "anthropic::Weekly (7d)".into(),
1025 KeyRecord {
1026 notified_at: at(23, 12, 0),
1027 reset_at_snapshot: Some(at(30, 0, 0)),
1028 },
1029 );
1030 state.write_to(&path).unwrap();
1031 assert_eq!(NotifyState::read_from(&path), state);
1032
1033 std::fs::write(&path, "{ not json").unwrap();
1034 assert_eq!(NotifyState::read_from(&path), NotifyState::default());
1035 drop(dir);
1036 }
1037
1038 #[test]
1039 #[cfg(unix)]
1040 fn state_file_is_mode_0600() {
1041 use std::os::unix::fs::PermissionsExt;
1042 let (dir, path) = state_path();
1043 NotifyState::default().write_to(&path).unwrap();
1044 let mode = std::fs::metadata(&path).unwrap().permissions().mode();
1045 assert_eq!(mode & 0o777, 0o600);
1046 drop(dir);
1047 }
1048
1049 #[test]
1050 fn rearm_survives_a_simulated_restart() {
1051 let (dir, path) = state_path();
1052 let now = at(23, 12, 0);
1053 let mut state = NotifyState::read_from(&path);
1054
1055 let high = input(vec![row("Weekly (7d)", 97, None)]);
1056 let low = input(vec![row("Weekly (7d)", 89, None)]);
1057 assert_eq!(decide(&high, 97, &mut state, now).len(), 1);
1058 state.write_to(&path).unwrap();
1059
1060 let mut state = NotifyState::read_from(&path);
1062 assert_eq!(decide(&high, 97, &mut state, now).len(), 0);
1063
1064 decide(&low, 97, &mut state, now);
1066 state.write_to(&path).unwrap();
1067
1068 let mut state = NotifyState::read_from(&path);
1070 assert_eq!(decide(&high, 97, &mut state, now).len(), 1);
1071 drop(dir);
1072 }
1073
1074 #[test]
1075 fn anthropic_api_daily_notification_keys_migrate_without_refiring() {
1076 let (dir, path) = state_path();
1077 let old = at(17, 9, 0);
1078 let newest = at(18, 9, 0);
1079 let mut state = NotifyState::default();
1080 for (label, notified_at) in [
1081 ("Month to date (through Sep 17)", old),
1082 ("Month to date (through Sep 18)", newest),
1083 ("Completed daily buckets this month", old),
1084 ] {
1085 state.entries.insert(
1086 format!("anthropic_api::{label}"),
1087 KeyRecord {
1088 notified_at,
1089 reset_at_snapshot: None,
1090 },
1091 );
1092 }
1093 state.write_to(&path).unwrap();
1094 let input = RefreshInput {
1095 entry_id: "anthropic_api".into(),
1096 vendor: "Anthropic API".into(),
1097 account: None,
1098 rows: vec![row("Spend (mo)", 98, None)],
1099 credits: Vec::new(),
1100 };
1101 let mut sink = RecorderSink::default();
1102 run_at(
1103 &input,
1104 97,
1105 &path,
1106 Duration::from_secs(2),
1107 &mut sink,
1108 at(19, 9, 0),
1109 );
1110
1111 assert!(
1112 sink.delivered.is_empty(),
1113 "the existing crossing remains notified"
1114 );
1115 let migrated = NotifyState::read_from(&path);
1116 assert_eq!(migrated.entries.len(), 1);
1117 assert_eq!(
1118 migrated.entries["anthropic_api::Spend (mo)"].notified_at,
1119 newest
1120 );
1121 assert!(
1122 migrated
1123 .entries
1124 .keys()
1125 .all(|key| !key.contains("Month to date") && !key.contains("Completed daily"))
1126 );
1127 drop(dir);
1128 }
1129
1130 #[test]
1131 fn an_existing_stable_anthropic_api_notification_record_wins_migration() {
1132 let stable_at = at(16, 9, 0);
1133 let mut state = NotifyState::default();
1134 for (label, notified_at) in [
1135 ("Spend (mo)", stable_at),
1136 ("Month to date (through Sep 18)", at(18, 9, 0)),
1137 ] {
1138 state.entries.insert(
1139 format!("anthropic_api@work::{label}"),
1140 KeyRecord {
1141 notified_at,
1142 reset_at_snapshot: None,
1143 },
1144 );
1145 }
1146 let input = RefreshInput {
1147 entry_id: "anthropic_api@work".into(),
1148 vendor: "Anthropic API".into(),
1149 account: Some("work".into()),
1150 rows: Vec::new(),
1151 credits: Vec::new(),
1152 };
1153 assert!(decide(&input, 97, &mut state, at(19, 9, 0)).is_empty());
1154 assert_eq!(state.entries.len(), 1);
1155 assert_eq!(
1156 state.entries["anthropic_api@work::Spend (mo)"].notified_at,
1157 stable_at
1158 );
1159 }
1160
1161 #[test]
1164 fn run_at_persists_marks_and_delivers_through_the_sink() {
1165 let (dir, path) = state_path();
1166 let mut sink = RecorderSink::default();
1167 let input = input(vec![row("Weekly (7d)", 97, None)]);
1168 run_at(
1169 &input,
1170 97,
1171 &path,
1172 Duration::from_secs(2),
1173 &mut sink,
1174 at(23, 12, 0),
1175 );
1176 assert_eq!(sink.delivered.len(), 1);
1177
1178 let mut second = RecorderSink::default();
1180 run_at(
1181 &input,
1182 97,
1183 &path,
1184 Duration::from_secs(2),
1185 &mut second,
1186 at(23, 12, 0),
1187 );
1188 assert!(second.delivered.is_empty());
1189 assert!(path.exists());
1190 drop(dir);
1191 }
1192
1193 #[test]
1194 fn lock_contention_skips_notifying_entirely() {
1195 let (dir, path) = state_path();
1196 let lock_path = path.with_file_name(".notifications.lock");
1197 let _held = crate::cache::acquire_lock(&lock_path, Duration::from_secs(5)).unwrap();
1198
1199 let mut sink = RecorderSink::default();
1200 let input = input(vec![row("Weekly (7d)", 100, None)]);
1201 run_at(
1202 &input,
1203 97,
1204 &path,
1205 Duration::from_millis(100),
1206 &mut sink,
1207 at(23, 12, 0),
1208 );
1209 assert!(
1210 sink.delivered.is_empty(),
1211 "a missed notification beats a double one"
1212 );
1213 assert!(!path.exists(), "no state without the lock");
1214 drop(dir);
1215 }
1216
1217 #[test]
1218 fn an_unwritable_state_file_skips_delivery_rather_than_repeating() {
1219 let dir = tempfile::TempDir::new().unwrap();
1220 let state_path = dir.path().join("notifications.json");
1223 std::fs::create_dir(&state_path).unwrap();
1224 let mut sink = RecorderSink::default();
1225 let input = input(vec![row("Weekly (7d)", 97, None)]);
1226 run_at(
1227 &input,
1228 97,
1229 &state_path,
1230 Duration::from_secs(2),
1231 &mut sink,
1232 at(23, 12, 0),
1233 );
1234 assert!(sink.delivered.is_empty());
1235 }
1236
1237 #[test]
1241 #[cfg(target_os = "linux")]
1242 fn a_critical_toast_asks_for_a_timeout_and_a_normal_one_does_not() {
1243 let sink = NotifySendSink::new();
1244 let toast = |urgency| Notification {
1245 title: "Claude — Session (5h) at 100%".to_string(),
1246 body: "resets in 2h".to_string(),
1247 urgency,
1248 key: "anthropic::Session (5h)".to_string(),
1249 };
1250 let critical_toast = toast(Urgency::Critical);
1251
1252 let critical = sink.argv(&critical_toast);
1253 assert!(
1254 critical
1255 .windows(2)
1256 .any(|pair| pair[0] == "-t" && pair[1] == "30000"),
1257 "a critical toast asks for the cap: {critical:?}"
1258 );
1259
1260 let normal = sink.argv(&toast(Urgency::Normal));
1261 assert!(
1262 !normal.iter().any(|arg| arg == "-t"),
1263 "a normal toast leaves the timeout to the daemon: {normal:?}"
1264 );
1265
1266 let tail = &critical[critical.len() - 2..];
1269 assert_eq!(tail[0], critical_toast.title);
1270 assert_eq!(tail[1], critical_toast.body);
1271 }
1272
1273 #[test]
1277 #[cfg(target_os = "linux")]
1278 fn a_missing_notifier_binary_is_a_silent_no_op() {
1279 let (dir, path) = state_path();
1280 let mut sink = NotifySendSink::with_program("/nonexistent/notify-send");
1281 let input = input(vec![row("Weekly (7d)", 97, None)]);
1282 run_at(
1283 &input,
1284 97,
1285 &path,
1286 Duration::from_secs(2),
1287 &mut sink,
1288 at(23, 12, 0),
1289 );
1290 assert!(
1291 path.exists(),
1292 "state bookkeeping is independent of delivery"
1293 );
1294 assert!(
1295 NotifyState::read_from(&path).is_notified("anthropic@gmail::Weekly (7d)"),
1296 "the crossing is marked even though nothing was delivered"
1297 );
1298 drop(dir);
1299 }
1300
1301 #[test]
1302 fn noop_sink_records_nothing_and_never_fails() {
1303 let (dir, path) = state_path();
1304 let mut sink = NoopSink;
1305 let input = input(vec![row("Weekly (7d)", 97, None)]);
1306 run_at(
1307 &input,
1308 97,
1309 &path,
1310 Duration::from_secs(2),
1311 &mut sink,
1312 at(23, 12, 0),
1313 );
1314 assert!(path.exists());
1315 drop(dir);
1316 }
1317
1318 #[test]
1321 fn from_tab_projects_rows_account_and_credits() {
1322 use crate::tui::app::{ReadyTab, TabId, TabState};
1323 use crate::usage::{
1324 AnthropicSnapshot, ResetCredit, ResetCredits, UsageWindow, VendorSnapshot,
1325 };
1326
1327 let reset = at(24, 2, 0);
1328 let state = TabState::Ready(Box::new(ReadyTab {
1329 snapshot: VendorSnapshot::Anthropic(AnthropicSnapshot {
1330 plan: "Claude Max 20x".into(),
1331 session: UsageWindow {
1332 utilization_pct: 98,
1333 resets_at: Some(reset),
1334 window_duration: chrono::Duration::hours(5),
1335 },
1336 weekly: UsageWindow {
1337 utilization_pct: 42,
1338 resets_at: None,
1339 window_duration: chrono::Duration::days(7),
1340 },
1341 sonnet: None,
1342 scoped: vec![],
1343 extra: None,
1344 reset_credits: ResetCredits {
1348 available: 1,
1349 credits: vec![ResetCredit {
1350 title: Some("Opus 5.5 launch reset".into()),
1351 expires_at: Some(at(25, 0, 0)),
1352 }],
1353 },
1354 }),
1355 stale: false,
1356 last_error: None,
1357 fetched_at: None,
1358 display: Default::default(),
1359 }));
1360
1361 let tab = TabId::account("gmail");
1362 let projected =
1363 RefreshInput::from_tab(&tab, &state, at(23, 12, 0)).expect("ready tab projects");
1364 assert_eq!(projected.entry_id, "anthropic@gmail");
1365 assert_eq!(projected.vendor, "Claude");
1366 assert_eq!(projected.account.as_deref(), Some("gmail"));
1367 assert_eq!(
1368 projected.rows,
1369 vec![
1370 row("Session (5h)", 98, Some(reset)),
1371 row("Weekly (7d)", 42, None),
1372 ]
1373 );
1374 assert_eq!(
1375 projected.credits,
1376 vec![CreditExpiry {
1377 title: Some("Opus 5.5 launch reset".into()),
1378 expires_at: at(25, 0, 0),
1379 }]
1380 );
1381
1382 let state = TabState::Ready(Box::new(ReadyTab {
1384 snapshot: VendorSnapshot::Openai(crate::usage::OpenAiSnapshot {
1385 plan: "ChatGPT Pro".into(),
1386 session: None,
1387 weekly: None,
1388 code_review: None,
1389 additional_limits: Vec::new(),
1390 unavailable_models: Vec::new(),
1391 credits: None,
1392 reset_credits: Some(ResetCredits {
1393 available: 2,
1394 credits: vec![ResetCredit {
1395 title: Some("Full reset".into()),
1396 expires_at: Some(at(25, 0, 0)),
1397 }],
1398 }),
1399 source: crate::usage::OpenAiSource::CodexOauth,
1400 }),
1401 stale: false,
1402 last_error: None,
1403 fetched_at: None,
1404 display: Default::default(),
1405 }));
1406 let projected = RefreshInput::from_tab(
1407 &TabId::vendor(crate::vendor::VendorId::Openai),
1408 &state,
1409 at(23, 12, 0),
1410 )
1411 .expect("ready tab projects");
1412 assert_eq!(projected.entry_id, "openai");
1413 assert_eq!(
1414 projected.credits,
1415 vec![CreditExpiry {
1416 title: Some("Full reset".into()),
1417 expires_at: at(25, 0, 0),
1418 }]
1419 );
1420 }
1421
1422 #[test]
1423 fn from_tab_skips_grouped_slices_and_unavailable_credits() {
1424 use crate::tui::app::{ReadyTab, TabId, TabState};
1425 use crate::usage::{
1426 ResetCredit, ResetCredits, SuperGrokPeriod, SuperGrokProduct, SuperGrokSnapshot,
1427 VendorSnapshot,
1428 };
1429
1430 let state = TabState::Ready(Box::new(ReadyTab {
1431 snapshot: VendorSnapshot::SuperGrok(SuperGrokSnapshot {
1432 plan: "SuperGrok Heavy".into(),
1433 account: "scope".into(),
1434 weekly_pct: 97,
1435 period: SuperGrokPeriod::Weekly,
1436 reset_at: Some(at(26, 0, 0)),
1437 prepaid_balance: None,
1438 reset_credits: ResetCredits {
1440 available: 0,
1441 credits: vec![ResetCredit {
1442 title: None,
1443 expires_at: Some(at(25, 0, 0)),
1444 }],
1445 },
1446 products: vec![SuperGrokProduct {
1447 label: "Grok Build".into(),
1448 percent: 94,
1449 }],
1450 }),
1451 stale: false,
1452 last_error: None,
1453 fetched_at: None,
1454 display: Default::default(),
1455 }));
1456 let projected = RefreshInput::from_tab(
1457 &TabId::vendor(crate::vendor::VendorId::Supergrok),
1458 &state,
1459 at(23, 12, 0),
1460 )
1461 .expect("ready tab projects");
1462 assert_eq!(projected.rows.len(), 1, "{:?}", projected.rows);
1465 assert!(projected.credits.is_empty());
1466 }
1467
1468 fn antigravity_ready(
1469 session: i32,
1470 third_party_session: i32,
1471 weekly: i32,
1472 third_party_weekly: i32,
1473 ) -> crate::tui::app::TabState {
1474 use crate::tui::app::{ReadyTab, TabState};
1475 use crate::usage::{AntigravitySnapshot, AntigravitySource, UsageWindow, VendorSnapshot};
1476
1477 let window = |pct: i32, resets_at: DateTime<Utc>, hours: i64| {
1478 Some(UsageWindow {
1479 utilization_pct: pct,
1480 resets_at: Some(resets_at),
1481 window_duration: chrono::Duration::hours(hours),
1482 })
1483 };
1484 TabState::Ready(Box::new(ReadyTab {
1485 snapshot: VendorSnapshot::Antigravity(AntigravitySnapshot {
1486 plan: "Google AI Pro".into(),
1487 account: "acct:test".into(),
1488 source: AntigravitySource::Local,
1489 session: window(session, at(24, 2, 21), 5),
1490 weekly: window(weekly, at(26, 1, 34), 168),
1491 third_party_session: window(third_party_session, at(24, 2, 19), 5),
1492 third_party_weekly: window(third_party_weekly, at(30, 19, 19), 168),
1493 model_pools: Vec::new(),
1494 }),
1495 stale: false,
1496 last_error: None,
1497 fetched_at: None,
1498 display: Default::default(),
1499 }))
1500 }
1501
1502 #[test]
1506 fn from_tab_names_repeated_labels_by_their_heading() {
1507 use crate::tui::app::TabId;
1508
1509 let projected = RefreshInput::from_tab(
1510 &TabId::vendor(crate::vendor::VendorId::Antigravity),
1511 &antigravity_ready(3, 100, 98, 56),
1512 at(23, 22, 0),
1513 )
1514 .expect("ready tab projects");
1515 let windows: Vec<_> = projected
1516 .rows
1517 .iter()
1518 .map(|row| row.window.as_str())
1519 .collect();
1520 assert_eq!(
1521 windows,
1522 [
1523 "Session · Gemini",
1524 "Session · Claude & GPT OSS",
1525 "Weekly · Gemini",
1526 "Weekly · Claude & GPT OSS",
1527 ]
1528 );
1529 }
1530
1531 #[test]
1536 fn a_window_whose_label_repeats_notifies_once() {
1537 use crate::tui::app::TabId;
1538
1539 let tab = TabId::vendor(crate::vendor::VendorId::Antigravity);
1540 let state = antigravity_ready(3, 100, 98, 56);
1541 let mut notify_state = NotifyState::default();
1542 let fired: Vec<Vec<String>> = (0..4)
1543 .map(|refresh| {
1544 let now = at(23, 22, 0) + chrono::Duration::minutes(5 * refresh);
1545 let input = RefreshInput::from_tab(&tab, &state, now).expect("ready tab projects");
1546 decide(&input, 97, &mut notify_state, now)
1547 .into_iter()
1548 .map(|notification| notification.title)
1549 .collect()
1550 })
1551 .collect();
1552 assert_eq!(
1553 fired[0],
1554 [
1555 "Antigravity — Session · Claude & GPT OSS at 100%",
1556 "Antigravity — Weekly · Gemini at 98%",
1557 ]
1558 );
1559 assert!(
1560 fired[1..].iter().all(Vec::is_empty),
1561 "later refreshes must stay quiet: {fired:?}"
1562 );
1563 }
1564
1565 #[test]
1567 fn a_window_keeps_its_key_when_its_sibling_disappears_or_returns() {
1568 use crate::tui::app::{TabId, TabState};
1569 use crate::usage::VendorSnapshot;
1570
1571 let tab = TabId::vendor(crate::vendor::VendorId::Antigravity);
1572 for starts_with_session in [false, true] {
1573 let mut notify_state = NotifyState::default();
1574 let steps = [
1578 (starts_with_session, 98, 1),
1579 (!starts_with_session, 98, 0),
1580 (starts_with_session, 98, 0),
1581 (false, 20, 0),
1582 (true, 98, 1),
1583 (false, 98, 0),
1584 ];
1585 for (refresh, (has_session, weekly_pct, expected)) in steps.into_iter().enumerate() {
1586 let mut state = antigravity_ready(3, 10, weekly_pct, 56);
1587 let TabState::Ready(ready) = &mut state else {
1588 unreachable!();
1589 };
1590 let VendorSnapshot::Antigravity(snapshot) = &mut ready.snapshot else {
1591 unreachable!();
1592 };
1593 if !has_session {
1594 snapshot.session = None;
1595 snapshot.third_party_session = None;
1596 }
1597 let now = at(23, 22, 0) + chrono::Duration::minutes(5 * refresh as i64);
1598 let input = RefreshInput::from_tab(&tab, &state, now).expect("ready tab projects");
1599 let fired = decide(&input, 97, &mut notify_state, now);
1600 assert_eq!(
1601 fired.len(),
1602 expected,
1603 "starts_with_session={starts_with_session}, refresh={refresh}: {fired:?}"
1604 );
1605 if let Some(notification) = fired.first() {
1606 assert_eq!(notification.key, "antigravity::Weekly · Gemini");
1607 }
1608 }
1609 }
1610 }
1611
1612 #[test]
1616 fn a_spend_over_the_threshold_notifies_once_as_the_covered_day_moves() {
1617 use crate::tui::app::{ReadyTab, TabId, TabState};
1618 use crate::usage::{AnthropicApiSnapshot, VendorSnapshot};
1619
1620 let tab = TabId::vendor(crate::vendor::VendorId::AnthropicApi);
1621 let mut notify_state = NotifyState::default();
1622 let fired: Vec<usize> = (17..=19)
1623 .map(|day| {
1624 let state = TabState::Ready(Box::new(ReadyTab {
1625 snapshot: VendorSnapshot::AnthropicApi(AnthropicApiSnapshot {
1626 spent: 98.0,
1627 limit: Some(100.0),
1628 covered_through: chrono::NaiveDate::from_ymd_opt(2026, 9, day),
1629 }),
1630 stale: false,
1631 last_error: None,
1632 fetched_at: None,
1633 display: Default::default(),
1634 }));
1635 let now = at(day + 1, 9, 0);
1636 let input = RefreshInput::from_tab(&tab, &state, now).expect("ready tab projects");
1637 decide(&input, 97, &mut notify_state, now).len()
1638 })
1639 .collect();
1640 assert_eq!(fired, [1, 0, 0]);
1641 }
1642
1643 #[test]
1646 fn metric_names_follow_heading_scope_even_for_unique_labels() {
1647 use crate::balance::MetricHeadline;
1648 use crate::pacing::PaceSeverity;
1649 use crate::tui::panels::{Section, SectionProjection};
1650
1651 let projection = |section| SectionProjection {
1652 section,
1653 reset_at: None,
1654 window: None,
1655 group: None,
1656 model_pool: false,
1657 headline: MetricHeadline::Percent,
1658 used_cents: None,
1659 limit_cents: None,
1660 };
1661 let metric = |label: &str, pct| {
1662 projection(Section::Metric {
1663 label: label.into(),
1664 pct,
1665 severity: PaceSeverity::Low,
1666 value_label: format!("{pct}%"),
1667 footnote: String::new(),
1668 })
1669 };
1670 let heading = |label: &str| {
1671 projection(Section::Text {
1672 label: label.into(),
1673 value: String::new(),
1674 })
1675 };
1676 let rows = metric_rows([
1677 metric("Unscoped", 20),
1678 heading("Session"),
1679 projection(Section::Spacer),
1680 metric("General", 40),
1681 metric("Video", 10),
1682 projection(Section::Spacer),
1683 heading("Weekly"),
1684 metric("General", 60),
1685 projection(Section::Text {
1686 label: "Info".into(),
1687 value: "Details".into(),
1688 }),
1689 metric("Other", 30),
1690 ]);
1691 let windows: Vec<_> = rows.iter().map(|row| row.window.as_str()).collect();
1692 assert_eq!(
1693 windows,
1694 [
1695 "Unscoped",
1696 "Session · General",
1697 "Session · Video",
1698 "Weekly · General",
1699 "Other",
1700 ]
1701 );
1702 }
1703
1704 #[test]
1705 fn from_tab_returns_none_for_unready_states() {
1706 use crate::tui::app::{TabId, TabState};
1707 assert!(
1708 RefreshInput::from_tab(
1709 &TabId::vendor(crate::vendor::VendorId::Zai),
1710 &TabState::Loading,
1711 at(23, 12, 0)
1712 )
1713 .is_none()
1714 );
1715 assert!(
1716 RefreshInput::from_tab(
1717 &TabId::vendor(crate::vendor::VendorId::Zai),
1718 &TabState::error("not signed in"),
1719 at(23, 12, 0)
1720 )
1721 .is_none()
1722 );
1723 }
1724}