wttr/src/render/formatted.zig

946 lines
38 KiB
Zig
Raw Blame History

This file contains ambiguous Unicode characters

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

const std = @import("std");
const types = @import("../weather/types.zig");
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("<span style=\"color:{s}\">{s}</span> {s}\n", .{ cloud_color, art[0], current.condition });
try w.print("<span style=\"color:{s}\">{s}</span> <span style=\"color:{s}\">{c}{d:.0}({c}{d:.0})</span> {s}\n", .{ cloud_color, art[1], temp_color, sign, abs_temp, fl_sign, abs_fl, temp_unit });
try w.print("<span style=\"color:{s}\">{s}</span> {s} <span style=\"color:{s}\">{d:.0}</span> {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("<span style=\"color:{s}\">{s}</span> {d:.0} {s}\n", .{ cloud_color, art[3], visibility, vis_unit });
} else {
try w.print("<span style=\"color:{s}\">{s}</span>\n", .{ cloud_color, std.mem.trimRight(u8, art[3], " ") });
}
try w.print("<span style=\"color:{s}\">{s}</span> {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("<span style=\"color:#bcbcbc\">{s}</span> ", .{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("<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_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("<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));
}