1401 lines
60 KiB
Zig
1401 lines
60 KiB
Zig
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);
|
||
}
|