const std = @import("std");
const types = @import("../weather/types.zig");
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,
days: u8 = 3,
use_imperial: bool = false,
no_caption: bool = false,
format: Format = .ansi,
};
pub fn render(allocator: std.mem.Allocator, data: types.WeatherData, options: RenderOptions) ![]const u8 {
var output = std.Io.Writer.Allocating.init(allocator);
defer output.deinit();
const w = &output.writer;
if (!options.no_caption)
try w.print("Weather report: {s}\n\n", .{data.location});
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);
}
}
return output.toOwnedSlice();
}
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);
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 cloud_color = "\x1b[38;5;250m";
const reset = "\x1b[0m";
try w.print("{s}{s}{s} {s}\n", .{ cloud_color, art[0], reset, current.condition });
try w.print("{s}{s}{s} \x1b[38;5;{d}m{c}{d:.0}({c}{d:.0}){s} {s}\n", .{ cloud_color, art[1], reset, temp_color_code, sign, abs_temp, fl_sign, abs_fl, reset, temp_unit });
try w.print("{s}{s}{s} {s} \x1b[38;5;{d}m{d:.0}{s} {s}\n", .{ cloud_color, art[2], reset, 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}{s}{s} {d:.0} {s}\n", .{ cloud_color, art[3], reset, visibility, vis_unit });
} else {
try w.print("{s}{s}{s}\n", .{ cloud_color, std.mem.trimRight(u8, art[3], " "), reset });
}
try w.print("{s}{s}{s} {d:.1} {s}\n", .{ cloud_color, art[4], reset, precip, precip_unit });
},
.html => {
const temp_color = ansiToHex(tempColor(current.temp_c));
const wind_color = ansiToHex(windColor(current.wind_kph));
const cloud_color = "#bcbcbc"; // 250
try w.print("{s} {s}\n", .{ cloud_color, art[0], current.condition });
try w.print("{s} {c}{d:.0}({c}{d:.0}) {s}\n", .{ cloud_color, art[1], temp_color, sign, abs_temp, fl_sign, abs_fl, temp_unit });
try w.print("{s} {s} {d:.0} {s}\n", .{ cloud_color, 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", .{ cloud_color, art[3], visibility, vis_unit });
} else {
try w.print("{s}\n", .{ cloud_color, std.mem.trimRight(u8, art[3], " ") });
}
try w.print("{s} {d:.1} {s}\n", .{ cloud_color, art[4], precip, precip_unit });
},
}
}
fn renderForecastDay(w: *std.Io.Writer, day: types.ForecastDay, options: RenderOptions) !void {
var date_str: [11]u8 = undefined;
if (day.hourly.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);
_ = try formatDate(day.date, .compressed, &date_str);
try w.print("\n{s}\n", .{std.mem.trimEnd(u8, date_str[0..], " ")});
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;
}
const formatted_date = try formatDate(day.date, .justified, &date_str);
try w.writeAll(" ┌─────────────┐\n");
try w.print("┌──────────────────────────────┬───────────────────────┤ {s} ├───────────────────────┬──────────────────────────────┐\n", .{
std.mem.trimEnd(u8, formatted_date, " "),
});
try w.writeAll("│ Morning │ Noon └──────┬──────┘ Evening │ Night │\n");
try w.writeAll("├──────────────────────────────┼──────────────────────────────┼──────────────────────────────┼──────────────────────────────┤\n");
for (0..5) |line| {
try w.writeAll("│ ");
for (day.hourly[0..4], 0..) |hour, i| {
try renderHourlyCell(w, hour, line, options);
if (i < 3) {
try w.writeAll(" │ ");
} else {
try w.writeAll(" │");
}
}
try w.writeAll("\n");
}
try w.writeAll("└──────────────────────────────┴──────────────────────────────┴──────────────────────────────┴──────────────────────────────┘\n");
}
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);
const total_width = 28;
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;
switch (options.format) {
.ansi => {
try w.print("\x1b[38;5;250m{s}\x1b[0m ", .{art[line]});
},
.html => {
try w.print("{s} ", .{art[line]});
},
.plain_text => {
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_width)) - @as(isize, @intCast(display_width)), 0),
);
}
const DateFormat = enum {
justified,
compressed,
};
/// The return value from this function will always be exactly 11 characters long, padded at the
/// end with any necessary spaces
fn formatDate(iso_date: []const u8, comptime date_format: DateFormat, date_str_out: []u8) ![]u8 {
const days = [_][]const u8{ "Sat", "Sun", "Mon", "Tue", "Wed", "Thu", "Fri" };
const months = [_][]const u8{ "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec" };
const year = try std.fmt.parseInt(i32, iso_date[0..4], 10);
const month = try std.fmt.parseInt(u8, iso_date[5..7], 10);
const day = try std.fmt.parseInt(u8, iso_date[8..10], 10);
var y = year;
var m: i32 = month;
if (m < 3) {
m += 12;
y -= 1;
}
const dow = @mod((day + @divFloor((13 * (m + 1)), 5) + y + @divFloor(y, 4) - @divFloor(y, 100) + @divFloor(y, 400)), 7);
const day_format = if (date_format == .justified) "{d:>2}" else "{d}";
const written = try std.fmt.bufPrint(
date_str_out,
"{s} " ++ day_format ++ " {s}",
.{ days[@intCast(dow)], day, months[month - 1] },
);
if (written.len < 11)
@memset(date_str_out[written.len..], ' ');
return date_str_out[0..11];
}
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) [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",
.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,
};
const output = try render(std.testing.allocator, data, .{ .use_imperial = true });
defer std.testing.allocator.free(output);
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",
.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,
};
const output = try render(std.testing.allocator, data, .{});
defer std.testing.allocator.free(output);
try std.testing.expect(std.mem.indexOf(u8, output, "\\ /") != null);
try std.testing.expect(std.mem.indexOf(u8, output, "( )") != null);
}
test "partly cloudy weather art" {
const data = types.WeatherData{
.location = "Test",
.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,
};
const output = try render(std.testing.allocator, data, .{});
defer std.testing.allocator.free(output);
try std.testing.expect(std.mem.indexOf(u8, output, "\"\".-.") != null);
try std.testing.expect(std.mem.indexOf(u8, output, "\\_( )") != null);
}
test "cloudy weather art" {
const data = types.WeatherData{
.location = "Test",
.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,
};
const output = try render(std.testing.allocator, data, .{});
defer std.testing.allocator.free(output);
try std.testing.expect(std.mem.indexOf(u8, output, ".--.") != null);
try std.testing.expect(std.mem.indexOf(u8, output, "(___.__)__)") != null);
}
test "rain weather art" {
const data = types.WeatherData{
.location = "Test",
.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,
};
const output = try render(std.testing.allocator, data, .{});
defer std.testing.allocator.free(output);
try std.testing.expect(std.mem.indexOf(u8, output, "ʻ ʻ ʻ ʻ") != null);
}
test "thunderstorm weather art" {
const data = types.WeatherData{
.location = "Test",
.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,
};
const output = try render(std.testing.allocator, data, .{});
defer std.testing.allocator.free(output);
try std.testing.expect(std.mem.indexOf(u8, output, "⚡") != null);
}
test "snow weather art" {
const data = types.WeatherData{
.location = "Test",
.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,
};
const output = try render(std.testing.allocator, data, .{});
defer std.testing.allocator.free(output);
try std.testing.expect(std.mem.indexOf(u8, output, "* * *") != null);
}
test "sleet weather art" {
const data = types.WeatherData{
.location = "Test",
.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,
};
const output = try render(std.testing.allocator, data, .{});
defer std.testing.allocator.free(output);
try std.testing.expect(std.mem.indexOf(u8, output, "ʻ * ʻ *") != null);
}
test "fog weather art" {
const data = types.WeatherData{
.location = "Test",
.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,
};
const output = try render(std.testing.allocator, data, .{});
defer std.testing.allocator.free(output);
try std.testing.expect(std.mem.indexOf(u8, output, "_ - _ - _ -") != null);
}
test "unknown weather code art" {
const data = types.WeatherData{
.location = "Test",
.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,
};
const output = try render(std.testing.allocator, data, .{});
defer std.testing.allocator.free(output);
try std.testing.expect(std.mem.indexOf(u8, output, "?") != null);
try std.testing.expect(std.mem.indexOf(u8, output, "¯\\_(ツ)_/¯") != null);
}
test "temperature matches between ansi and custom format" {
const custom = @import("custom.zig");
const data = types.WeatherData{
.location = "PDX",
.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,
};
const ansi_output = try render(std.testing.allocator, data, .{ .use_imperial = true });
defer std.testing.allocator.free(ansi_output);
const custom_output = try custom.render(std.testing.allocator, data, "%t", true);
defer std.testing.allocator.free(custom_output);
// 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, custom_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();
const output = try render(allocator, weather_data, .{ .format = .plain_text, .days = 3 });
defer allocator.free(output);
const expected =
\\Weather report: 47.6038,-122.3301
\\
\\ .-. Light rain
\\ ( ). +7(+7) °C
\\ (___(__) ← 6 km/h
\\ ʻ ʻ ʻ ʻ
\\ ʻ ʻ ʻ ʻ 0.0 mm
\\
\\
\\Fri 2 Jan
\\ .-. Rain
\\ ( ). 7°C / 7°C
\\ (___(__)
\\ ʻ ʻ ʻ ʻ
\\ ʻ ʻ ʻ ʻ
\\ ┌─────────────┐
\\┌──────────────────────────────┬───────────────────────┤ Sat 3 Jan ├───────────────────────┬──────────────────────────────┐
\\│ Morning │ Noon └──────┬──────┘ Evening │ Night │
\\├──────────────────────────────┼──────────────────────────────┼──────────────────────────────┼──────────────────────────────┤
\\│ .-. Rain │ Cloudy │ .-. Heavy rain │ \ / Partly cloudy │
\\│ ( ). +7(+7) °C │ .--. +6(+6) °C │ ( ). +7(+7) °C │ _ /"".-. +8(+8) °C │
\\│ (___(__) ↖ 5 km/h │ .-( ). ↓ 9 km/h │ (___(__) ↖ 14 km/h │ \_( ). ↑ 12 km/h │
\\│ ʻ ʻ ʻ ʻ │ (___.__)__) │ ʻ ʻ ʻ ʻ │ /(___(__) │
\\│ ʻ ʻ ʻ ʻ 0.3 mm | 0% │ 0.0 mm | 0% │ ʻ ʻ ʻ ʻ 1.2 mm | 0% │ 0.0 mm | 0% │
\\└──────────────────────────────┴──────────────────────────────┴──────────────────────────────┴──────────────────────────────┘
\\ ┌─────────────┐
\\┌──────────────────────────────┬───────────────────────┤ Sun 4 Jan ├───────────────────────┬──────────────────────────────┐
\\│ Morning │ Noon └──────┬──────┘ Evening │ Night │
\\├──────────────────────────────┼──────────────────────────────┼──────────────────────────────┼──────────────────────────────┤
\\│ Cloudy │ Cloudy │ Cloudy │ .-. Light rain │
\\│ .--. +10(+10) °C │ .--. +8(+8) °C │ .--. +10(+10) °C │ ( ). +9(+9) °C │
\\│ .-( ). ↙ 7 km/h │ .-( ). ↑ 14 km/h │ .-( ). ↑ 31 km/h │ (___(__) ↑ 24 km/h │
\\│ (___.__)__) │ (___.__)__) │ (___.__)__) │ ʻ ʻ ʻ ʻ │
\\│ 0.0 mm | 0% │ 0.0 mm | 0% │ 0.0 mm | 0% │ ʻ ʻ ʻ ʻ 0.2 mm | 0% │
\\└──────────────────────────────┴──────────────────────────────┴──────────────────────────────┴──────────────────────────────┘
\\
;
try std.testing.expectEqualStrings(expected, output);
}
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();
const output = try render(allocator, weather_data, .{ .format = .ansi, .days = 3, .use_imperial = true });
defer allocator.free(output);
// const file = try std.fs.cwd().createFile("/tmp/formatted_output.txt", .{});
// defer file.close();
// try file.writeAll(output);
const expected = @embedFile("../tests/metno-phoenix-tmp.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));
}