use super::*;
use crate::profile::{AppState, ProfileName};
use crate::usage::{FetchStatus, UsageInfo, epoch_secs_to_iso, now_epoch_secs};
use std::collections::BTreeMap;
fn reset_in(secs: i64) -> String {
epoch_secs_to_iso(now_epoch_secs() + secs)
}
fn profile(name: &str, threshold: f64, util: f64, reset_secs: i64) -> Profile {
Profile {
name: name.into(),
base_url: None,
api_key: None,
auto_start: false,
env: BTreeMap::new(),
models: Default::default(),
fallback_threshold: Some(threshold),
last_resort: false,
bell_threshold: None,
credentials: None,
usage: Some(UsageInfo {
five_hour: Some(UsageWindow {
utilization: util,
resets_at: Some(reset_in(reset_secs)),
}),
..UsageInfo::default()
}),
fetch_status: None,
provider: None,
third_party_usage: None,
}
}
fn config_with(profiles: Vec<Profile>, active: Option<&str>, chain: Vec<&str>) -> AppConfig {
let names: Vec<ProfileName> = profiles.iter().map(|p| p.name.clone()).collect();
AppConfig {
state: AppState {
active_profile: active.map(Into::into),
profiles: names,
fallback_chain: chain.into_iter().map(Into::into).collect(),
..AppState::default()
},
profiles,
}
}
fn line_text(line: &Line<'static>) -> String {
line.spans.iter().map(|s| s.content.as_ref()).collect()
}
fn resumes_line(lines: &[Line<'static>]) -> Option<String> {
lines.iter().map(line_text).find(|t| t.contains("resumes:"))
}
#[test]
fn all_exhausted_wrap_mode_shows_resumes_hint() {
let a = profile("a", 95.0, 100.0, 3600);
let b = profile("b", 95.0, 100.0, 1800);
let config = config_with(vec![a, b], Some("a"), vec!["a", "b"]);
let app = App::new(config);
let lines = fallback_flow_lines(&app, 60, 20);
let hint =
resumes_line(&lines).expect("resumes hint must render when the whole chain is exhausted");
assert!(
hint.contains("resumes: b in ~"),
"names the soonest-resuming member: {hint}"
);
}
#[test]
fn all_exhausted_wrap_off_active_cleared_shows_resumes_hint() {
let a = profile("a", 95.0, 100.0, 900);
let b = profile("b", 95.0, 100.0, 3600);
let mut config = config_with(vec![a, b], None, vec!["a", "b"]);
config.state.wrap_off = true;
let app = App::new(config);
let lines = fallback_flow_lines(&app, 60, 20);
let hint = resumes_line(&lines)
.expect("resumes hint must render even with no active profile (wrap-off cleared it)");
assert!(hint.contains("resumes: a in ~"), "{hint}");
}
#[test]
fn partially_exhausted_chain_hides_resumes_hint() {
let a = profile("a", 95.0, 100.0, 3600);
let b = profile("b", 95.0, 20.0, 3600);
let config = config_with(vec![a, b], Some("a"), vec!["a", "b"]);
let app = App::new(config);
let lines = fallback_flow_lines(&app, 60, 20);
assert!(
resumes_line(&lines).is_none(),
"must not show when the chain isn't fully exhausted"
);
}
#[test]
fn healthy_chain_hides_resumes_hint() {
let a = profile("a", 95.0, 10.0, 3600);
let b = profile("b", 95.0, 5.0, 3600);
let config = config_with(vec![a, b], Some("a"), vec!["a", "b"]);
let app = App::new(config);
let lines = fallback_flow_lines(&app, 60, 20);
assert!(resumes_line(&lines).is_none());
}
#[test]
fn broken_login_marker_outranks_bell_and_active() {
let a = profile("a", 95.0, 10.0, 3600);
let mut config = config_with(vec![a], Some("a"), vec![]);
config.state.auth_broken.push("a".into());
let mut app = App::new(config);
app.bell_fired.insert("a".into(), true);
let widths = OverviewWidths::new(80, &app);
let line = render_overview_row(&app, 0, &widths, false, true);
let text = line_text(&line);
assert!(text.contains('×'), "broken login renders ×: {text}");
assert!(!text.contains('!'), "bell yields to ×: {text}");
assert!(!text.contains('●'), "active dot yields to ×: {text}");
let marker = line.spans.iter().find(|s| s.content == "×").unwrap();
assert_eq!(marker.style.fg, theme::danger().fg);
}
#[test]
fn bell_marker_shows_when_login_is_fine() {
let a = profile("a", 95.0, 10.0, 3600);
let config = config_with(vec![a], None, vec![]);
let mut app = App::new(config);
app.bell_fired.insert("a".into(), true);
let widths = OverviewWidths::new(80, &app);
let text = line_text(&render_overview_row(&app, 0, &widths, false, true));
assert!(text.contains('!'), "{text}");
assert!(!text.contains('×'), "{text}");
}
#[test]
fn cached_row_colors_countdown_amber_and_underlines_nothing() {
let mut a = profile("a", 95.0, 10.0, 3600);
a.fetch_status = Some(FetchStatus::Cached);
let config = config_with(vec![a], None, vec![]);
let app = App::new(config);
app.next_refresh_per_profile
.lock()
.unwrap()
.insert("a".to_string(), now_ms() + 30_000);
let widths = OverviewWidths::new(80, &app);
let line = render_overview_row(&app, 0, &widths, false, true);
assert!(
line.spans
.iter()
.all(|s| !s.style.add_modifier.contains(Modifier::UNDERLINED)),
"underline cue is retired"
);
let bracket = line
.spans
.iter()
.find(|s| s.content == "[")
.expect("bracketed 5h bar");
assert_eq!(bracket.style.fg, theme::dim().fg, "brackets stay plain dim");
let countdown = line
.spans
.iter()
.find(|s| s.content.ends_with("s "))
.expect("refresh countdown");
assert_eq!(countdown.style.fg, Some(theme::warning_color()));
}
#[test]
fn failed_row_colors_countdown_red() {
let mut a = profile("a", 95.0, 10.0, 3600);
a.fetch_status = Some(FetchStatus::Failed);
let config = config_with(vec![a], None, vec![]);
let app = App::new(config);
app.next_refresh_per_profile
.lock()
.unwrap()
.insert("a".to_string(), now_ms() + 30_000);
let widths = OverviewWidths::new(80, &app);
let line = render_overview_row(&app, 0, &widths, false, true);
let bracket = line
.spans
.iter()
.find(|s| s.content == "[")
.expect("bracketed 5h bar");
assert_eq!(bracket.style.fg, theme::dim().fg, "brackets stay plain dim");
let countdown = line
.spans
.iter()
.find(|s| s.content.ends_with("s "))
.expect("refresh countdown");
assert_eq!(countdown.style.fg, Some(theme::danger_color()));
}
#[test]
fn gap_widening_never_clips_the_row() {
let a = profile("ax-main", 95.0, 10.0, 3600);
let b = profile("ax-backup", 95.0, 20.0, 3600);
let config = config_with(vec![a, b], Some("ax-main"), vec![]);
let app = App::new(config);
for width in 34u16..=200 {
let w = OverviewWidths::new(width, &app);
let min =
fixed_overview_width(w.name, w.kind, w.five_hour, w.seven_day, w.route, 2) + TIMER_SLOT;
if min > width as usize {
continue;
}
let used = fixed_overview_width(w.name, w.kind, w.five_hour, w.seven_day, w.route, w.gap)
+ TIMER_SLOT;
assert!(
used <= width as usize,
"row overflows at width {width}: used {used} (gap {})",
w.gap
);
}
}