wttr/src/render/Formatted.zig

1401 lines
60 KiB
Zig
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

const std = @import("std");
const types = @import("../weather/types.zig");
const zeit = @import("zeit");
/// Select 4 hours representing morning (6am), noon (12pm), evening (6pm), night (12am) in LOCAL time
/// Hours in the hourly forecast are assumed to be all on the same day, in local time
/// Returns null for slots where no reasonable data is available (e.g., time has passed or no data)
fn selectHourlyForecasts(all_hours: []const types.HourlyForecast, buf: []?types.HourlyForecast) []?types.HourlyForecast {
if (all_hours.len == 0) return buf[0..0];
const target_hours = [_]u8{ 6, 12, 18, 0 }; // Local times we want
const max_diff_threshold = 3; // Only select if within 3 hours of target
var selected: std.ArrayList(?types.HourlyForecast) = .initBuffer(buf);
for (target_hours) |target_hour| {
// Find the hour closest to our target local time
var best_idx: ?usize = null;
var best_diff: i32 = 24;
for (all_hours, 0..) |hour, i| {
const local_hour: i32 = @intCast(hour.local_time.hour);
// Calculate difference from target
const diff: i32 = @intCast(@abs(local_hour - @as(i32, target_hour)));
const wrapped_diff: i32 = if (diff > 12) 24 - diff else diff;
if (wrapped_diff < best_diff) {
best_diff = wrapped_diff;
best_idx = i;
}
}
// Only use the match if it's within threshold
if (best_idx) |idx| {
if (best_diff <= max_diff_threshold) {
selected.appendAssumeCapacity(all_hours[idx]);
} else {
selected.appendAssumeCapacity(null);
}
} else {
selected.appendAssumeCapacity(null);
}
}
return selected.items;
}
fn degreeToArrow(deg: f32) []const u8 {
const normalized = @mod(deg + 22.5, 360.0);
const idx: usize = @intFromFloat(normalized / 45.0);
const arrows = [_][]const u8{ "", "", "", "", "", "", "", "" };
return arrows[@min(idx, 7)];
}
pub const Format = enum {
plain_text,
ansi,
html,
};
fn countInvisible(bytes: []const u8, format: Format) usize {
var count: usize = 0;
var i: usize = 0;
switch (format) {
.plain_text => for (bytes) |byte| { // Count multi-byte UTF-8 continuation bytes
if (byte & 0xC0 == 0x80) count += 1;
},
.ansi => while (i < bytes.len) {
if (bytes[i] == '\x1b' and i + 1 < bytes.len and bytes[i + 1] == '[') {
const start = i;
i += 2;
while (i < bytes.len and bytes[i] != 'm') : (i += 1) {}
if (i < bytes.len) i += 1;
count += i - start;
} else {
// Also count UTF-8 continuation bytes
if (bytes[i] & 0xC0 == 0x80) count += 1;
i += 1;
}
},
.html => while (i < bytes.len) {
if (bytes[i] == '<') {
const start = i;
while (i < bytes.len and bytes[i] != '>') : (i += 1) {}
if (i < bytes.len) i += 1;
count += i - start;
} else {
// Also count UTF-8 continuation bytes
if (bytes[i] & 0xC0 == 0x80) count += 1;
i += 1;
}
},
}
return count;
}
pub const RenderOptions = struct {
narrow: bool = false,
quiet: bool = false,
super_quiet: bool = false,
days: u8 = 3,
use_imperial: bool = false,
format: Format = .ansi,
};
pub fn render(writer: *std.Io.Writer, data: types.WeatherData, options: RenderOptions) !void {
const w = writer;
if (options.format == .html) try w.writeAll("<pre>");
if (!options.super_quiet)
try w.print(
"{s}{s}\n\n",
.{ if (!options.quiet) "Weather report: " else "", data.locationDisplayName() },
);
try renderCurrent(w, data.current, options);
const days_to_show = @min(options.days, data.forecast.len);
if (days_to_show > 0) {
try w.writeAll("\n");
for (data.forecast[0..days_to_show]) |day| {
try renderForecastDay(w, day, options);
}
}
if (options.format == .html) try w.writeAll("</pre>");
}
fn renderCurrent(w: *std.Io.Writer, current: types.CurrentCondition, options: RenderOptions) !void {
const temp = if (options.use_imperial) current.tempFahrenheit() else current.temp_c;
const feels_like = if (options.use_imperial) current.feelsLikeFahrenheit() else current.feels_like_c;
const temp_unit = if (options.use_imperial) "°F" else "°C";
const wind_unit = if (options.use_imperial) "mph" else "km/h";
const wind_speed = if (options.use_imperial) current.windMph() else current.wind_kph;
const precip_unit = if (options.use_imperial) "in" else "mm";
const precip = if (options.use_imperial) current.precip_mm * 0.0393701 else current.precip_mm;
const art = getWeatherArt(current.weather_code, options.format);
const sign: u8 = if (temp >= 0) '+' else '-';
const abs_temp = @abs(temp);
const fl_sign: u8 = if (feels_like >= 0) '+' else '-';
const abs_fl = @abs(feels_like);
switch (options.format) {
.plain_text => {
try w.print("{s} {s}\n", .{ art[0], current.condition });
try w.print("{s} {c}{d:.0}({c}{d:.0}) {s}\n", .{ art[1], sign, abs_temp, fl_sign, abs_fl, temp_unit });
try w.print("{s} {s} {d:.0} {s}\n", .{ art[2], degreeToArrow(current.wind_deg), wind_speed, wind_unit });
if (current.visibility_km) |_| {
const visibility = if (options.use_imperial) current.visiblityMph().? else current.visibility_km.?;
const vis_unit = if (options.use_imperial) "mi" else "km";
try w.print("{s} {d:.0} {s}\n", .{ art[3], visibility, vis_unit });
} else {
try w.print("{s}\n", .{std.mem.trimRight(u8, art[3], " ")});
}
try w.print("{s} {d:.1} {s}\n", .{ art[4], precip, precip_unit });
},
.ansi => {
const temp_color_code = tempColor(current.temp_c);
const wind_color_code = windColor(current.wind_kph);
const reset = "\x1b[0m";
try w.print("{s} {s}\n", .{ art[0], current.condition });
try w.print("{s} \x1b[38;5;{d}m{c}{d:.0}({c}{d:.0}){s} {s}\n", .{ art[1], temp_color_code, sign, abs_temp, fl_sign, abs_fl, reset, temp_unit });
try w.print("{s} {s} \x1b[38;5;{d}m{d:.0}{s} {s}\n", .{ art[2], degreeToArrow(current.wind_deg), wind_color_code, wind_speed, reset, wind_unit });
if (current.visibility_km) |_| {
const visibility = if (options.use_imperial) current.visiblityMph().? else current.visibility_km.?;
const vis_unit = if (options.use_imperial) "mi" else "km";
try w.print("{s} {d:.0} {s}\n", .{ art[3], visibility, vis_unit });
} else {
try w.print("{s}\n", .{std.mem.trimRight(u8, art[3], " ")});
}
try w.print("{s} {d:.1} {s}\n", .{ art[4], precip, precip_unit });
},
.html => {
const temp_color = ansiToHex(tempColor(current.temp_c));
const wind_color = ansiToHex(windColor(current.wind_kph));
try w.print("{s} {s}\n", .{ art[0], current.condition });
try w.print("{s} <span style=\"color:{s}\">{c}{d:.0}({c}{d:.0})</span> {s}\n", .{ art[1], temp_color, sign, abs_temp, fl_sign, abs_fl, temp_unit });
try w.print("{s} {s} <span style=\"color:{s}\">{d:.0}</span> {s}\n", .{ art[2], degreeToArrow(current.wind_deg), wind_color, wind_speed, wind_unit });
if (current.visibility_km) |_| {
const visibility = if (options.use_imperial) current.visiblityMph().? else current.visibility_km.?;
const vis_unit = if (options.use_imperial) "mi" else "km";
try w.print("{s} {d:.0} {s}\n", .{ art[3], visibility, vis_unit });
} else {
try w.print("{s}\n", .{std.mem.trimRight(u8, art[3], " ")});
}
try w.print("{s} {d:.1} {s}\n", .{ art[4], precip, precip_unit });
},
}
}
fn renderForecastDay(w: *std.Io.Writer, day: types.ForecastDay, options: RenderOptions) !void {
// Select 4 representative hours based on local timezone
var selected_hours_buf: [4]?types.HourlyForecast = undefined;
const selected_hours = selectHourlyForecasts(day.hourly, &selected_hours_buf);
var date_str: [11]u8 = undefined;
if (selected_hours.len < 4) {
const max_temp = if (options.use_imperial) day.maxTempFahrenheit() else day.max_temp_c;
const min_temp = if (options.use_imperial) day.minTempFahrenheit() else day.min_temp_c;
const temp_unit = if (options.use_imperial) "°F" else "°C";
const art = getWeatherArt(day.weather_code, options.format);
// Format date using gofmt: "Mon 2 Jan" (compressed)
const date_time = zeit.Time{ .year = day.date.year, .month = day.date.month, .day = day.date.day };
var date_stream = std.io.fixedBufferStream(&date_str);
try date_time.gofmt(date_stream.writer(), "Mon 2 Jan");
const date_len = date_stream.pos;
try w.print("\n{s}\n", .{date_str[0..date_len]});
try w.print("{s} {s}\n", .{ art[0], day.condition });
try w.print("{s} {d:.0}{s} / {d:.0}{s}\n", .{ art[1], max_temp, temp_unit, min_temp, temp_unit });
try w.print("{s}\n", .{std.mem.trimRight(u8, art[2], " ")});
try w.print("{s}\n", .{std.mem.trimRight(u8, art[3], " ")});
try w.print("{s}\n", .{std.mem.trimRight(u8, art[4], " ")});
return;
}
// Format date using gofmt: "Mon _2 Jan" (justified with space padding)
const date_time = zeit.Time{ .year = day.date.year, .month = day.date.month, .day = day.date.day };
var date_stream = std.io.fixedBufferStream(&date_str);
try date_time.gofmt(date_stream.writer(), "Mon _2 Jan");
const date_len = date_stream.pos;
if (!options.narrow) {
try w.writeAll(" ┌─────────────┐\n");
try w.print("┌──────────────────────────────┬───────────────────────┤ {s} ├───────────────────────┬──────────────────────────────┐\n", .{
date_str[0..date_len],
});
try w.writeAll("│ Morning │ Noon └──────┬──────┘ Evening │ Night │\n");
try w.writeAll("├──────────────────────────────┼──────────────────────────────┼──────────────────────────────┼──────────────────────────────┤\n");
} else {
// narrow mode
try w.writeAll(" ┌─────────────┐\n");
try w.print("┌───────────────────────┤ {s} ├──────────────────────┐\n", .{
date_str[0..date_len],
});
try w.writeAll("│ Noon └──────┬──────┘ Night │\n");
try w.writeAll("├──────────────────────────────┼─────────────────────────────┤\n");
}
const last_cell: u3 = if (options.narrow) 2 else 4;
for (0..5) |line| {
try w.writeAll("");
for (selected_hours[0..4], 0..) |maybe_hour, i| {
if (options.narrow and i % 2 == 0) continue;
if (maybe_hour) |hour|
try renderHourlyCell(w, hour, line, options)
else
try w.splatByteAll(' ', total_cell_width);
if (i < last_cell - 1) {
try w.writeAll("");
} else {
try w.writeAll("");
}
}
try w.writeAll("\n");
}
if (!options.narrow)
try w.writeAll("└──────────────────────────────┴──────────────────────────────┴──────────────────────────────┴──────────────────────────────┘\n")
else
try w.writeAll("└──────────────────────────────┴─────────────────────────────┘\n");
}
const total_cell_width = 28;
fn renderHourlyCell(w: *std.Io.Writer, hour: types.HourlyForecast, line: usize, options: RenderOptions) !void {
const Line = enum(u8) {
condition = 0,
temperature = 1,
wind = 2,
visibility = 3,
precipitation = 4,
};
const art = getWeatherArt(hour.weather_code, options.format);
const art_width = 14; // includes spacer between art and data. This is display width, not actual
var buf: [64]u8 = undefined; // We need more than total_width because total_width is display width, not bytes
var cell_writer = std.Io.Writer.fixed(&buf);
const cw = &cell_writer;
try w.print("{s} ", .{art[line]});
switch (@as(Line, @enumFromInt(line))) {
.condition => {
try cw.writeAll(hour.condition);
},
.temperature => {
const temp = if (options.use_imperial) hour.tempFahrenheit() else hour.temp_c;
const feels_like = if (options.use_imperial) hour.feelsLikeFahrenheit() else hour.feels_like_c;
const temp_unit = if (options.use_imperial) "°F" else "°C";
const sign: u8 = if (temp >= 0) '+' else '-';
const abs_temp = @abs(temp);
const fl_sign: u8 = if (feels_like >= 0) '+' else '-';
const abs_fl = @abs(feels_like);
switch (options.format) {
.ansi => {
const color = tempColor(hour.temp_c);
try cw.print("\x1b[38;5;{d}m{c}{d:.0}({c}{d:.0})\x1b[0m {s}", .{
color,
sign,
abs_temp,
fl_sign,
abs_fl,
temp_unit,
});
},
.html => {
const color = ansiToHex(tempColor(hour.temp_c));
try cw.print("<span style=\"color:{s}\">{c}{d:.0}({c}{d:.0})</span> {s}", .{
color,
sign,
abs_temp,
fl_sign,
abs_fl,
temp_unit,
});
},
.plain_text => {
try cw.print("{c}{d:.0}({c}{d:.0}) {s}", .{ sign, abs_temp, fl_sign, abs_fl, temp_unit });
},
}
},
.wind => {
const wind_speed = if (options.use_imperial) hour.windMph() else hour.wind_kph;
const wind_unit = if (options.use_imperial) "mph" else "km/h";
const arrow = degreeToArrow(hour.wind_deg);
switch (options.format) {
.ansi => {
const color = windColor(hour.wind_kph);
try cw.print("{s} \x1b[38;5;{d}m{d:.0}\x1b[0m {s}", .{ arrow, color, wind_speed, wind_unit });
},
.html => {
const color = ansiToHex(windColor(hour.wind_kph));
try cw.print("{s} <span style=\"color:{s}\">{d:.0}</span> {s}", .{ arrow, color, wind_speed, wind_unit });
},
.plain_text => {
try cw.print("{s} {d:.0} {s}", .{ arrow, wind_speed, wind_unit });
},
}
},
.visibility => {
if (hour.visibility_km) |_| {
const visibility = if (options.use_imperial) hour.visiblityMph().? else hour.visibility_km.?;
const vis_unit = if (options.use_imperial) "mi" else "km";
try cw.print("{d:.0} {s}", .{ visibility, vis_unit });
}
},
.precipitation => {
const precip = if (options.use_imperial) hour.precip_mm * 0.0393701 else hour.precip_mm;
const precip_unit = if (options.use_imperial) "in" else "mm";
try cw.print("{d:.1} {s} | 0%", .{ precip, precip_unit });
},
}
try cw.flush();
const buffered = cell_writer.buffered();
const invisible = countInvisible(buffered, options.format);
const display_width = art_width + buffered.len - invisible;
try w.writeAll(buffered);
try w.splatByteAll(
' ',
@max(@as(isize, @intCast(total_cell_width)) - @as(isize, @intCast(display_width)), 0),
);
}
fn tempColor(temp_c: f32) u8 {
const temp: i32 = @intFromFloat(@round(temp_c));
return switch (temp) {
-15, -14, -13 => 27,
-12, -11, -10 => 33,
-9, -8, -7 => 39,
-6, -5, -4 => 45,
-3, -2, -1 => 51,
0, 1 => 50,
2, 3 => 49,
4, 5 => 48,
6, 7 => 47,
8, 9 => 46,
10, 11, 12 => 82,
13, 14, 15 => 118,
16, 17, 18 => 154,
19, 20, 21 => 190,
22, 23, 24 => 226,
25, 26, 27 => 220,
28, 29, 30 => 214,
31, 32, 33 => 208,
34, 35, 36 => 202,
else => if (temp > 36) 196 else 21,
};
}
fn windColor(wind_kph: f32) u8 {
const wind: i32 = @intFromFloat(@round(wind_kph));
if (wind <= 3) return 241;
if (wind <= 6) return 242;
if (wind <= 9) return 243;
if (wind <= 12) return 246;
if (wind <= 15) return 250;
if (wind <= 19) return 253;
if (wind <= 23) return 214;
if (wind <= 27) return 208;
if (wind <= 31) return 202;
return 196;
}
fn ansiToHex(code: u8) []const u8 {
return switch (code) {
21 => "#0000ff",
27 => "#005fff",
33 => "#0087ff",
39 => "#00afff",
45 => "#00d7ff",
46 => "#00ffff",
47 => "#00ffd7",
48 => "#00ffaf",
49 => "#00ff87",
50 => "#00ff5f",
51 => "#00ff00",
82 => "#5fff00",
118 => "#87ff00",
154 => "#afff00",
190 => "#d7ff00",
196 => "#ff0000",
202 => "#ff5f00",
208 => "#ff8700",
214 => "#ffaf00",
220 => "#ffd700",
226 => "#ffff00",
241 => "#626262",
242 => "#6c6c6c",
243 => "#767676",
246 => "#949494",
250 => "#bcbcbc",
253 => "#dadada",
else => "#ffffff",
};
}
fn getWeatherArt(code: types.WeatherCode, format: Format) [5][]const u8 {
return switch (format) {
.plain_text => getWeatherArtPlain(code),
.ansi => getWeatherArtAnsi(code),
.html => getWeatherArtHtml(code),
};
}
fn getWeatherArtPlain(code: types.WeatherCode) [5][]const u8 {
return switch (@intFromEnum(code)) {
800 => .{ // Clear
" \\ / ",
" .-. ",
" ― ( ) ― ",
" `-' ",
" / \\ ",
},
801, 802 => .{ // Partly cloudy
" \\ / ",
" _ /\"\".-. ",
" \\_( ). ",
" /(___(__) ",
" ",
},
803, 804 => .{ // Cloudy
" ",
" .--. ",
" .-( ). ",
" (___.__)__) ",
" ",
},
300...321, 500...531 => .{ // Drizzle/Rain
" .-. ",
" ( ). ",
" (___(__) ",
" ʻ ʻ ʻ ʻ ",
" ʻ ʻ ʻ ʻ ",
},
200...232 => .{ // Thunderstorm
" .-. ",
" ( ). ",
" (___(__) ",
" ⚡ʻ⚡ʻ ",
" ʻ ʻ ʻ ",
},
600...610, 617...622 => .{ // Snow
" .-. ",
" ( ). ",
" (___(__) ",
" * * * ",
" * * * ",
},
611...616 => .{ // Sleet
" .-. ",
" ( ). ",
" (___(__) ",
" ʻ * ʻ * ",
" * ʻ * ʻ ",
},
701, 741 => .{ // Fog
" ",
" _ - _ - _ - ",
" _ - _ - _ ",
" _ - _ - _ - ",
" ",
},
else => .{ // Unknown
" ",
" ? ",
" ¯\\_(ツ)_/¯ ",
" ",
" ",
},
};
}
fn getWeatherArtAnsi(code: types.WeatherCode) [5][]const u8 {
return switch (@intFromEnum(code)) {
800 => .{ // Clear
"\x1b[38;5;226m \\ / \x1b[0m",
"\x1b[38;5;226m .-. \x1b[0m",
"\x1b[38;5;226m ― ( ) ― \x1b[0m",
"\x1b[38;5;226m `-' \x1b[0m",
"\x1b[38;5;226m / \\ \x1b[0m",
},
801, 802 => .{ // Partly cloudy
"\x1b[38;5;226m \\ /\x1b[0m ",
"\x1b[38;5;226m _ /\"\"\x1b[38;5;250m.-. \x1b[0m",
"\x1b[38;5;226m \\_\x1b[38;5;250m( ). \x1b[0m",
"\x1b[38;5;226m /\x1b[38;5;250m(___(__) \x1b[0m",
" ",
},
803, 804 => .{ // Cloudy
" ",
"\x1b[38;5;250m .--. \x1b[0m",
"\x1b[38;5;250m .-( ). \x1b[0m",
"\x1b[38;5;250m (___.__)__) \x1b[0m",
" ",
},
300...321, 500...531 => .{ // Drizzle/Rain
"\x1b[38;5;250m .-. \x1b[0m",
"\x1b[38;5;250m ( ). \x1b[0m",
"\x1b[38;5;250m (___(__) \x1b[0m",
"\x1b[38;5;111m ʻ ʻ ʻ ʻ \x1b[0m",
"\x1b[38;5;111m ʻ ʻ ʻ ʻ \x1b[0m",
},
200...232 => .{ // Thunderstorm
"\x1b[38;5;250m .-. \x1b[0m",
"\x1b[38;5;250m ( ). \x1b[0m",
"\x1b[38;5;250m (___(__) \x1b[0m",
"\x1b[38;5;228;5m ⚡\x1b[38;5;111;25mʻʻ\x1b[38;5;228;5m⚡\x1b[38;5;111;25mʻʻ\x1b[0m",
"\x1b[38;5;111m ʻ ʻ ʻ ʻ \x1b[0m",
},
600...610, 617...622 => .{ // Snow
"\x1b[38;5;250m .-. \x1b[0m",
"\x1b[38;5;250m ( ). \x1b[0m",
"\x1b[38;5;250m (___(__) \x1b[0m",
"\x1b[38;5;255m * * * \x1b[0m",
"\x1b[38;5;255m * * * \x1b[0m",
},
611...616 => .{ // Sleet
"\x1b[38;5;250m .-. \x1b[0m",
"\x1b[38;5;250m ( ). \x1b[0m",
"\x1b[38;5;250m (___(__) \x1b[0m",
"\x1b[38;5;111m ʻ \x1b[38;5;255m*\x1b[38;5;111m ʻ \x1b[38;5;255m* \x1b[0m",
"\x1b[38;5;255m *\x1b[38;5;111m ʻ \x1b[38;5;255m*\x1b[38;5;111m ʻ \x1b[0m",
},
701, 741 => .{ // Fog
" ",
"\x1b[38;5;251m _ - _ - _ - \x1b[0m",
"\x1b[38;5;251m _ - _ - _ \x1b[0m",
"\x1b[38;5;251m _ - _ - _ - \x1b[0m",
" ",
},
else => .{ // Unknown
" ",
" ? ",
" ¯\\_(ツ)_/¯ ",
" ",
" ",
},
};
}
fn getWeatherArtHtml(code: types.WeatherCode) [5][]const u8 {
return switch (@intFromEnum(code)) {
800 => .{ // Clear
"<span style=\"color:#ffff00\"> \\ / </span>",
"<span style=\"color:#ffff00\"> .-. </span>",
"<span style=\"color:#ffff00\"> ― ( ) ― </span>",
"<span style=\"color:#ffff00\"> `-' </span>",
"<span style=\"color:#ffff00\"> / \\ </span>",
},
801, 802 => .{ // Partly cloudy
"<span style=\"color:#ffff00\"> \\ / </span>",
"<span style=\"color:#bcbcbc\"> _ /\"\".-. </span>",
"<span style=\"color:#bcbcbc\"> \\_( ). </span>",
"<span style=\"color:#bcbcbc\"> /(___(__) </span>",
" ",
},
803, 804 => .{ // Cloudy
" ",
"<span style=\"color:#bcbcbc\"> .--. </span>",
"<span style=\"color:#bcbcbc\"> .-( ). </span>",
"<span style=\"color:#bcbcbc\"> (___.__)__) </span>",
" ",
},
300...321, 500...531 => .{ // Drizzle/Rain
"<span style=\"color:#bcbcbc\"> .-. </span>",
"<span style=\"color:#bcbcbc\"> ( ). </span>",
"<span style=\"color:#bcbcbc\"> (___(__) </span>",
"<span style=\"color:#87afff\"> ʻ ʻ ʻ ʻ </span>",
"<span style=\"color:#87afff\"> ʻ ʻ ʻ ʻ </span>",
},
200...232 => .{ // Thunderstorm
"<span style=\"color:#bcbcbc\"> .-. </span>",
"<span style=\"color:#bcbcbc\"> ( ). </span>",
"<span style=\"color:#bcbcbc\"> (___(__) </span>",
"<span style=\"color:#ffff87\"> ⚡</span><span style=\"color:#87afff\">ʻ</span><span style=\"color:#ffff87\">⚡</span><span style=\"color:#87afff\">ʻ </span>",
"<span style=\"color:#87afff\"> ʻ ʻ ʻ </span>",
},
600...610, 617...622 => .{ // Snow
"<span style=\"color:#bcbcbc\"> .-. </span>",
"<span style=\"color:#bcbcbc\"> ( ). </span>",
"<span style=\"color:#bcbcbc\"> (___(__) </span>",
"<span style=\"color:#eeeeee\"> * * * </span>",
"<span style=\"color:#eeeeee\"> * * * </span>",
},
611...616 => .{ // Sleet
"<span style=\"color:#bcbcbc\"> .-. </span>",
"<span style=\"color:#bcbcbc\"> ( ). </span>",
"<span style=\"color:#bcbcbc\"> (___(__) </span>",
"<span style=\"color:#87afff\"> ʻ </span><span style=\"color:#eeeeee\">*</span><span style=\"color:#87afff\"> ʻ </span><span style=\"color:#eeeeee\">* </span>",
"<span style=\"color:#eeeeee\"> * </span><span style=\"color:#87afff\">ʻ</span><span style=\"color:#eeeeee\"> * </span><span style=\"color:#87afff\">ʻ </span>",
},
701, 741 => .{ // Fog
" ",
"<span style=\"color:#c6c6c6\"> _ - _ - _ - </span>",
"<span style=\"color:#c6c6c6\"> _ - _ - _ </span>",
"<span style=\"color:#c6c6c6\"> _ - _ - _ - </span>",
" ",
},
else => .{ // Unknown
" ",
" ? ",
" ¯\\_(ツ)_/¯ ",
" ",
" ",
},
};
}
test "render with imperial units" {
const data = types.WeatherData{
.location = "Chicago",
.coords = .{ .latitude = 0, .longitude = 0 },
.current = .{
.temp_c = 10.0,
.feels_like_c = 10.0,
.condition = "Clear",
.weather_code = .clear,
.humidity = 60,
.wind_kph = 16.0,
.wind_deg = 0.0,
.pressure_mb = 1013.0,
.precip_mm = 0.0,
.visibility_km = null,
},
.forecast = &.{},
.allocator = std.testing.allocator,
};
var output_buf: [4096]u8 = undefined;
var writer = std.Io.Writer.fixed(&output_buf);
try render(&writer, data, .{ .use_imperial = true });
const output = output_buf[0..writer.end];
try std.testing.expect(std.mem.indexOf(u8, output, "+50") != null);
try std.testing.expect(std.mem.indexOf(u8, output, "°F") != null);
// 10°C should be color 82
try std.testing.expect(std.mem.indexOf(u8, output, "\x1b[38;5;82m") != null);
}
test "clear weather art" {
const data = types.WeatherData{
.location = "Test",
.coords = .{ .latitude = 0, .longitude = 0 },
.current = .{
.temp_c = 20.0,
.feels_like_c = 20.0,
.condition = "Clear",
.weather_code = .clear,
.humidity = 50,
.wind_kph = 10.0,
.wind_deg = 0.0,
.pressure_mb = 1013.0,
.precip_mm = 0.0,
.visibility_km = null,
},
.forecast = &.{},
.allocator = std.testing.allocator,
};
try testArt(data);
}
test "partly cloudy weather art" {
const data = types.WeatherData{
.location = "Test",
.coords = .{ .latitude = 0, .longitude = 0 },
.current = .{
.temp_c = 18.0,
.feels_like_c = 18.0,
.condition = "Partly cloudy",
.weather_code = .clouds_few,
.humidity = 55,
.wind_kph = 12.0,
.wind_deg = 45.0,
.pressure_mb = 1013.0,
.precip_mm = 0.0,
.visibility_km = null,
},
.forecast = &.{},
.allocator = std.testing.allocator,
};
try testArt(data);
}
/// Tests to make sure the weather art shows up in the rendering for all formats
fn testArt(data: types.WeatherData) !void {
inline for (std.meta.fields(Format)) |f| {
const format: Format = @enumFromInt(f.value);
var output_buf: [8192]u8 = undefined;
var writer = std.Io.Writer.fixed(&output_buf);
try render(&writer, data, .{ .format = format });
const output = output_buf[0..writer.end];
const target = getWeatherArt(
data.current.weather_code,
format,
);
for (target, 1..) |line, i| {
const trimmed = std.mem.trimRight(u8, line, " ");
std.testing.expect(std.mem.indexOf(u8, output, trimmed) != null) catch |e| {
std.log.err(
"Test failure, weather code {}, format {}, line {d}. Line '{s}', Output:\n{s}\n",
.{ data.current.weather_code, format, i, line, output },
);
return e;
};
}
}
}
test "cloudy weather art" {
const data = types.WeatherData{
.location = "Test",
.coords = .{ .latitude = 0, .longitude = 0 },
.current = .{
.temp_c = 15.0,
.feels_like_c = 15.0,
.condition = "Cloudy",
.weather_code = .clouds_overcast,
.humidity = 70,
.wind_kph = 15.0,
.wind_deg = 90.0,
.pressure_mb = 1010.0,
.precip_mm = 0.0,
.visibility_km = null,
},
.forecast = &.{},
.allocator = std.testing.allocator,
};
try testArt(data);
}
test "rain weather art" {
const data = types.WeatherData{
.location = "Test",
.coords = .{ .latitude = 0, .longitude = 0 },
.current = .{
.temp_c = 12.0,
.feels_like_c = 12.0,
.condition = "Rain",
.weather_code = .rain_moderate,
.humidity = 85,
.wind_kph = 20.0,
.wind_deg = 135.0,
.pressure_mb = 1005.0,
.precip_mm = 5.0,
.visibility_km = null,
},
.forecast = &.{},
.allocator = std.testing.allocator,
};
try testArt(data);
}
test "thunderstorm weather art" {
const data = types.WeatherData{
.location = "Test",
.coords = .{ .latitude = 0, .longitude = 0 },
.current = .{
.temp_c = 14.0,
.feels_like_c = 14.0,
.condition = "Thunderstorm",
.weather_code = .thunderstorm,
.humidity = 90,
.wind_kph = 30.0,
.wind_deg = 180.0,
.pressure_mb = 1000.0,
.precip_mm = 10.0,
.visibility_km = null,
},
.forecast = &.{},
.allocator = std.testing.allocator,
};
try testArt(data);
}
test "snow weather art" {
const data = types.WeatherData{
.location = "Test",
.coords = .{ .latitude = 0, .longitude = 0 },
.current = .{
.temp_c = -2.0,
.feels_like_c = -2.0,
.condition = "Snow",
.weather_code = .snow,
.humidity = 80,
.wind_kph = 18.0,
.wind_deg = 225.0,
.pressure_mb = 1008.0,
.precip_mm = 3.0,
.visibility_km = null,
},
.forecast = &.{},
.allocator = std.testing.allocator,
};
try testArt(data);
}
test "sleet weather art" {
const data = types.WeatherData{
.location = "Test",
.coords = .{ .latitude = 0, .longitude = 0 },
.current = .{
.temp_c = 0.0,
.feels_like_c = 0.0,
.condition = "Sleet",
.weather_code = .sleet,
.humidity = 75,
.wind_kph = 22.0,
.wind_deg = 270.0,
.pressure_mb = 1007.0,
.precip_mm = 2.0,
.visibility_km = null,
},
.forecast = &.{},
.allocator = std.testing.allocator,
};
try testArt(data);
}
test "fog weather art" {
const data = types.WeatherData{
.location = "Test",
.coords = .{ .latitude = 0, .longitude = 0 },
.current = .{
.temp_c = 8.0,
.feels_like_c = 8.0,
.condition = "Fog",
.weather_code = .fog,
.humidity = 95,
.wind_kph = 5.0,
.wind_deg = 315.0,
.pressure_mb = 1012.0,
.precip_mm = 0.0,
.visibility_km = null,
},
.forecast = &.{},
.allocator = std.testing.allocator,
};
try testArt(data);
}
test "unknown weather code art" {
const data = types.WeatherData{
.location = "Test",
.coords = .{ .latitude = 0, .longitude = 0 },
.current = .{
.temp_c = 16.0,
.feels_like_c = 16.0,
.condition = "Unknown",
.weather_code = .unknown,
.humidity = 60,
.wind_kph = 10.0,
.wind_deg = 0.0,
.pressure_mb = 1013.0,
.precip_mm = 0.0,
.visibility_km = null,
},
.forecast = &.{},
.allocator = std.testing.allocator,
};
try testArt(data);
}
test "temperature matches between ansi and custom format" {
const custom = @import("Custom.zig");
const data = types.WeatherData{
.location = "PDX",
.coords = .{ .latitude = 0, .longitude = 0 },
.current = .{
.temp_c = 13.1,
.feels_like_c = 13.1,
.condition = "Clear",
.weather_code = .clear,
.humidity = 60,
.wind_kph = 10.0,
.wind_deg = 0.0,
.pressure_mb = 1013.0,
.precip_mm = 0.0,
.visibility_km = null,
},
.forecast = &.{},
.allocator = std.testing.allocator,
};
var ansi_buf: [4096]u8 = undefined;
var ansi_writer = std.Io.Writer.fixed(&ansi_buf);
try render(&ansi_writer, data, .{ .use_imperial = true });
const ansi_output = ansi_buf[0..ansi_writer.end];
var custom_buf: [1024]u8 = undefined;
var custom_writer = std.Io.Writer.fixed(&custom_buf);
try custom.render(&custom_writer, data, "%t", true);
const output = custom_buf[0..custom_writer.end];
// ANSI rounds to integer, custom shows decimal
try std.testing.expect(std.mem.indexOf(u8, ansi_output, "+56") != null);
try std.testing.expect(std.mem.indexOf(u8, output, "55.6°F") != null);
}
test "tempColor returns correct colors for temperature ranges" {
// Very cold
try std.testing.expectEqual(@as(u8, 27), tempColor(-15));
try std.testing.expectEqual(@as(u8, 27), tempColor(-13));
try std.testing.expectEqual(@as(u8, 33), tempColor(-12));
try std.testing.expectEqual(@as(u8, 33), tempColor(-10));
// Cold
try std.testing.expectEqual(@as(u8, 39), tempColor(-9));
try std.testing.expectEqual(@as(u8, 45), tempColor(-6));
try std.testing.expectEqual(@as(u8, 51), tempColor(-3));
// Cool
try std.testing.expectEqual(@as(u8, 50), tempColor(0));
try std.testing.expectEqual(@as(u8, 49), tempColor(2));
try std.testing.expectEqual(@as(u8, 48), tempColor(4));
try std.testing.expectEqual(@as(u8, 46), tempColor(8));
// Mild
try std.testing.expectEqual(@as(u8, 82), tempColor(10));
try std.testing.expectEqual(@as(u8, 118), tempColor(13));
try std.testing.expectEqual(@as(u8, 118), tempColor(15));
// Warm
try std.testing.expectEqual(@as(u8, 154), tempColor(16));
try std.testing.expectEqual(@as(u8, 190), tempColor(20));
try std.testing.expectEqual(@as(u8, 226), tempColor(23));
try std.testing.expectEqual(@as(u8, 220), tempColor(26));
// Hot
try std.testing.expectEqual(@as(u8, 214), tempColor(29));
try std.testing.expectEqual(@as(u8, 208), tempColor(32));
try std.testing.expectEqual(@as(u8, 202), tempColor(35));
// Very hot
try std.testing.expectEqual(@as(u8, 196), tempColor(37));
try std.testing.expectEqual(@as(u8, 196), tempColor(50));
// Very cold (below range)
try std.testing.expectEqual(@as(u8, 21), tempColor(-20));
}
test "windColor returns correct colors for wind speed ranges" {
// Calm
try std.testing.expectEqual(@as(u8, 241), windColor(0));
try std.testing.expectEqual(@as(u8, 241), windColor(3));
// Light breeze
try std.testing.expectEqual(@as(u8, 242), windColor(4));
try std.testing.expectEqual(@as(u8, 242), windColor(6));
try std.testing.expectEqual(@as(u8, 243), windColor(7));
try std.testing.expectEqual(@as(u8, 243), windColor(9));
// Moderate
try std.testing.expectEqual(@as(u8, 246), windColor(10));
try std.testing.expectEqual(@as(u8, 246), windColor(12));
try std.testing.expectEqual(@as(u8, 250), windColor(13));
try std.testing.expectEqual(@as(u8, 250), windColor(15));
try std.testing.expectEqual(@as(u8, 253), windColor(16));
try std.testing.expectEqual(@as(u8, 253), windColor(19));
// Fresh
try std.testing.expectEqual(@as(u8, 214), windColor(20));
try std.testing.expectEqual(@as(u8, 214), windColor(23));
try std.testing.expectEqual(@as(u8, 208), windColor(24));
try std.testing.expectEqual(@as(u8, 208), windColor(27));
// Strong
try std.testing.expectEqual(@as(u8, 202), windColor(28));
try std.testing.expectEqual(@as(u8, 202), windColor(31));
try std.testing.expectEqual(@as(u8, 196), windColor(32));
try std.testing.expectEqual(@as(u8, 196), windColor(50));
}
test "plain text format - MetNo real data" {
const allocator = std.testing.allocator;
const MetNo = @import("../weather/MetNo.zig");
const json_data = @embedFile("../tests/metno_test_data.json");
const weather_data = try MetNo.parse(undefined, allocator, json_data);
defer weather_data.deinit();
var output_buf: [8192]u8 = undefined;
var writer = std.Io.Writer.fixed(&output_buf);
try render(&writer, weather_data, .{ .format = .plain_text, .days = 3 });
const output = output_buf[0..writer.end];
const expected =
\\Weather report: 47.6038,-122.3301
\\
\\ .-. Light rain
\\ ( ). +7(+7) °C
\\ (___(__) ← 6 km/h
\\ ʻ ʻ ʻ ʻ
\\ ʻ ʻ ʻ ʻ 0.0 mm
\\
\\ ┌─────────────┐
\\┌──────────────────────────────┬───────────────────────┤ Fri 2 Jan ├───────────────────────┬──────────────────────────────┐
\\│ Morning │ Noon └──────┬──────┘ Evening │ Night │
\\├──────────────────────────────┼──────────────────────────────┼──────────────────────────────┼──────────────────────────────┤
\\│ │ .-. Light rain │ .-. Rain │ \ / Partly cloudy │
\\│ │ ( ). +7(+7) °C │ ( ). +7(+7) °C │ _ /"".-. +6(+6) °C │
\\│ │ (___(__) ← 6 km/h │ (___(__) ← 7 km/h │ \_( ). ↙ 7 km/h │
\\│ │ ʻ ʻ ʻ ʻʻ ʻ ʻ ʻ │ /(___(__) │
\\│ │ ʻ ʻ ʻ ʻ 0.2 mm | 0% │ ʻ ʻ ʻ ʻ 0.7 mm | 0% │ 0.0 mm | 0% │
\\└──────────────────────────────┴──────────────────────────────┴──────────────────────────────┴──────────────────────────────┘
\\ ┌─────────────┐
\\┌──────────────────────────────┬───────────────────────┤ Sat 3 Jan ├───────────────────────┬──────────────────────────────┐
\\│ Morning │ Noon └──────┬──────┘ Evening │ Night │
\\├──────────────────────────────┼──────────────────────────────┼──────────────────────────────┼──────────────────────────────┤
\\│ .-. Rain │ \ / Partly cloudy │ Cloudy │ \ / Fair │
\\│ ( ). +7(+7) °C │ _ /"".-. +11(+11) °C │ .--. +9(+9) °C │ _ /"".-. +5(+5) °C │
\\│ (___(__) ↖ 14 km/h │ \_( ). ↗ 12 km/h │ .-( ). ↙ 15 km/h │ \_( ). ↓ 9 km/h │
\\│ ʻ ʻ ʻ ʻ │ /(___(__) │ (___.__)__) │ /(___(__) │
\\│ ʻ ʻ ʻ ʻ 0.8 mm | 0% │ 0.0 mm | 0% │ 0.0 mm | 0% │ 0.0 mm | 0% │
\\└──────────────────────────────┴──────────────────────────────┴──────────────────────────────┴──────────────────────────────┘
\\ ┌─────────────┐
\\┌──────────────────────────────┬───────────────────────┤ Sun 4 Jan ├───────────────────────┬──────────────────────────────┐
\\│ Morning │ Noon └──────┬──────┘ Evening │ Night │
\\├──────────────────────────────┼──────────────────────────────┼──────────────────────────────┼──────────────────────────────┤
\\│ \ / Partly cloudy │ .-. Heavy rain │ Cloudy │ \ / Partly cloudy │
\\│ _ /"".-. +9(+9) °C │ ( ). +8(+8) °C │ .--. +7(+7) °C │ _ /"".-. +7(+7) °C │
\\│ \_( ). ↑ 32 km/h │ (___(__) ↑ 23 km/h │ .-( ). ↗ 27 km/h │ \_( ). ↖ 19 km/h │
\\│ /(___(__) │ ʻ ʻ ʻ ʻ │ (___.__)__) │ /(___(__) │
\\│ 0.0 mm | 0% │ ʻ ʻ ʻ ʻ 1.2 mm | 0% │ 0.0 mm | 0% │ 0.0 mm | 0% │
\\└──────────────────────────────┴──────────────────────────────┴──────────────────────────────┴──────────────────────────────┘
\\
;
try std.testing.expectEqualStrings(expected, output);
}
test "selectHourlyForecasts - MetNo real data verification" {
const allocator = std.testing.allocator;
const MetNo = @import("../weather/MetNo.zig");
const json_data = @embedFile("../tests/metno_test_data.json");
const weather_data = try MetNo.parse(undefined, allocator, json_data);
defer weather_data.deinit();
// Verify we have 3 forecast days
try std.testing.expectEqual(@as(usize, 3), weather_data.forecast.len);
// Friday, 2 Jan - partial day (hours 15-23)
try std.testing.expectEqual(@as(usize, 9), weather_data.forecast[0].hourly.len);
var fri_selected_buf: [4]?types.HourlyForecast = undefined;
const fri_selected = selectHourlyForecasts(weather_data.forecast[0].hourly, &fri_selected_buf);
try std.testing.expectEqual(@as(usize, 4), fri_selected.len);
// Morning slot should be null (no data near 6am)
try std.testing.expect(fri_selected[0] == null);
// Noon slot should have hour 15 (closest to 12pm, within 3-hour threshold)
try std.testing.expect(fri_selected[1] != null);
try std.testing.expectApproxEqAbs(@as(f32, 6.5), fri_selected[1].?.temp_c, 0.1);
// Evening slot should have hour 18 (exact match for 6pm)
try std.testing.expect(fri_selected[2] != null);
try std.testing.expectApproxEqAbs(@as(f32, 6.7), fri_selected[2].?.temp_c, 0.1);
// Night slot should have hour 23 (closest to midnight, within threshold)
try std.testing.expect(fri_selected[3] != null);
try std.testing.expectApproxEqAbs(@as(f32, 5.5), fri_selected[3].?.temp_c, 0.1);
// Saturday, 3 Jan - full day, verify specific hours
var sat_selected_buf: [4]?types.HourlyForecast = undefined;
const sat_selected = selectHourlyForecasts(weather_data.forecast[1].hourly, &sat_selected_buf);
// All slots should have data with exact matches
try std.testing.expect(sat_selected[0] != null); // Morning
try std.testing.expectEqual(@as(u5, 6), sat_selected[0].?.local_time.hour);
try std.testing.expectApproxEqAbs(@as(f32, 7.4), sat_selected[0].?.temp_c, 0.1);
try std.testing.expect(sat_selected[1] != null); // Noon
try std.testing.expectEqual(@as(u5, 12), sat_selected[1].?.local_time.hour);
try std.testing.expectApproxEqAbs(@as(f32, 10.5), sat_selected[1].?.temp_c, 0.1);
try std.testing.expect(sat_selected[2] != null); // Evening
try std.testing.expectEqual(@as(u5, 18), sat_selected[2].?.local_time.hour);
try std.testing.expectApproxEqAbs(@as(f32, 8.6), sat_selected[2].?.temp_c, 0.1);
try std.testing.expect(sat_selected[3] != null); // Night
try std.testing.expectEqual(@as(u5, 0), sat_selected[3].?.local_time.hour);
try std.testing.expectApproxEqAbs(@as(f32, 4.9), sat_selected[3].?.temp_c, 0.1);
// Sunday, 4 Jan - full day
var sun_selected_buf: [4]?types.HourlyForecast = undefined;
const sun_selected = selectHourlyForecasts(weather_data.forecast[2].hourly, &sun_selected_buf);
// All slots should have data with exact matches
try std.testing.expect(sun_selected[0] != null); // Morning
try std.testing.expectEqual(@as(u5, 6), sun_selected[0].?.local_time.hour);
try std.testing.expectApproxEqAbs(@as(f32, 8.9), sun_selected[0].?.temp_c, 0.1);
try std.testing.expect(sun_selected[1] != null); // Noon
try std.testing.expectEqual(@as(u5, 12), sun_selected[1].?.local_time.hour);
try std.testing.expectApproxEqAbs(@as(f32, 8.3), sun_selected[1].?.temp_c, 0.1);
try std.testing.expect(sun_selected[2] != null); // Evening
try std.testing.expectEqual(@as(u5, 18), sun_selected[2].?.local_time.hour);
try std.testing.expectApproxEqAbs(@as(f32, 7.0), sun_selected[2].?.temp_c, 0.1);
try std.testing.expect(sun_selected[3] != null); // Night
try std.testing.expectEqual(@as(u5, 0), sun_selected[3].?.local_time.hour);
try std.testing.expectApproxEqAbs(@as(f32, 7.4), sun_selected[3].?.temp_c, 0.1);
}
test "selectHourlyForecasts - MetNo Phoenix data verification" {
const allocator = std.testing.allocator;
const MetNo = @import("../weather/MetNo.zig");
const json_data = @embedFile("../tests/metno-phoenix.json");
const weather_data = try MetNo.parse(undefined, allocator, json_data);
defer weather_data.deinit();
// Verify we have 3 forecast days
try std.testing.expectEqual(@as(usize, 3), weather_data.forecast.len);
// Day 0 - partial day (only 3 hours: 21, 22, 23)
try std.testing.expectEqual(@as(usize, 3), weather_data.forecast[0].hourly.len);
var day0_selected_buf: [4]?types.HourlyForecast = undefined;
const day0_selected = selectHourlyForecasts(weather_data.forecast[0].hourly, &day0_selected_buf);
try std.testing.expectEqual(@as(usize, 4), day0_selected.len);
// Morning and Noon slots should be null (no data)
try std.testing.expect(day0_selected[0] == null);
try std.testing.expect(day0_selected[1] == null);
// Evening slot should have hour 21 (closest to 18, within threshold)
try std.testing.expect(day0_selected[2] != null);
try std.testing.expectEqual(@as(u5, 21), day0_selected[2].?.local_time.hour);
try std.testing.expectApproxEqAbs(@as(f32, 16.0), day0_selected[2].?.temp_c, 0.1);
// Night slot should have hour 23 (closest to 0, within threshold)
try std.testing.expect(day0_selected[3] != null);
try std.testing.expectEqual(@as(u5, 23), day0_selected[3].?.local_time.hour);
try std.testing.expectApproxEqAbs(@as(f32, 15.1), day0_selected[3].?.temp_c, 0.1);
// Day 1 - full day
var day1_selected_buf: [4]?types.HourlyForecast = undefined;
const day1_selected = selectHourlyForecasts(weather_data.forecast[1].hourly, &day1_selected_buf);
// All slots should have data
try std.testing.expect(day1_selected[0] != null);
try std.testing.expectEqual(@as(u5, 6), day1_selected[0].?.local_time.hour);
try std.testing.expectApproxEqAbs(@as(f32, 10.2), day1_selected[0].?.temp_c, 0.1);
try std.testing.expect(day1_selected[1] != null);
try std.testing.expectEqual(@as(u5, 12), day1_selected[1].?.local_time.hour);
try std.testing.expectApproxEqAbs(@as(f32, 18.5), day1_selected[1].?.temp_c, 0.1);
try std.testing.expect(day1_selected[2] != null);
try std.testing.expectEqual(@as(u5, 18), day1_selected[2].?.local_time.hour);
try std.testing.expectApproxEqAbs(@as(f32, 18.2), day1_selected[2].?.temp_c, 0.1);
try std.testing.expect(day1_selected[3] != null);
try std.testing.expectEqual(@as(u5, 0), day1_selected[3].?.local_time.hour);
try std.testing.expectApproxEqAbs(@as(f32, 14.8), day1_selected[3].?.temp_c, 0.1);
// Day 2 - full day
var day2_selected_buf: [4]?types.HourlyForecast = undefined;
const day2_selected = selectHourlyForecasts(weather_data.forecast[2].hourly, &day2_selected_buf);
// All slots should have data
try std.testing.expect(day2_selected[0] != null);
try std.testing.expectEqual(@as(u5, 6), day2_selected[0].?.local_time.hour);
try std.testing.expectApproxEqAbs(@as(f32, 10.8), day2_selected[0].?.temp_c, 0.1);
try std.testing.expect(day2_selected[1] != null);
try std.testing.expectEqual(@as(u5, 12), day2_selected[1].?.local_time.hour);
try std.testing.expectApproxEqAbs(@as(f32, 17.7), day2_selected[1].?.temp_c, 0.1);
try std.testing.expect(day2_selected[2] != null);
try std.testing.expectEqual(@as(u5, 18), day2_selected[2].?.local_time.hour);
try std.testing.expectApproxEqAbs(@as(f32, 17.2), day2_selected[2].?.temp_c, 0.1);
try std.testing.expect(day2_selected[3] != null);
try std.testing.expectEqual(@as(u5, 0), day2_selected[3].?.local_time.hour);
try std.testing.expectApproxEqAbs(@as(f32, 13.6), day2_selected[3].?.temp_c, 0.1);
}
test "ansi format - MetNo real data - phoenix" {
const allocator = std.testing.allocator;
const MetNo = @import("../weather/MetNo.zig");
const json_data = @embedFile("../tests/metno-phoenix.json");
const weather_data = try MetNo.parse(undefined, allocator, json_data);
defer weather_data.deinit();
var output_buf: [16384]u8 = undefined;
var writer = std.Io.Writer.fixed(&output_buf);
try render(&writer, weather_data, .{ .format = .ansi, .days = 3, .use_imperial = true });
const output = output_buf[0..writer.end];
const expected = @embedFile("../tests/metno-phoenix.ansi");
try std.testing.expectEqualStrings(expected, output);
}
test "countInvisible - plain text with UTF-8" {
const testing = std.testing;
try testing.expectEqual(0, countInvisible("hello", .plain_text));
try testing.expectEqual(1, countInvisible("°C", .plain_text)); // ° is 2 bytes
try testing.expectEqual(2, countInvisible("", .plain_text)); // ↑ is 3 bytes
try testing.expectEqual(3, countInvisible("°C ↑", .plain_text));
}
test "countInvisible - ANSI escape sequences" {
const testing = std.testing;
try testing.expectEqual(0, countInvisible("hello", .ansi));
try testing.expectEqual(14, countInvisible("\x1b[38;5;82mhello\x1b[0m", .ansi));
try testing.expectEqual(1, countInvisible("°C", .ansi)); // UTF-8 still counted
try testing.expectEqual(15, countInvisible("\x1b[38;5;82m°C\x1b[0m", .ansi));
}
test "countInvisible - HTML tags" {
const testing = std.testing;
try testing.expectEqual(0, countInvisible("hello", .html));
try testing.expectEqual(25, countInvisible("<span class=\"c82\">hello</span>", .html));
try testing.expectEqual(1, countInvisible("°C", .html)); // UTF-8 still counted
}
test "countInvisible - ansi full string" {
const str = "\x1b[38;5;250m .--. \x1b[0m \x1b[38;5;154m+61(+61)\x1b[0m °F ";
try std.testing.expectEqual(28, str.len - countInvisible(str, .ansi));
}
test "countInvisible - ansi formatted" {
const str = "\x1b[38;5;154m+61(+61)\x1b[0m °F";
try std.testing.expectEqual(11, str.len - countInvisible(str, .ansi));
}
test "selectHourlyForecasts - selects correct hours" {
const allocator = std.testing.allocator;
// Create hourly data for a full day (UTC times)
var hours: std.ArrayList(types.HourlyForecast) = .empty;
defer {
for (hours.items) |h| {
// time is now zeit.Time (no allocation to free)
allocator.free(h.condition);
}
hours.deinit(allocator);
}
// Add hours from 00:00 to 23:00 UTC (with corresponding local times for NYC UTC-5)
for (0..24) |i| {
const utc_hour: u5 = @intCast(i);
const local_hour: u5 = @intCast(@mod(@as(i32, @intCast(i)) - 5, 24)); // UTC-5
try hours.append(allocator, .{
.time = zeit.Time{ .hour = utc_hour, .minute = 0 },
.local_time = zeit.Time{ .hour = local_hour, .minute = 0 },
.temp_c = 20.0,
.feels_like_c = 20.0,
.condition = try allocator.dupe(u8, "Clear"),
.weather_code = .clear,
.wind_kph = 10.0,
.wind_deg = 180.0,
.precip_mm = 0.0,
.visibility_km = 10.0,
});
}
var selected_buf: [4]?types.HourlyForecast = undefined;
const selected = selectHourlyForecasts(hours.items, &selected_buf);
// Should select 4 hours closest to 6am, noon, 6pm, midnight local
// 6am local = 11:00 UTC, noon local = 17:00 UTC, 6pm local = 23:00 UTC, midnight local = 05:00 UTC
try std.testing.expectEqual(@as(usize, 4), selected.len);
try std.testing.expectEqual(@as(u5, 11), selected[0].?.time.hour); // Morning (6am local)
try std.testing.expectEqual(@as(u5, 17), selected[1].?.time.hour); // Noon (12pm local)
try std.testing.expectEqual(@as(u5, 23), selected[2].?.time.hour); // Evening (6pm local)
try std.testing.expectEqual(@as(u5, 5), selected[3].?.time.hour); // Night (midnight local)
try std.testing.expectEqual(@as(u5, 6), selected[0].?.local_time.hour); // Morning (6am local)
try std.testing.expectEqual(@as(u5, 12), selected[1].?.local_time.hour); // Noon (12pm local)
try std.testing.expectEqual(@as(u5, 18), selected[2].?.local_time.hour); // Evening (6pm local)
try std.testing.expectEqual(@as(u5, 0), selected[3].?.local_time.hour); // Night (midnight local)
}
test "selectHourlyForecasts - handles empty input" {
const empty: []types.HourlyForecast = &[_]types.HourlyForecast{};
var selected_buf: [4]?types.HourlyForecast = undefined;
const selected = selectHourlyForecasts(empty, &selected_buf);
try std.testing.expectEqual(@as(usize, 0), selected.len);
}
test "selectHourlyForecasts - falls back to evenly spaced" {
const allocator = std.testing.allocator;
// Create only 6 hours, none matching our targets well
var hours: std.ArrayList(types.HourlyForecast) = .empty;
defer {
for (hours.items) |h| {
// time is now zeit.Time (no allocation to free)
allocator.free(h.condition);
}
hours.deinit(allocator);
}
for (0..6) |i| {
try hours.append(allocator, .{
.time = zeit.Time{ .hour = @intCast(i * 4), .minute = 0 },
.local_time = zeit.Time{ .hour = @intCast(i * 4), .minute = 0 }, // Same as UTC for this test
.temp_c = 20.0,
.feels_like_c = 20.0,
.condition = try allocator.dupe(u8, "Clear"),
.weather_code = .clear,
.wind_kph = 10.0,
.wind_deg = 180.0,
.precip_mm = 0.0,
.visibility_km = 10.0,
});
}
var selected_buf: [4]?types.HourlyForecast = undefined;
const selected = selectHourlyForecasts(hours.items, &selected_buf);
try std.testing.expectEqual(@as(usize, 4), selected.len);
// With hours at 0,4,8,12,16,20 and targets 6,12,18,0:
// - Target 6: closest is 4 or 8 (diff=2), within threshold
// - Target 12: exact match at 12
// - Target 18: closest is 16 or 20 (diff=2), within threshold
// - Target 0: exact match at 0
// All should have data
try std.testing.expect(selected[0] != null);
try std.testing.expect(selected[1] != null);
try std.testing.expect(selected[2] != null);
try std.testing.expect(selected[3] != null);
}