use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use anyhow::{Context, Result};
use ratatui::Frame;
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Paragraph, Wrap};
use serde::Deserialize;
use crate::config::WeatherConfig;
use crate::frame::Binding;
use crate::glyphs;
use crate::grid::{Column, Grid};
use crate::panel::{Panel, RenderContext};
const HTTP_TIMEOUT: Duration = Duration::from_secs(15);
const HOUR_W: u16 = 5;
const TEMP_W: u16 = 5;
const FEELS_W: u16 = 5;
const RAIN_W: u16 = 5;
const WIND_W: u16 = 7;
const SKY_W: u16 = 2 + 1 + 12;
const RAIN_MIN: u16 = HOUR_W + SKY_W + TEMP_W + RAIN_W + 3;
const FEELS_MIN: u16 = HOUR_W + SKY_W + TEMP_W + FEELS_W + RAIN_W + 4;
const WIND_MIN: u16 = HOUR_W + SKY_W + TEMP_W + FEELS_W + RAIN_W + WIND_W + 5;
const COLUMNS: &[Column] = &[
Column::fixed("hour", HOUR_W),
Column::flex("sky", 1),
Column::fixed("temp", TEMP_W).right(),
Column::fixed("feels", FEELS_W)
.right()
.drops_below(FEELS_MIN),
Column::fixed("rain", RAIN_W).right().drops_below(RAIN_MIN),
Column::fixed("wind", WIND_W).right().drops_below(WIND_MIN),
];
const BINDINGS: &[Binding] = &[
Binding::primary("r", "refresh"),
Binding::primary("u", "units"),
];
const FRAME_HEIGHT: u16 = 2;
const FRAME_WIDTH: u16 = 4;
const MAX_FORECAST_HOURS: u16 = 24;
const FORECAST_WIDTH: u16 = WIND_MIN;
#[derive(Debug, Clone)]
pub struct Slot {
pub hour: u8,
pub temperature: f64,
pub feels_like: f64,
pub code: u8,
pub precipitation_chance: Option<u8>,
pub wind: f64,
}
#[derive(Debug, Clone)]
pub struct WeatherData {
pub place: String,
pub temperature: f64,
pub feels_like: f64,
pub code: u8,
pub wind: f64,
pub humidity: Option<u8>,
pub hours: Vec<Slot>,
pub temperature_unit: &'static str,
pub wind_unit: &'static str,
pub observed: String,
}
#[derive(Debug, Clone, Default)]
struct State {
data: Option<Box<WeatherData>>,
fetched: Option<Instant>,
error: Option<String>,
}
impl State {
fn age(&self) -> Option<Duration> {
self.fetched.map(|at| at.elapsed())
}
}
fn describe_age(age: Duration) -> String {
let minutes = age.as_secs() / 60;
if minutes < 60 {
return format!("{minutes}m old");
}
let hours = minutes / 60;
if hours < 24 {
return format!("{hours}h old");
}
format!("{}d old", hours / 24)
}
#[derive(Debug)]
pub struct WeatherPanel {
state: Arc<Mutex<State>>,
refresh: Arc<Mutex<bool>>,
forecast_hours: u8,
stale_after: Duration,
imperial: bool,
}
fn convert_temperature(value: f64, to_imperial: bool) -> f64 {
if to_imperial {
value * 9.0 / 5.0 + 32.0
} else {
(value - 32.0) * 5.0 / 9.0
}
}
fn convert_wind(value: f64, to_imperial: bool) -> f64 {
const KM_PER_MILE: f64 = 1.609_344;
if to_imperial {
value / KM_PER_MILE
} else {
value * KM_PER_MILE
}
}
impl WeatherPanel {
pub fn new(config: WeatherConfig) -> Self {
let forecast_hours = config.forecast_hours;
let imperial = config.units != "metric";
let stale_after = Duration::from_secs(config.refresh_minutes.max(1) * 60 * 2);
let state = Arc::new(Mutex::new(State::default()));
let refresh = Arc::new(Mutex::new(false));
let shared = Arc::clone(&state);
let shared_refresh = Arc::clone(&refresh);
std::thread::Builder::new()
.name("mirador-weather".into())
.spawn(move || fetch_loop(&config, &shared, &shared_refresh))
.expect("spawning the weather thread");
Self {
state,
refresh,
forecast_hours,
stale_after,
imperial,
}
}
fn in_display_units(&self, mut data: WeatherData) -> WeatherData {
let fetched_imperial = data.temperature_unit == "°F";
if fetched_imperial == self.imperial {
return data;
}
let to = self.imperial;
data.temperature = convert_temperature(data.temperature, to);
data.feels_like = convert_temperature(data.feels_like, to);
data.wind = convert_wind(data.wind, to);
for hour in &mut data.hours {
hour.temperature = convert_temperature(hour.temperature, to);
hour.feels_like = convert_temperature(hour.feels_like, to);
hour.wind = convert_wind(hour.wind, to);
}
data.temperature_unit = if to { "°F" } else { "°C" };
data.wind_unit = if to { "mph" } else { "km/h" };
data
}
fn snapshot(&self) -> State {
match self.state.lock() {
Ok(guard) => guard.clone(),
Err(poisoned) => poisoned.into_inner().clone(),
}
}
fn is_stale(&self, state: &State) -> bool {
state.error.is_some() || state.age().is_some_and(|age| age > self.stale_after)
}
}
fn fetch_loop(config: &WeatherConfig, state: &Arc<Mutex<State>>, refresh: &Arc<Mutex<bool>>) {
let located = match resolve_location(config) {
Ok(v) => v,
Err(e) => {
update(state, |s| s.error = Some(format!("{e:#}")));
return;
}
};
let interval = Duration::from_secs(config.refresh_minutes.max(1) * 60);
loop {
match fetch_weather(config, &located) {
Ok(data) => update(state, |s| {
s.data = Some(Box::new(data));
s.fetched = Some(Instant::now());
s.error = None;
}),
Err(e) => update(state, |s| s.error = Some(format!("{e:#}"))),
}
let mut waited = Duration::ZERO;
while waited < interval {
std::thread::sleep(Duration::from_millis(500));
waited += Duration::from_millis(500);
let asked = match refresh.lock() {
Ok(mut flag) => std::mem::replace(&mut *flag, false),
Err(poisoned) => std::mem::replace(&mut *poisoned.into_inner(), false),
};
if asked {
break;
}
}
}
}
fn update(state: &Arc<Mutex<State>>, f: impl FnOnce(&mut State)) {
match state.lock() {
Ok(mut guard) => f(&mut guard),
Err(poisoned) => f(&mut poisoned.into_inner()),
}
}
#[derive(Debug, Clone)]
struct Located {
name: String,
latitude: f64,
longitude: f64,
}
fn resolve_location(config: &WeatherConfig) -> Result<Located> {
if let (Some(latitude), Some(longitude)) = (config.latitude, config.longitude) {
return Ok(Located {
name: if config.location.is_empty() {
format!("{latitude:.2}, {longitude:.2}")
} else {
config.location.clone()
},
latitude,
longitude,
});
}
if config.location.trim().is_empty() {
anyhow::bail!(
"no location set. Add `location = \"City, Region\"` or explicit \
`latitude`/`longitude` under [weather]."
);
}
geocode(&config.location)
}
#[derive(Debug, Deserialize)]
struct GeocodeResponse {
#[serde(default)]
results: Vec<GeocodeResult>,
}
#[derive(Debug, Deserialize)]
struct GeocodeResult {
name: String,
latitude: f64,
longitude: f64,
#[serde(default)]
admin1: Option<String>,
#[serde(default)]
country_code: Option<String>,
}
fn geocode(query: &str) -> Result<Located> {
let city = query.split(',').next().unwrap_or(query).trim();
let url = format!(
"https://geocoding-api.open-meteo.com/v1/search?name={}&count=10&language=en&format=json",
urlencode(city)
);
let body = http_get(&url).context("geocoding the configured location")?;
let parsed: GeocodeResponse =
serde_json::from_str(&body).context("parsing the geocoding response")?;
if parsed.results.is_empty() {
anyhow::bail!(
"could not find `{query}`. Try a different spelling, or set \
`latitude` and `longitude` under [weather]."
);
}
let hint = query
.split_once(',')
.map(|(_, rest)| rest.trim().to_ascii_lowercase())
.unwrap_or_default();
let best = parsed
.results
.iter()
.find(|r| {
!hint.is_empty()
&& (r
.admin1
.as_ref()
.is_some_and(|a| a.to_ascii_lowercase() == hint)
|| r.country_code
.as_ref()
.is_some_and(|c| c.to_ascii_lowercase() == hint))
})
.unwrap_or(&parsed.results[0]);
let label = match &best.admin1 {
Some(region) if !region.is_empty() => format!("{}, {region}", best.name),
_ => best.name.clone(),
};
Ok(Located {
name: label,
latitude: best.latitude,
longitude: best.longitude,
})
}
#[derive(Debug, Deserialize)]
struct ForecastResponse {
current: Current,
hourly: Hourly,
}
#[derive(Debug, Deserialize)]
struct Current {
time: String,
temperature_2m: f64,
apparent_temperature: f64,
weather_code: u8,
wind_speed_10m: f64,
#[serde(default)]
relative_humidity_2m: Option<u8>,
}
#[derive(Debug, Deserialize)]
struct Hourly {
time: Vec<String>,
temperature_2m: Vec<f64>,
apparent_temperature: Vec<f64>,
weather_code: Vec<u8>,
wind_speed_10m: Vec<f64>,
#[serde(default)]
precipitation_probability: Vec<Option<u8>>,
}
fn fetch_weather(config: &WeatherConfig, located: &Located) -> Result<WeatherData> {
let imperial = config.units == "imperial";
let wanted = usize::from(MAX_FORECAST_HOURS);
let url = format!(
"https://api.open-meteo.com/v1/forecast\
?latitude={lat}&longitude={lon}\
¤t=temperature_2m,apparent_temperature,weather_code,wind_speed_10m,relative_humidity_2m\
&hourly=temperature_2m,apparent_temperature,weather_code,wind_speed_10m,precipitation_probability\
&forecast_days=2&timezone=auto{units}",
lat = located.latitude,
lon = located.longitude,
units = if imperial {
"&temperature_unit=fahrenheit&wind_speed_unit=mph&precipitation_unit=inch"
} else {
""
},
);
let body = http_get(&url).context("fetching the forecast")?;
let parsed: ForecastResponse =
serde_json::from_str(&body).context("parsing the forecast response")?;
let hours = upcoming_hours(&parsed, wanted);
Ok(WeatherData {
place: located.name.clone(),
temperature: parsed.current.temperature_2m,
feels_like: parsed.current.apparent_temperature,
code: parsed.current.weather_code,
wind: parsed.current.wind_speed_10m,
humidity: parsed.current.relative_humidity_2m,
hours,
temperature_unit: if imperial { "°F" } else { "°C" },
wind_unit: if imperial { "mph" } else { "km/h" },
observed: hour_label(&parsed.current.time).unwrap_or_default(),
})
}
fn upcoming_hours(parsed: &ForecastResponse, wanted: usize) -> Vec<Slot> {
let now = parsed.current.time.as_str();
let start = parsed
.hourly
.time
.iter()
.position(|t| t.as_str() >= now)
.unwrap_or(0);
parsed
.hourly
.time
.iter()
.enumerate()
.skip(start)
.take(wanted)
.filter_map(|(index, time)| {
Some(Slot {
hour: parse_hour(time)?,
temperature: *parsed.hourly.temperature_2m.get(index)?,
feels_like: parsed
.hourly
.apparent_temperature
.get(index)
.copied()
.unwrap_or_default(),
code: parsed.hourly.weather_code.get(index).copied().unwrap_or(0),
precipitation_chance: parsed
.hourly
.precipitation_probability
.get(index)
.copied()
.flatten(),
wind: parsed
.hourly
.wind_speed_10m
.get(index)
.copied()
.unwrap_or_default(),
})
})
.collect()
}
fn parse_hour(timestamp: &str) -> Option<u8> {
let time = timestamp.split('T').nth(1)?;
time.split(':').next()?.parse().ok()
}
fn hour_label(timestamp: &str) -> Option<String> {
let time = timestamp.split('T').nth(1)?;
let mut parts = time.split(':');
let hour = parts.next()?;
let minute = parts.next().unwrap_or("00");
Some(format!("{hour}:{minute}"))
}
fn http_get(url: &str) -> Result<String> {
let agent = ureq::Agent::config_builder()
.timeout_global(Some(HTTP_TIMEOUT))
.user_agent(concat!("mirador/", env!("CARGO_PKG_VERSION")))
.build()
.new_agent();
let mut response = agent.get(url).call().map_err(|e| match e {
ureq::Error::StatusCode(code) => {
anyhow::anyhow!("the weather service returned HTTP {code}")
}
other => anyhow::anyhow!("network request failed: {other}"),
})?;
response
.body_mut()
.read_to_string()
.context("reading the response body")
}
fn urlencode(input: &str) -> String {
let mut out = String::with_capacity(input.len());
for byte in input.bytes() {
match byte {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
out.push(byte as char);
}
b' ' => out.push_str("%20"),
other => {
use std::fmt::Write as _;
let _ = write!(out, "%{other:02X}");
}
}
}
out
}
impl Panel for WeatherPanel {
fn title(&self) -> String {
match self.snapshot().data {
Some(data) => format!("Weather — {}", data.place),
None => "Weather".to_string(),
}
}
fn counter(&self) -> Option<String> {
let state = self.snapshot();
let Some(data) = &state.data else {
return Some(if state.error.is_some() {
"offline".to_string()
} else {
"loading".to_string()
});
};
if self.is_stale(&state) {
return state.age().map(describe_age);
}
(!data.observed.is_empty()).then(|| format!("at {}", data.observed))
}
fn bindings(&self) -> &'static [Binding] {
BINDINGS
}
fn max_width(&self) -> Option<u16> {
Some(FORECAST_WIDTH + FRAME_WIDTH)
}
fn max_height(&self) -> Option<u16> {
let hours = MAX_FORECAST_HOURS.max(u16::from(self.forecast_hours));
Some(
u16::try_from(glyphs::ART_HEIGHT).unwrap_or(4)
+ 2 + hours
+ FRAME_HEIGHT,
)
}
fn refresh_interval(&self) -> Duration {
Duration::from_secs(2)
}
fn handle_key(&mut self, key: ratatui::crossterm::event::KeyEvent) -> crate::panel::KeyOutcome {
use ratatui::crossterm::event::KeyCode;
match key.code {
KeyCode::Char('r') => {
if let Ok(mut flag) = self.refresh.lock() {
*flag = true;
}
crate::panel::KeyOutcome::Consumed
}
KeyCode::Char('u') => {
self.imperial = !self.imperial;
crate::panel::KeyOutcome::Consumed
}
_ => crate::panel::KeyOutcome::Ignored,
}
}
fn render(&mut self, frame: &mut Frame, area: Rect, ctx: RenderContext<'_>) {
let theme = ctx.theme;
if area.width == 0 || area.height == 0 {
return;
}
let state = self.snapshot();
let is_old = self.is_stale(&state);
let Some(reading) = state.data.clone() else {
let lines = match &state.error {
Some(message) => vec![
Line::from(Span::styled(
"Weather unavailable",
Style::default()
.fg(theme.error)
.add_modifier(Modifier::BOLD),
)),
Line::from(Span::styled(
message.clone(),
Style::default().fg(theme.muted),
)),
Line::from(Span::styled("r to retry", Style::default().fg(theme.muted))),
],
None => vec![Line::from(Span::styled(
"Fetching weather\u{2026}",
Style::default().fg(theme.muted),
))],
};
frame.render_widget(Paragraph::new(lines).wrap(Wrap { trim: true }), area);
return;
};
let data = Box::new(self.in_display_units(*reading));
let show_art = area.height >= 7 && area.width >= 34;
let now_height = if show_art {
u16::try_from(glyphs::ART_HEIGHT).unwrap_or(4)
} else {
2
};
let rows = Layout::vertical([
Constraint::Length(now_height.min(area.height)),
Constraint::Length(u16::from(area.height > now_height + 2)), Constraint::Min(0), ])
.split(area);
let notice = is_old.then(|| {
let age = state.age().map_or_else(String::new, describe_age);
match &state.error {
Some(_) => format!("{age} — refresh failing"),
None => age,
}
});
Self::render_now(frame, rows[0], theme, &data, show_art, notice.as_deref());
if rows[1].height > 0 {
crate::frame::rule(frame, rows[1], theme, "next hours");
}
if rows[2].height > 0 {
render_forecast(frame, rows[2], theme, &data);
}
}
}
impl WeatherPanel {
fn render_now(
frame: &mut Frame,
area: Rect,
theme: &crate::theme::Theme,
data: &WeatherData,
show_art: bool,
stale_notice: Option<&str>,
) {
if area.height == 0 {
return;
}
let sky = glyphs::sky(data.code);
let readings_area = if show_art {
let art_width = u16::try_from(glyphs::ART_WIDTH).unwrap_or(12);
let split = Layout::horizontal([Constraint::Length(art_width + 2), Constraint::Min(0)])
.split(area);
for (index, line) in glyphs::art(sky).iter().enumerate() {
let y = area.y + u16::try_from(index).unwrap_or(0);
if y >= area.y + area.height {
break;
}
frame.render_widget(
Paragraph::new(Span::styled(*line, Style::default().fg(theme.accent))),
Rect::new(split[0].x, y, split[0].width, 1),
);
}
split[1]
} else {
area
};
let mut lines = vec![
Line::from(vec![
Span::styled(
format!("{:.0}{}", data.temperature, data.temperature_unit),
Style::default().fg(theme.text).add_modifier(Modifier::BOLD),
),
Span::styled(
format!(" {}", glyphs::describe(sky)),
Style::default().fg(theme.text),
),
]),
Line::from(Span::styled(
format!("feels {:.0}{}", data.feels_like, data.temperature_unit),
Style::default().fg(theme.muted),
)),
];
let mut extras = vec![format!("wind {:.0} {}", data.wind, data.wind_unit)];
if let Some(humidity) = data.humidity {
extras.push(format!("humidity {humidity}%"));
}
lines.push(Line::from(Span::styled(
extras.join(" "),
Style::default().fg(theme.muted),
)));
if let Some(notice) = stale_notice {
lines.push(Line::from(Span::styled(
notice.to_string(),
Style::default()
.fg(theme.warning)
.add_modifier(Modifier::BOLD),
)));
}
frame.render_widget(Paragraph::new(lines), readings_area);
}
}
fn render_forecast(frame: &mut Frame, area: Rect, theme: &crate::theme::Theme, data: &WeatherData) {
if data.hours.is_empty() {
frame.render_widget(
Paragraph::new(Span::styled(
"No hourly data.",
Style::default().fg(theme.muted),
)),
area,
);
return;
}
let grid = Grid::new(COLUMNS, area.width);
if grid.is_empty() {
return;
}
let mut lines = vec![grid.header(theme)];
let room = usize::from(area.height.saturating_sub(1));
for hour in data.hours.iter().take(room) {
let sky = glyphs::sky(hour.code);
let rain = hour
.precipitation_chance
.map_or_else(|| "–".to_string(), |c| format!("{c}%"));
let rain_style = match hour.precipitation_chance.unwrap_or(0) {
0..=19 => Style::default().fg(theme.muted),
20..=59 => Style::default().fg(theme.label),
_ => Style::default().fg(theme.warning),
};
lines.push(grid.row(&[
Span::styled(
format!("{:02}:00", hour.hour),
Style::default().fg(theme.muted),
),
Span::styled(
format!("{} {}", glyphs::mark(sky), glyphs::describe(sky)),
Style::default().fg(theme.text),
),
Span::styled(
format!("{:.0}{}", hour.temperature, data.temperature_unit),
Style::default().fg(theme.text),
),
Span::styled(
format!("{:.0}°", hour.feels_like),
Style::default().fg(theme.muted),
),
Span::styled(rain, rain_style),
Span::styled(
format!("{:.0} {}", hour.wind, data.wind_unit),
Style::default().fg(theme.muted),
),
]));
}
frame.render_widget(Paragraph::new(lines), area);
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_data(imperial: bool) -> WeatherData {
WeatherData {
place: "Cincinnati".into(),
temperature: if imperial { 82.0 } else { 27.777_78 },
feels_like: if imperial { 86.0 } else { 30.0 },
code: 0,
wind: if imperial { 6.0 } else { 9.656_064 },
humidity: Some(44),
hours: vec![Slot {
hour: 13,
temperature: if imperial { 212.0 } else { 100.0 },
feels_like: if imperial { 32.0 } else { 0.0 },
code: 0,
precipitation_chance: Some(0),
wind: if imperial { 10.0 } else { 16.093_44 },
}],
temperature_unit: if imperial { "\u{b0}F" } else { "\u{b0}C" },
wind_unit: if imperial { "mph" } else { "km/h" },
observed: "13:00".into(),
}
}
fn panel_showing(imperial: bool) -> WeatherPanel {
WeatherPanel {
state: Arc::new(Mutex::new(State::default())),
refresh: Arc::new(Mutex::new(false)),
forecast_hours: 8,
stale_after: Duration::from_hours(1),
imperial,
}
}
#[test]
fn every_optional_column_appears_as_soon_as_it_fits() {
for (label, threshold) in [("rain", RAIN_MIN), ("feels", FEELS_MIN), ("wind", WIND_MIN)] {
let at = Grid::new(COLUMNS, threshold);
assert!(at.has(label), "`{label}` missing at its own threshold");
assert!(
at.column_width("sky") >= SKY_W,
"`{label}` at {threshold} squeezes the sky to {} (needs {SKY_W})",
at.column_width("sky")
);
let below = Grid::new(COLUMNS, threshold - 1);
assert!(
!below.has(label),
"`{label}` still shows at {}, so its threshold is too high",
threshold - 1
);
}
}
#[test]
fn the_sky_label_is_never_truncated_at_any_width_the_table_settles_on() {
for total in FORECAST_WIDTH..FORECAST_WIDTH + 40 {
let grid = Grid::new(COLUMNS, total);
assert!(
grid.column_width("sky") >= SKY_W,
"sky is {} at total {total}",
grid.column_width("sky")
);
}
}
#[test]
fn the_declared_width_is_where_the_table_completes() {
let complete = Grid::new(COLUMNS, FORECAST_WIDTH);
for label in ["hour", "sky", "temp", "feels", "rain", "wind"] {
assert!(
complete.has(label),
"`{label}` is missing at the declared width of {FORECAST_WIDTH}"
);
}
let narrower = Grid::new(COLUMNS, FORECAST_WIDTH - 1);
assert!(
!["hour", "sky", "temp", "feels", "rain", "wind"]
.iter()
.all(|label| narrower.has(label)),
"the table still completes at {}, so the declared width is too generous",
FORECAST_WIDTH - 1
);
}
#[test]
fn a_taller_panel_shows_more_forecast_hours() {
let theme = crate::theme::Theme::default();
let mut data = sample_data(true);
data.hours = (0..MAX_FORECAST_HOURS)
.map(|i| Slot {
hour: u8::try_from(i).unwrap_or(0),
temperature: 20.0,
feels_like: 20.0,
code: 0,
precipitation_chance: Some(0),
wind: 5.0,
})
.collect();
let rows_drawn = |height: u16| -> usize {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let mut terminal = Terminal::new(TestBackend::new(FORECAST_WIDTH, height)).unwrap();
terminal
.draw(|frame| render_forecast(frame, frame.area(), &theme, &data))
.unwrap();
let buf = terminal.backend().buffer().clone();
(0..height)
.filter(|y| {
let line: String = (0..5).map(|x| buf[(x, *y)].symbol()).collect();
line.ends_with(":00")
})
.count()
};
assert_eq!(rows_drawn(5), 4, "four rows of height, four hours");
assert_eq!(rows_drawn(9), 8, "a taller panel shows more");
assert!(
rows_drawn(20) > rows_drawn(9),
"height must keep buying hours, not stop at the configured count"
);
}
#[test]
fn a_failed_refresh_keeps_the_last_reading_instead_of_blanking_the_panel() {
let state = Arc::new(Mutex::new(State::default()));
update(&state, |s| {
s.data = Some(Box::new(sample_data(true)));
s.fetched = Some(Instant::now());
});
update(&state, |s| s.error = Some("network request failed".into()));
let snapshot = state.lock().unwrap().clone();
assert!(snapshot.data.is_some(), "the reading must survive");
assert!(snapshot.error.is_some(), "and the failure must be recorded");
}
#[test]
fn a_successful_refresh_clears_a_previous_error() {
let state = Arc::new(Mutex::new(State::default()));
update(&state, |s| s.error = Some("boom".into()));
update(&state, |s| {
s.data = Some(Box::new(sample_data(true)));
s.fetched = Some(Instant::now());
s.error = None;
});
assert!(state.lock().unwrap().error.is_none());
}
#[test]
fn a_reading_is_stale_when_the_refresh_failed_or_when_it_is_simply_old() {
let panel = panel_showing(true);
let fresh = State {
data: Some(Box::new(sample_data(true))),
fetched: Some(Instant::now()),
error: None,
};
assert!(
!panel.is_stale(&fresh),
"a good recent reading is not stale"
);
let failing = State {
error: Some("timed out".into()),
..fresh.clone()
};
assert!(panel.is_stale(&failing), "a failing refresh marks it stale");
let old = State {
fetched: Instant::now().checked_sub(Duration::from_hours(2)),
..fresh.clone()
};
assert!(panel.is_stale(&old), "age alone is enough");
}
#[test]
fn the_counter_shows_the_age_once_stale_and_the_observation_time_otherwise() {
let panel = panel_showing(true);
update(&panel.state, |s| {
s.data = Some(Box::new(sample_data(true)));
s.fetched = Some(Instant::now());
});
assert_eq!(panel.counter(), Some("at 13:00".to_string()));
update(&panel.state, |s| s.error = Some("timed out".into()));
assert_eq!(panel.counter(), Some("0m old".to_string()));
}
#[test]
fn the_counter_distinguishes_never_loaded_from_failed_to_load() {
let panel = panel_showing(true);
assert_eq!(panel.counter(), Some("loading".to_string()));
update(&panel.state, |s| s.error = Some("no such host".into()));
assert_eq!(
panel.counter(),
Some("offline".to_string()),
"a first fetch that failed is not still loading"
);
}
#[test]
fn an_age_reads_the_way_a_person_would_say_it() {
assert_eq!(describe_age(Duration::from_secs(0)), "0m old");
assert_eq!(describe_age(Duration::from_mins(59)), "59m old");
assert_eq!(describe_age(Duration::from_hours(1)), "1h old");
assert_eq!(describe_age(Duration::from_hours(23)), "23h old");
assert_eq!(describe_age(Duration::from_hours(24)), "1d old");
assert_eq!(describe_age(Duration::from_hours(120)), "5d old");
}
#[test]
fn data_already_in_the_display_units_is_left_alone() {
let panel = panel_showing(true);
let before = sample_data(true);
let after = panel.in_display_units(before.clone());
assert_eq!(after.temperature.to_bits(), before.temperature.to_bits());
assert_eq!(after.temperature_unit, "\u{b0}F");
}
#[test]
fn fahrenheit_data_is_restated_in_celsius_including_the_forecast() {
let panel = panel_showing(false);
let converted = panel.in_display_units(sample_data(true));
assert!((converted.temperature - 27.777_78).abs() < 0.001);
assert!((converted.feels_like - 30.0).abs() < 0.001);
assert_eq!(converted.temperature_unit, "\u{b0}C");
assert_eq!(converted.wind_unit, "km/h");
assert!(
(converted.hours[0].temperature - 100.0).abs() < 0.001,
"212F is 100C, got {}",
converted.hours[0].temperature
);
assert!((converted.hours[0].feels_like - 0.0).abs() < 0.001);
assert!((converted.hours[0].wind - 16.093_44).abs() < 0.001);
}
#[test]
fn celsius_data_is_restated_in_fahrenheit() {
let panel = panel_showing(true);
let converted = panel.in_display_units(sample_data(false));
assert!((converted.temperature - 82.0).abs() < 0.01);
assert!((converted.hours[0].temperature - 212.0).abs() < 0.01);
assert!((converted.wind - 6.0).abs() < 0.01);
assert_eq!(converted.temperature_unit, "\u{b0}F");
}
#[test]
fn converting_there_and_back_returns_the_original_reading() {
let out = panel_showing(false).in_display_units(sample_data(true));
let back = panel_showing(true).in_display_units(out);
let original = sample_data(true);
assert!((back.temperature - original.temperature).abs() < 0.001);
assert!((back.wind - original.wind).abs() < 0.001);
}
#[test]
fn u_switches_units_and_is_documented() {
use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
let mut panel = panel_showing(true);
assert!(
BINDINGS.iter().any(|b| b.key == "u"),
"a key nobody is told about might as well not exist"
);
let outcome = panel.handle_key(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::NONE));
assert_eq!(outcome, crate::panel::KeyOutcome::Consumed);
assert!(!panel.imperial, "the first press switches to metric");
panel.handle_key(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::NONE));
assert!(panel.imperial, "and the second switches back");
}
fn sample() -> ForecastResponse {
serde_json::from_str(
r#"{
"current":{"time":"2026-07-25T14:00","temperature_2m":71.2,
"apparent_temperature":70.0,"weather_code":3,
"wind_speed_10m":8.1,"relative_humidity_2m":58},
"hourly":{
"time":["2026-07-25T12:00","2026-07-25T13:00","2026-07-25T14:00",
"2026-07-25T15:00","2026-07-25T16:00"],
"temperature_2m":[68.0,70.0,71.2,72.5,73.0],
"apparent_temperature":[67.0,69.0,70.0,71.0,72.0],
"weather_code":[0,1,3,61,80],
"wind_speed_10m":[5.0,6.0,8.1,9.0,10.0],
"precipitation_probability":[0,5,10,60,80]
}}"#,
)
.expect("sample must parse")
}
#[test]
fn urlencode_escapes_spaces_and_punctuation() {
assert_eq!(urlencode("Boston"), "Boston");
assert_eq!(urlencode("New York"), "New%20York");
assert_eq!(urlencode("a,b"), "a%2Cb");
assert_eq!(urlencode("Zürich"), "Z%C3%BCrich");
assert_eq!(urlencode("a-b_c.d~e"), "a-b_c.d~e");
}
#[test]
fn the_forecast_starts_at_the_current_hour_not_at_midnight() {
let hours = upcoming_hours(&sample(), 10);
assert_eq!(hours[0].hour, 14, "past hours must be dropped");
assert_eq!(hours.len(), 3);
}
#[test]
fn the_forecast_respects_the_requested_length() {
let hours = upcoming_hours(&sample(), 2);
assert_eq!(hours.len(), 2);
assert_eq!(hours[0].hour, 14);
assert_eq!(hours[1].hour, 15);
}
#[test]
fn hourly_values_line_up_with_their_timestamps() {
let hours = upcoming_hours(&sample(), 10);
assert!((hours[0].temperature - 71.2).abs() < f64::EPSILON);
assert_eq!(hours[0].code, 3);
assert_eq!(hours[0].precipitation_chance, Some(10));
assert_eq!(hours[1].precipitation_chance, Some(60));
}
#[test]
fn a_current_time_past_every_hour_falls_back_to_the_whole_span() {
let mut parsed = sample();
parsed.current.time = "2026-07-26T23:00".into();
let hours = upcoming_hours(&parsed, 3);
assert_eq!(hours.len(), 3, "must not return an empty forecast");
assert_eq!(hours[0].hour, 12);
}
#[test]
fn hours_parse_out_of_iso_timestamps() {
assert_eq!(parse_hour("2026-07-25T14:00"), Some(14));
assert_eq!(parse_hour("2026-07-25T00:00"), Some(0));
assert_eq!(parse_hour("2026-07-25T23:00"), Some(23));
assert_eq!(parse_hour("nonsense"), None);
assert_eq!(parse_hour(""), None);
}
#[test]
fn observation_labels_keep_hours_and_minutes() {
assert_eq!(hour_label("2026-07-25T14:30"), Some("14:30".to_string()));
assert_eq!(hour_label("2026-07-25T09:00"), Some("09:00".to_string()));
assert_eq!(hour_label("broken"), None);
}
#[test]
fn missing_optional_hourly_fields_do_not_drop_the_hour() {
let parsed: ForecastResponse = serde_json::from_str(
r#"{
"current":{"time":"2026-07-25T14:00","temperature_2m":71.2,
"apparent_temperature":70.0,"weather_code":3,"wind_speed_10m":8.1},
"hourly":{"time":["2026-07-25T14:00"],"temperature_2m":[71.2],
"apparent_temperature":[70.0],"weather_code":[3],
"wind_speed_10m":[8.1]}
}"#,
)
.expect("precipitation is optional");
let hours = upcoming_hours(&parsed, 5);
assert_eq!(hours.len(), 1);
assert_eq!(hours[0].precipitation_chance, None);
}
#[test]
fn geocode_responses_parse() {
let json = r#"{"results":[{"name":"Boston","latitude":42.36,"longitude":-71.06,
"admin1":"Massachusetts","country_code":"US"}]}"#;
let parsed: GeocodeResponse = serde_json::from_str(json).unwrap();
assert_eq!(parsed.results[0].name, "Boston");
}
#[test]
fn an_empty_geocode_result_set_parses_as_empty() {
let parsed: GeocodeResponse = serde_json::from_str("{}").unwrap();
assert!(parsed.results.is_empty());
}
#[test]
fn explicit_coordinates_skip_geocoding() {
let config = WeatherConfig {
location: "Anywhere".into(),
latitude: Some(42.36),
longitude: Some(-71.06),
..Default::default()
};
let located = resolve_location(&config).expect("no network needed");
assert!((located.latitude - 42.36).abs() < f64::EPSILON);
assert_eq!(located.name, "Anywhere");
}
#[test]
fn a_blank_location_with_no_coordinates_is_an_error() {
let config = WeatherConfig {
location: " ".into(),
latitude: None,
longitude: None,
..Default::default()
};
let err = resolve_location(&config).expect_err("must fail");
assert!(err.to_string().contains("no location set"));
}
#[test]
fn every_forecast_column_fits_a_reasonable_panel() {
let grid = Grid::new(COLUMNS, 60);
assert!(grid.has("hour"));
assert!(grid.has("temp"));
assert!(grid.has("rain"));
}
#[test]
fn narrow_panels_drop_optional_columns_rather_than_squeezing() {
let grid = Grid::new(COLUMNS, 30);
assert!(grid.has("hour"), "the hour is the whole point of the row");
assert!(grid.has("temp"));
assert!(!grid.has("wind"));
assert!(!grid.has("feels"));
}
}