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("
");
    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("
"); } 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} {c}{d:.0}({c}{d:.0}) {s}\n", .{ art[1], temp_color, 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_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("{c}{d:.0}({c}{d:.0}) {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} {d:.0} {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 " \\ / ", " .-. ", " ― ( ) ― ", " `-' ", " / \\ ", }, 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 " ", " ? ", " ¯\\_(ツ)_/¯ ", " ", " ", }, }; } 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("hello", .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); }