Compare commits
No commits in common. "a4f698bb7bb5749667e9d4e9388e058ef1386938" and "add82dee40e7e0a4443f3aa3d2ec0a62893f2e8d" have entirely different histories.
a4f698bb7b
...
add82dee40
20 changed files with 350 additions and 1255 deletions
|
|
@ -20,8 +20,6 @@ jobs:
|
|||
run: zig build --summary all
|
||||
- name: Run tests
|
||||
run: zig build test -Ddownload-geoip --summary all
|
||||
- name: Check code coverage >= 80%
|
||||
run: zig build coverage -Ddownload-geoip -Dcoverage-threshold=80 --summary all
|
||||
- name: Package
|
||||
run: zig build -Dtarget="$BUILD_TARGET" -Doptimize="$BUILD_OPTIMIZATION"
|
||||
- name: Upload
|
||||
|
|
|
|||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -1,4 +1,3 @@
|
|||
*.swp
|
||||
.zig-cache/
|
||||
zig-out/
|
||||
coverage/
|
||||
|
|
|
|||
|
|
@ -2,20 +2,24 @@
|
|||
|
||||
Features not yet implemented in the Zig version:
|
||||
|
||||
## 1. PNG Generation
|
||||
## 1. Prometheus Metrics Format (format=p1)
|
||||
- Export weather data in Prometheus metrics format
|
||||
- See API_ENDPOINTS.md for format specification
|
||||
|
||||
## 2. PNG Generation
|
||||
- Render weather reports as PNG images
|
||||
- Support transparency and custom styling
|
||||
- Requires image rendering library integration
|
||||
|
||||
## 2. Language/Localization
|
||||
## 3. Language/Localization
|
||||
- Accept-Language header parsing
|
||||
- lang query parameter support
|
||||
- Translation of weather conditions and text (54 languages)
|
||||
|
||||
## 3. Json output
|
||||
## 4. Json output
|
||||
- Does not match wttr.in format
|
||||
|
||||
## 4. Moon endpoint
|
||||
## 5. Moon endpoint
|
||||
- `/Moon` and `/Moon@YYYY-MM-DD` endpoints not yet implemented
|
||||
- Moon phase calculation is implemented and available in custom format (%m, %M)
|
||||
|
||||
|
|
|
|||
|
|
@ -178,6 +178,8 @@ The result will look like:
|
|||
|
||||
## Prometheus Metrics Output
|
||||
|
||||
**Note:** Not yet implemented - see [MISSING_FEATURES.md](MISSING_FEATURES.md)
|
||||
|
||||
To fetch information in Prometheus format:
|
||||
|
||||
```bash
|
||||
|
|
|
|||
83
build.zig
83
build.zig
|
|
@ -1,6 +1,5 @@
|
|||
const std = @import("std");
|
||||
const GitVersion = @import("build/GitVersion.zig");
|
||||
const Coverage = @import("build/Coverage.zig");
|
||||
|
||||
pub fn build(b: *std.Build) void {
|
||||
const target = b.standardTargetOptions(.{});
|
||||
|
|
@ -101,36 +100,32 @@ pub fn build(b: *std.Build) void {
|
|||
maxminddb.installHeadersDirectory(maxminddb_upstream.path("include"), "", .{});
|
||||
|
||||
const version = GitVersion.getVersion(b, .{});
|
||||
const download_geoip = b.option(bool, "download-geoip", "Download GeoIP database for tests") orelse false;
|
||||
const build_options = b.addOptions();
|
||||
build_options.addOption([]const u8, "version", version);
|
||||
build_options.addOption(bool, "download_geoip", download_geoip);
|
||||
|
||||
const root_module = b.createModule(.{
|
||||
.root_source_file = b.path("src/main.zig"),
|
||||
.target = target,
|
||||
.optimize = optimize,
|
||||
});
|
||||
root_module.addImport("httpz", httpz.module("httpz"));
|
||||
root_module.addImport("zeit", zeit.module("zeit"));
|
||||
root_module.addAnonymousImport("airports.dat", .{
|
||||
.root_source_file = openflights.path("data/airports.dat"),
|
||||
});
|
||||
root_module.addOptions("build_options", build_options);
|
||||
root_module.addIncludePath(maxminddb_upstream.path("include"));
|
||||
root_module.addIncludePath(b.path("libs/phoon_14Aug2014"));
|
||||
root_module.addIncludePath(b.path("libs/sunriset"));
|
||||
root_module.addConfigHeader(maxminddb_config);
|
||||
const libs = &[_]*std.Build.Step.Compile{
|
||||
maxminddb,
|
||||
phoon,
|
||||
sunriset,
|
||||
};
|
||||
const exe = b.addExecutable(.{
|
||||
.name = "wttr",
|
||||
.root_module = root_module,
|
||||
.root_module = b.createModule(.{
|
||||
.root_source_file = b.path("src/main.zig"),
|
||||
.target = target,
|
||||
.optimize = optimize,
|
||||
}),
|
||||
});
|
||||
configureCompilationUnit(exe, libs);
|
||||
|
||||
exe.root_module.addImport("httpz", httpz.module("httpz"));
|
||||
exe.root_module.addImport("zeit", zeit.module("zeit"));
|
||||
exe.root_module.addAnonymousImport("airports.dat", .{
|
||||
.root_source_file = openflights.path("data/airports.dat"),
|
||||
});
|
||||
exe.root_module.addOptions("build_options", build_options);
|
||||
exe.root_module.addIncludePath(maxminddb_upstream.path("include"));
|
||||
exe.root_module.addIncludePath(b.path("libs/phoon_14Aug2014"));
|
||||
exe.root_module.addIncludePath(b.path("libs/sunriset"));
|
||||
exe.root_module.addConfigHeader(maxminddb_config);
|
||||
exe.linkLibrary(maxminddb);
|
||||
exe.linkLibrary(phoon);
|
||||
exe.linkLibrary(sunriset);
|
||||
exe.linkLibC();
|
||||
|
||||
b.installArtifact(exe);
|
||||
|
||||
|
|
@ -143,19 +138,35 @@ pub fn build(b: *std.Build) void {
|
|||
const run_step = b.step("run", "Run the app");
|
||||
run_step.dependOn(&run_cmd.step);
|
||||
|
||||
const tests = b.addTest(.{ .root_module = root_module });
|
||||
configureCompilationUnit(tests, libs);
|
||||
const download_geoip = b.option(bool, "download-geoip", "Download GeoIP database for tests") orelse false;
|
||||
|
||||
const test_options = b.addOptions();
|
||||
test_options.addOption(bool, "download_geoip", download_geoip);
|
||||
test_options.addOption([]const u8, "version", version);
|
||||
|
||||
const tests = b.addTest(.{
|
||||
.root_module = b.createModule(.{
|
||||
.root_source_file = b.path("src/main.zig"),
|
||||
.target = target,
|
||||
.optimize = optimize,
|
||||
}),
|
||||
});
|
||||
tests.root_module.addImport("httpz", httpz.module("httpz"));
|
||||
tests.root_module.addImport("zeit", zeit.module("zeit"));
|
||||
tests.root_module.addAnonymousImport("airports.dat", .{
|
||||
.root_source_file = openflights.path("data/airports.dat"),
|
||||
});
|
||||
tests.root_module.addOptions("build_options", test_options);
|
||||
tests.root_module.addIncludePath(maxminddb_upstream.path("include"));
|
||||
tests.root_module.addIncludePath(b.path("libs/phoon_14Aug2014"));
|
||||
tests.root_module.addIncludePath(b.path("libs/sunriset"));
|
||||
tests.root_module.addConfigHeader(maxminddb_config);
|
||||
tests.linkLibrary(maxminddb);
|
||||
tests.linkLibrary(phoon);
|
||||
tests.linkLibrary(sunriset);
|
||||
tests.linkLibC();
|
||||
|
||||
const run_tests = b.addRunArtifact(tests);
|
||||
const test_step = b.step("test", "Run tests");
|
||||
test_step.dependOn(&run_tests.step);
|
||||
|
||||
// Coverage step
|
||||
const cov_step = Coverage.addCoverageStep(b, root_module, exe.name);
|
||||
configureCompilationUnit(cov_step.test_exe, libs);
|
||||
}
|
||||
|
||||
fn configureCompilationUnit(compile: *std.Build.Step.Compile, libs: []const *std.Build.Step.Compile) void {
|
||||
for (libs) |lib| compile.linkLibrary(lib);
|
||||
compile.linkLibC();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,182 +0,0 @@
|
|||
const builtin = @import("builtin");
|
||||
const std = @import("std");
|
||||
const Build = std.Build;
|
||||
|
||||
const Coverage = @This();
|
||||
|
||||
/// Adds test coverage. This will create a new test coverage executable to the
|
||||
/// build graph, generated only if coverage is a target. It will create an
|
||||
/// option -Dcoverage-threshold that will fail the build if the threshold is
|
||||
/// not met. It will also add a step that downloads a zig fork of the kcov
|
||||
/// executable into zig cache if it doesn't already exist
|
||||
///
|
||||
/// Because it is creating a new test executable from the root module provided,
|
||||
/// if there are any linking steps being done to your test executable, those
|
||||
/// must also be done to the test_exe returned by this function
|
||||
pub fn addCoverageStep(b: *Build, root_module: *Build.Module, coverage_name: []const u8) *Coverage {
|
||||
//verify host requirements
|
||||
{
|
||||
const supported = builtin.os.tag == .linux and
|
||||
(builtin.cpu.arch == .x86_64 or builtin.cpu.arch == .aarch64);
|
||||
|
||||
if (!supported)
|
||||
@panic("Coverage only supported on x86_64-linux or aarch64-linux");
|
||||
}
|
||||
|
||||
// Add options
|
||||
const coverage_threshold = b.option(u7, "coverage-threshold", "Minimum coverage percentage required") orelse 0;
|
||||
const coverage_dir = b.option([]const u8, "coverage-dir", "Coverage output directory") orelse
|
||||
b.pathJoin(&.{ b.build_root.path orelse ".", "coverage" });
|
||||
const coverage_step = b.step("coverage", "Generate test coverage report");
|
||||
|
||||
// Set up kcov download
|
||||
// We can't do it directly because we are sandboxed during build, but
|
||||
// we can create a program and run that program. First we need the destination
|
||||
// directory
|
||||
const kcov = blk: {
|
||||
const arch_name = switch (builtin.cpu.arch) {
|
||||
.x86_64 => "x86_64",
|
||||
.aarch64 => "aarch64",
|
||||
else => unreachable,
|
||||
};
|
||||
|
||||
const Algo = std.crypto.hash.sha2.Sha256;
|
||||
var hasher = Algo.init(.{});
|
||||
hasher.update("kcov-");
|
||||
hasher.update(arch_name);
|
||||
var cache_hash: [Algo.digest_length]u8 = undefined;
|
||||
hasher.final(&cache_hash);
|
||||
|
||||
const cache_dir = b.pathJoin(&.{
|
||||
b.cache_root.path.?,
|
||||
"o",
|
||||
b.fmt("{s}", .{std.fmt.bytesToHex(cache_hash, .lower)}),
|
||||
});
|
||||
|
||||
const kcov_name = b.fmt("kcov-{s}", .{arch_name});
|
||||
break :blk .{ .path = b.pathJoin(&.{ cache_dir, kcov_name }), .arch = arch_name };
|
||||
};
|
||||
|
||||
// Create download and coverage build steps
|
||||
return blk: {
|
||||
const download_exe = b.addExecutable(.{
|
||||
.name = "download-kcov",
|
||||
.root_module = b.createModule(.{
|
||||
.root_source_file = b.path("build/download_kcov.zig"),
|
||||
.target = b.resolveTargetQuery(.{}),
|
||||
}),
|
||||
});
|
||||
|
||||
const run_download = b.addRunArtifact(download_exe);
|
||||
run_download.addArg(kcov.path);
|
||||
run_download.addArg(kcov.arch);
|
||||
|
||||
const run_coverage = b.addSystemCommand(&.{kcov.path});
|
||||
const include_path = b.pathJoin(&.{ b.build_root.path.?, "src" });
|
||||
run_coverage.addArgs(&.{ "--include-path", include_path });
|
||||
const css_file = b.pathJoin(&.{ b.build_root.path.?, "build", "bcov.css" });
|
||||
run_coverage.addArg(b.fmt("--configure=css-file={s}", .{css_file}));
|
||||
run_coverage.addArg(coverage_dir);
|
||||
const test_exe = b.addTest(.{
|
||||
.name = coverage_name,
|
||||
.root_module = root_module,
|
||||
// we need to set the test exe to use llvm as the self hosted backend
|
||||
// does not support the data kcov needs
|
||||
.use_llvm = true,
|
||||
});
|
||||
run_coverage.addArtifactArg(test_exe);
|
||||
run_coverage.step.dependOn(&test_exe.step);
|
||||
run_coverage.step.dependOn(&run_download.step);
|
||||
|
||||
const json_path = b.fmt("{s}/{s}/coverage.json", .{ coverage_dir, coverage_name });
|
||||
const verbose = b.option(bool, "coverage-verbose", "Show test coverage for each file") orelse false;
|
||||
const check_step = create(b, test_exe, json_path, coverage_threshold, verbose);
|
||||
check_step.step.dependOn(&run_coverage.step);
|
||||
coverage_step.dependOn(&check_step.step);
|
||||
break :blk check_step;
|
||||
};
|
||||
}
|
||||
|
||||
step: Build.Step,
|
||||
json_path: []const u8,
|
||||
threshold: u7,
|
||||
test_exe: *std.Build.Step.Compile,
|
||||
verbose: bool,
|
||||
|
||||
pub fn create(owner: *Build, test_exe: *std.Build.Step.Compile, xml_path: []const u8, threshold: u7, verbose: bool) *Coverage {
|
||||
const check = owner.allocator.create(Coverage) catch @panic("OOM");
|
||||
check.* = .{
|
||||
.step = Build.Step.init(.{
|
||||
.id = .custom,
|
||||
.name = "check coverage",
|
||||
.owner = owner,
|
||||
.makeFn = make,
|
||||
}),
|
||||
.json_path = xml_path,
|
||||
.threshold = threshold,
|
||||
.test_exe = test_exe,
|
||||
.verbose = verbose,
|
||||
};
|
||||
return check;
|
||||
}
|
||||
|
||||
// This must be kept in step with kcov coverage.json format
|
||||
const CoverageReport = struct {
|
||||
percent_covered: f64,
|
||||
covered_lines: usize,
|
||||
total_lines: usize,
|
||||
percent_low: u7,
|
||||
percent_high: u7,
|
||||
command: []const u8,
|
||||
date: []const u8,
|
||||
files: []File,
|
||||
};
|
||||
|
||||
const File = struct {
|
||||
file: []const u8,
|
||||
percent_covered: f64,
|
||||
covered_lines: usize,
|
||||
total_lines: usize,
|
||||
|
||||
pub fn coverageLessThanDesc(context: []File, lhs: File, rhs: File) bool {
|
||||
_ = context;
|
||||
return lhs.percent_covered > rhs.percent_covered;
|
||||
}
|
||||
};
|
||||
|
||||
fn make(step: *Build.Step, options: Build.Step.MakeOptions) !void {
|
||||
_ = options;
|
||||
const check: *Coverage = @fieldParentPtr("step", step);
|
||||
const allocator = step.owner.allocator;
|
||||
|
||||
const file = try std.fs.cwd().openFile(check.json_path, .{});
|
||||
defer file.close();
|
||||
|
||||
const content = try file.readToEndAlloc(allocator, 10 * 1024 * 1024);
|
||||
defer allocator.free(content);
|
||||
|
||||
const json = try std.json.parseFromSlice(CoverageReport, allocator, content, .{});
|
||||
defer json.deinit();
|
||||
const coverage = json.value;
|
||||
|
||||
var stdout_buffer: [1024]u8 = undefined;
|
||||
var stdout_writer = std.fs.File.stdout().writer(&stdout_buffer);
|
||||
const stdout = &stdout_writer.interface;
|
||||
if (step.owner.verbose or check.verbose) {
|
||||
const files = coverage.files;
|
||||
std.mem.sort(File, files, files, File.coverageLessThanDesc);
|
||||
for (files) |f|
|
||||
try stdout.print(
|
||||
"{d: >5.1}% {d: >5}/{d: <5}:{s}\n",
|
||||
.{ f.percent_covered, f.covered_lines, f.total_lines, f.file },
|
||||
);
|
||||
}
|
||||
try stdout.print(
|
||||
"Total test coverage: {d}% ({d}/{d})\n",
|
||||
.{ coverage.percent_covered, coverage.covered_lines, coverage.total_lines },
|
||||
);
|
||||
try stdout.flush();
|
||||
|
||||
if (@as(u7, @intFromFloat(@floor(coverage.percent_covered))) < check.threshold)
|
||||
return step.fail("Coverage {d}% is below threshold {d}%", .{ coverage.percent_covered, check.threshold });
|
||||
}
|
||||
|
|
@ -1,46 +0,0 @@
|
|||
/* Based upon the lcov CSS style, style files can be reused - Dark Theme */
|
||||
body { color: #e0e0e0; background-color: #1e1e1e; }
|
||||
a:link { color: #6b9aff; text-decoration: underline; }
|
||||
a:visited { color: #4dbb7a; text-decoration: underline; }
|
||||
a:active { color: #ff6b8a; text-decoration: underline; }
|
||||
td.title { text-align: center; padding-bottom: 10px; font-size: 20pt; font-weight: bold; }
|
||||
td.ruler { background-color: #4a6ba8; }
|
||||
td.headerItem { text-align: right; padding-right: 6px; font-family: sans-serif; font-weight: bold; }
|
||||
td.headerValue { text-align: left; color: #6b9aff; font-family: sans-serif; font-weight: bold; }
|
||||
td.versionInfo { text-align: center; padding-top: 2px; }
|
||||
th.headerItem { text-align: right; padding-right: 6px; font-family: sans-serif; font-weight: bold; }
|
||||
th.headerValue { text-align: left; color: #6b9aff; font-family: sans-serif; font-weight: bold; }
|
||||
pre.source { font-family: monospace; white-space: pre; overflow: hidden; text-overflow: ellipsis; }
|
||||
span.lineNum { background-color: #5a5a2a; }
|
||||
span.lineNumLegend { background-color: #5a5a2a; width: 96px; font-weight: bold ;}
|
||||
span.lineCov { background-color: #2d5a2d; }
|
||||
span.linePartCov { background-color: #707000; }
|
||||
span.lineNoCov { background-color: #762c2c; }
|
||||
span.orderNum { background-color: #5a4a2a; float: right; width:5em; text-align: left; }
|
||||
span.orderNumLegend { background-color: #5a4a2a; width: 96px; font-weight: bold ;}
|
||||
span.coverHits { background-color: #4a4a2a; padding-left: 3px; padding-right: 1px; text-align: right; list-style-type: none; display: inline-block; width: 5em; }
|
||||
span.coverHitsLegend { background-color: #4a4a2a; width: 96px; font-weight: bold; margin: 0 auto;}
|
||||
td.tableHead { text-align: center; color: #e0e0e0; background-color: #4a6ba8; font-family: sans-serif; font-size: 120%; font-weight: bold; }
|
||||
td.coverFile { text-align: left; padding-left: 10px; padding-right: 20px; color: #6b9aff; font-family: monospace; background-color: #3a3a3a; }
|
||||
td.coverBar { padding-left: 10px; padding-right: 10px; background-color: #3a3a3a; }
|
||||
td.coverBarOutline { background-color: #4a4a4a; }
|
||||
td.coverPer { text-align: left; padding-left: 10px; padding-right: 10px; font-weight: bold; background-color: #3a3a3a; color: #e0e0e0; }
|
||||
td.coverPerLeftMed { text-align: left; padding-left: 10px; padding-right: 10px; background-color: #5a5a00; font-weight: bold; color: #e0e0e0; }
|
||||
td.coverPerLeftLo { text-align: left; padding-left: 10px; padding-right: 10px; background-color: #5a2d2d; font-weight: bold; color: #e0e0e0; }
|
||||
td.coverPerLeftHi { text-align: left; padding-left: 10px; padding-right: 10px; background-color: #2d5a2d; font-weight: bold; color: #e0e0e0; }
|
||||
td.coverNum { text-align: right; padding-left: 10px; padding-right: 10px; background-color: #3a3a3a; color: #e0e0e0; }
|
||||
|
||||
/* Override tablesorter hover styles for dark theme */
|
||||
.tablesorter-blue tbody > tr:hover > td,
|
||||
.tablesorter-blue tbody > tr:hover + tr.tablesorter-childRow > td,
|
||||
.tablesorter-blue tbody > tr:hover + tr.tablesorter-childRow + tr.tablesorter-childRow > td,
|
||||
.tablesorter-blue tbody > tr.even:hover > td,
|
||||
.tablesorter-blue tbody > tr.even:hover + tr.tablesorter-childRow > td,
|
||||
.tablesorter-blue tbody > tr.even:hover + tr.tablesorter-childRow + tr.tablesorter-childRow > td {
|
||||
background: #4a4a4a;
|
||||
}
|
||||
.tablesorter-blue tbody > tr.odd:hover > td,
|
||||
.tablesorter-blue tbody > tr.odd:hover + tr.tablesorter-childRow > td,
|
||||
.tablesorter-blue tbody > tr.odd:hover + tr.tablesorter-childRow + tr.tablesorter-childRow > td {
|
||||
background: #4a4a4a;
|
||||
}
|
||||
|
|
@ -1,82 +0,0 @@
|
|||
const std = @import("std");
|
||||
|
||||
pub fn main() !void {
|
||||
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
|
||||
const allocator = gpa.allocator();
|
||||
|
||||
const args = try std.process.argsAlloc(allocator);
|
||||
defer std.process.argsFree(allocator, args);
|
||||
|
||||
if (args.len != 3) return error.InvalidArgs;
|
||||
|
||||
const kcov_path = args[1];
|
||||
const arch_name = args[2];
|
||||
|
||||
// Check to see if file exists. If it does, we have nothing more to do
|
||||
const stat = std.fs.cwd().statFile(kcov_path) catch |err| blk: {
|
||||
if (err == error.FileNotFound) break :blk null else return err;
|
||||
};
|
||||
// This might be better checking whether it's executable and >= 7MB, but
|
||||
// for now, we'll do a simple exists check
|
||||
if (stat != null) return;
|
||||
var stdout_buffer: [1024]u8 = undefined;
|
||||
var stdout_writer = std.fs.File.stdout().writer(&stdout_buffer);
|
||||
const stdout = &stdout_writer.interface;
|
||||
|
||||
try stdout.writeAll("Determining latest kcov version\n");
|
||||
try stdout.flush();
|
||||
|
||||
var client = std.http.Client{ .allocator = allocator };
|
||||
defer client.deinit();
|
||||
|
||||
// Get redirect to find latest version
|
||||
const list_uri = try std.Uri.parse("https://git.lerch.org/lobo/-/packages/generic/kcov/");
|
||||
var req = try client.request(.GET, list_uri, .{ .redirect_behavior = .unhandled });
|
||||
defer req.deinit();
|
||||
|
||||
try req.sendBodiless();
|
||||
var redirect_buf: [1024]u8 = undefined;
|
||||
const response = try req.receiveHead(&redirect_buf);
|
||||
|
||||
if (response.head.status != .see_other) return error.UnexpectedResponse;
|
||||
|
||||
const location = response.head.location orelse return error.NoLocation;
|
||||
const version_start = std.mem.lastIndexOf(u8, location, "/") orelse return error.InvalidLocation;
|
||||
const version = location[version_start + 1 ..];
|
||||
|
||||
try stdout.print(
|
||||
"Downloading kcov version {s} for {s} to {s}...",
|
||||
.{ version, arch_name, kcov_path },
|
||||
);
|
||||
try stdout.flush();
|
||||
|
||||
const binary_url = try std.fmt.allocPrint(
|
||||
allocator,
|
||||
"https://git.lerch.org/api/packages/lobo/generic/kcov/{s}/kcov-{s}",
|
||||
.{ version, arch_name },
|
||||
);
|
||||
defer allocator.free(binary_url);
|
||||
|
||||
const cache_dir = std.fs.path.dirname(kcov_path) orelse return error.InvalidPath;
|
||||
std.fs.cwd().makeDir(cache_dir) catch |e| switch (e) {
|
||||
error.PathAlreadyExists => {},
|
||||
else => return e,
|
||||
};
|
||||
|
||||
const uri = try std.Uri.parse(binary_url);
|
||||
const file = try std.fs.cwd().createFile(kcov_path, .{ .mode = 0o755 });
|
||||
defer file.close();
|
||||
|
||||
var buffer: [8192]u8 = undefined;
|
||||
var writer = file.writer(&buffer);
|
||||
const result = try client.fetch(.{
|
||||
.location = .{ .uri = uri },
|
||||
.response_writer = &writer.interface,
|
||||
});
|
||||
|
||||
if (result.status != .ok) return error.DownloadFailed;
|
||||
try writer.interface.flush();
|
||||
|
||||
try stdout.writeAll("done\n");
|
||||
try stdout.flush();
|
||||
}
|
||||
22
src/Moon.zig
22
src/Moon.zig
|
|
@ -28,28 +28,6 @@ pub const Phase = struct {
|
|||
pub fn day(self: Phase) u8 {
|
||||
return @intFromFloat(@round(self.age_days));
|
||||
}
|
||||
|
||||
pub fn format(self: Phase, writer: *std.Io.Writer) std.Io.Writer.Error!void {
|
||||
const name = if (self.phase < 0.0625)
|
||||
"New Moon"
|
||||
else if (self.phase < 0.1875)
|
||||
"Waxing Crescent"
|
||||
else if (self.phase < 0.3125)
|
||||
"First Quarter"
|
||||
else if (self.phase < 0.4375)
|
||||
"Waxing Gibbous"
|
||||
else if (self.phase < 0.5625)
|
||||
"Full Moon"
|
||||
else if (self.phase < 0.6875)
|
||||
"Waning Gibbous"
|
||||
else if (self.phase < 0.8125)
|
||||
"Last Quarter"
|
||||
else if (self.phase < 0.9375)
|
||||
"Waning Crescent"
|
||||
else
|
||||
"New Moon";
|
||||
try writer.print("{s}", .{name});
|
||||
}
|
||||
};
|
||||
|
||||
pub fn getPhase(timestamp: i64) Phase {
|
||||
|
|
|
|||
|
|
@ -149,25 +149,3 @@ test "parse transparency" {
|
|||
const params_custom = try QueryParams.parse(allocator, "transparency=200");
|
||||
try std.testing.expectEqual(@as(u8, 200), params_custom.transparency.?);
|
||||
}
|
||||
|
||||
test "imperial units selection logic" {
|
||||
// This test documents the priority order for unit selection:
|
||||
// 1. Explicit ?u or ?m parameter (highest priority)
|
||||
// 2. lang=us parameter
|
||||
// 3. US IP detection
|
||||
// 4. Default to metric
|
||||
|
||||
// The actual logic is tested through integration tests
|
||||
// This test just verifies the QueryParams parsing works
|
||||
const allocator = std.testing.allocator;
|
||||
|
||||
const params_u = try QueryParams.parse(allocator, "u");
|
||||
try std.testing.expect(params_u.use_imperial.?);
|
||||
|
||||
const params_m = try QueryParams.parse(allocator, "m");
|
||||
try std.testing.expect(!params_m.use_imperial.?);
|
||||
|
||||
const params_lang = try QueryParams.parse(allocator, "lang=us");
|
||||
defer allocator.free(params_lang.lang.?);
|
||||
try std.testing.expectEqualStrings("us", params_lang.lang.?);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -76,7 +76,7 @@ fn handleWeather(ctx: *Context, req: *httpz.Request, res: *httpz.Response) !void
|
|||
try handler.handleWeather(&ctx.options, req, res, client_ip);
|
||||
}
|
||||
|
||||
pub fn getClientIp(req: *httpz.Request, buf: []u8) ![]const u8 {
|
||||
fn getClientIp(req: *httpz.Request, buf: []u8) ![]const u8 {
|
||||
// Check X-Forwarded-For header first (for proxies)
|
||||
if (req.header("x-forwarded-for")) |xff| {
|
||||
return parseXForwardedFor(xff);
|
||||
|
|
@ -114,7 +114,7 @@ pub fn deinit(self: *Server) void {
|
|||
self.httpz_server.deinit();
|
||||
}
|
||||
|
||||
pub const MockHarness = struct {
|
||||
const MockHarness = struct {
|
||||
allocator: std.mem.Allocator,
|
||||
config: Config,
|
||||
geoip: *GeoIp,
|
||||
|
|
@ -241,16 +241,6 @@ test "handleWeather: default endpoint uses IP address" {
|
|||
try handler.handleWeather(&harness.opts, ht.req, ht.res, client_ip);
|
||||
|
||||
try ht.expectStatus(200);
|
||||
try ht.expectBody(
|
||||
\\<pre>Weather report: 73.158.64.1
|
||||
\\
|
||||
\\<span style="color:#ffff00"> \ / </span> Clear
|
||||
\\<span style="color:#ffff00"> .-. </span> <span style="color:#d7ff00">+68(+68)</span> °F
|
||||
\\<span style="color:#ffff00"> ― ( ) ― </span> ↓ <span style="color:#6c6c6c">3</span> mph
|
||||
\\<span style="color:#ffff00"> `-' </span> 6 mi
|
||||
\\<span style="color:#ffff00"> / \ </span> 0.0 in
|
||||
\\</pre>
|
||||
);
|
||||
}
|
||||
|
||||
test "handleWeather: x-forwarded-for with multiple IPs" {
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ const Line = @import("../render/Line.zig");
|
|||
const Json = @import("../render/Json.zig");
|
||||
const V2 = @import("../render/V2.zig");
|
||||
const Custom = @import("../render/Custom.zig");
|
||||
const Prometheus = @import("../render/Prometheus.zig");
|
||||
const help = @import("help.zig");
|
||||
|
||||
const log = std.log.scoped(.handler);
|
||||
|
|
@ -136,55 +135,34 @@ fn handleWeatherInternal(
|
|||
render_options.use_imperial = true; // this is a US IP
|
||||
}
|
||||
|
||||
// Add coordinates header to response
|
||||
// Add coordinates header using response allocator
|
||||
const coords_header = try std.fmt.allocPrint(res.arena, "{d:.4},{d:.4}", .{ location.coords.latitude, location.coords.longitude });
|
||||
res.headers.add("X-Location-Coordinates", coords_header);
|
||||
|
||||
if (params.format) |fmt| {
|
||||
// Anything except the json will be plain text
|
||||
res.content_type = .TEXT;
|
||||
if (std.mem.eql(u8, fmt, "1")) {
|
||||
try Line.render(res.writer(), weather, .@"1", render_options.use_imperial);
|
||||
return;
|
||||
res.body = blk: {
|
||||
if (params.format) |fmt| {
|
||||
// Anything except the json will be plain text
|
||||
res.content_type = .TEXT;
|
||||
if (std.mem.eql(u8, fmt, "j1")) {
|
||||
res.content_type = .JSON; // reset to json
|
||||
break :blk try Json.render(req_alloc, weather);
|
||||
}
|
||||
if (std.mem.eql(u8, fmt, "v2"))
|
||||
break :blk try V2.render(req_alloc, weather, render_options.use_imperial);
|
||||
if (std.mem.startsWith(u8, fmt, "%"))
|
||||
break :blk try Custom.render(req_alloc, weather, fmt, render_options.use_imperial);
|
||||
// fall back to line if we don't understand the format parameter
|
||||
break :blk try Line.render(req_alloc, weather, fmt, render_options.use_imperial);
|
||||
} else {
|
||||
render_options.format = determineFormat(params, req.headers.get("user-agent"));
|
||||
log.debug(
|
||||
"Format: {}. params.ansi {}, params.text {}, user agent: {?s}",
|
||||
.{ render_options.format, params.ansi, params.text_only, req.headers.get("user-agent") },
|
||||
);
|
||||
if (render_options.format != .html) res.content_type = .TEXT else res.content_type = .HTML;
|
||||
break :blk try Formatted.render(req_alloc, weather, render_options);
|
||||
}
|
||||
if (std.mem.eql(u8, fmt, "2")) {
|
||||
try Line.render(res.writer(), weather, .@"2", render_options.use_imperial);
|
||||
return;
|
||||
}
|
||||
if (std.mem.eql(u8, fmt, "3")) {
|
||||
try Line.render(res.writer(), weather, .@"3", render_options.use_imperial);
|
||||
return;
|
||||
}
|
||||
if (std.mem.eql(u8, fmt, "4")) {
|
||||
try Line.render(res.writer(), weather, .@"4", render_options.use_imperial);
|
||||
return;
|
||||
}
|
||||
if (std.mem.eql(u8, fmt, "j1")) {
|
||||
res.content_type = .JSON; // reset to json
|
||||
try Json.render(res.writer(), weather);
|
||||
return;
|
||||
}
|
||||
if (std.mem.eql(u8, fmt, "p1")) {
|
||||
try Prometheus.render(res.writer(), weather);
|
||||
return;
|
||||
}
|
||||
if (std.mem.eql(u8, fmt, "v2")) {
|
||||
try V2.render(res.writer(), weather, render_options.use_imperial);
|
||||
return;
|
||||
}
|
||||
// Everything else goes to Custom renderer
|
||||
try Custom.render(res.writer(), weather, fmt, render_options.use_imperial);
|
||||
} else {
|
||||
// No specific format selected, we'll provide Formatted output in either
|
||||
// text (ansi/plain) or html
|
||||
render_options.format = determineFormat(params, req.headers.get("user-agent"));
|
||||
log.debug(
|
||||
"Format: {}. params.ansi {}, params.text {}, user agent: {?s}",
|
||||
.{ render_options.format, params.ansi, params.text_only, req.headers.get("user-agent") },
|
||||
);
|
||||
if (render_options.format != .html) res.content_type = .TEXT else res.content_type = .HTML;
|
||||
try Formatted.render(res.writer(), weather, render_options);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
fn determineFormat(params: QueryParams, user_agent: ?[]const u8) Formatted.Format {
|
||||
|
|
@ -217,255 +195,24 @@ fn determineFormat(params: QueryParams, user_agent: ?[]const u8) Formatted.Forma
|
|||
return .html;
|
||||
}
|
||||
|
||||
test "handler: help page" {
|
||||
test "imperial units selection logic" {
|
||||
// This test documents the priority order for unit selection:
|
||||
// 1. Explicit ?u or ?m parameter (highest priority)
|
||||
// 2. lang=us parameter
|
||||
// 3. US IP detection
|
||||
// 4. Default to metric
|
||||
|
||||
// The actual logic is tested through integration tests
|
||||
// This test just verifies the QueryParams parsing works
|
||||
const allocator = std.testing.allocator;
|
||||
const MockHarness = @import("Server.zig").MockHarness;
|
||||
|
||||
var harness = try MockHarness.init(allocator);
|
||||
defer harness.deinit();
|
||||
const params_u = try QueryParams.parse(allocator, "u");
|
||||
try std.testing.expect(params_u.use_imperial.?);
|
||||
|
||||
var ht = httpz.testing.init(.{});
|
||||
defer ht.deinit();
|
||||
const params_m = try QueryParams.parse(allocator, "m");
|
||||
try std.testing.expect(!params_m.use_imperial.?);
|
||||
|
||||
ht.url("/:help");
|
||||
ht.param("location", ":help");
|
||||
|
||||
try handleWeather(&harness.opts, ht.req, ht.res, "127.0.0.1");
|
||||
|
||||
try ht.expectStatus(200);
|
||||
}
|
||||
|
||||
test "handler: translation page" {
|
||||
const allocator = std.testing.allocator;
|
||||
const MockHarness = @import("Server.zig").MockHarness;
|
||||
|
||||
var harness = try MockHarness.init(allocator);
|
||||
defer harness.deinit();
|
||||
|
||||
var ht = httpz.testing.init(.{});
|
||||
defer ht.deinit();
|
||||
|
||||
ht.url("/:translation");
|
||||
ht.param("location", ":translation");
|
||||
|
||||
try handleWeather(&harness.opts, ht.req, ht.res, "127.0.0.1");
|
||||
|
||||
try ht.expectStatus(200);
|
||||
}
|
||||
|
||||
test "handler: favicon" {
|
||||
const allocator = std.testing.allocator;
|
||||
const MockHarness = @import("Server.zig").MockHarness;
|
||||
|
||||
var harness = try MockHarness.init(allocator);
|
||||
defer harness.deinit();
|
||||
|
||||
var ht = httpz.testing.init(.{});
|
||||
defer ht.deinit();
|
||||
|
||||
ht.url("/favicon.ico");
|
||||
ht.param("location", "favicon.ico");
|
||||
|
||||
try handleWeather(&harness.opts, ht.req, ht.res, "127.0.0.1");
|
||||
|
||||
try ht.expectStatus(200);
|
||||
}
|
||||
|
||||
test "handler: format j1 (json)" {
|
||||
const allocator = std.testing.allocator;
|
||||
const MockHarness = @import("Server.zig").MockHarness;
|
||||
|
||||
var harness = try MockHarness.init(allocator);
|
||||
defer harness.deinit();
|
||||
|
||||
var ht = httpz.testing.init(.{});
|
||||
defer ht.deinit();
|
||||
|
||||
ht.url("/73.158.64.1?format=j1");
|
||||
ht.param("location", "73.158.64.1");
|
||||
|
||||
var client_ip_buf: [47]u8 = undefined;
|
||||
const client_ip = try @import("Server.zig").getClientIp(ht.req, &client_ip_buf);
|
||||
try handleWeather(&harness.opts, ht.req, ht.res, client_ip);
|
||||
|
||||
try ht.expectStatus(200);
|
||||
try ht.expectHeader("Content-Type", "application/json; charset=UTF-8");
|
||||
try ht.expectBody(
|
||||
\\{"current_condition":{"temp_C":20,"weatherCode":"clear","weatherDesc":[{"value":"Clear"}],"humidity":50,"windspeedKmph":5,"winddirDegree":0,"pressure":1013,"precipMM":0},"weather":[]}
|
||||
);
|
||||
}
|
||||
|
||||
test "handler: format p1 (prometheus)" {
|
||||
const allocator = std.testing.allocator;
|
||||
const MockHarness = @import("Server.zig").MockHarness;
|
||||
|
||||
var harness = try MockHarness.init(allocator);
|
||||
defer harness.deinit();
|
||||
|
||||
var ht = httpz.testing.init(.{});
|
||||
defer ht.deinit();
|
||||
|
||||
ht.url("/73.158.64.1?format=p1");
|
||||
ht.param("location", "73.158.64.1");
|
||||
|
||||
var client_ip_buf: [47]u8 = undefined;
|
||||
const client_ip = try @import("Server.zig").getClientIp(ht.req, &client_ip_buf);
|
||||
try handleWeather(&harness.opts, ht.req, ht.res, client_ip);
|
||||
|
||||
try ht.expectStatus(200);
|
||||
try ht.expectBody(
|
||||
\\# HELP temperature_feels_like_celsius Feels Like Temperature in Celsius
|
||||
\\temperature_feels_like_celsius{forecast="current"} 20
|
||||
\\# HELP temperature_feels_like_fahrenheit Feels Like Temperature in Fahrenheit
|
||||
\\temperature_feels_like_fahrenheit{forecast="current"} 68
|
||||
\\# HELP cloudcover_percentage Cloud Coverage in Percent
|
||||
\\cloudcover_percentage{forecast="current"} 0
|
||||
\\# HELP humidity_percentage Humidity in Percent
|
||||
\\humidity_percentage{forecast="current"} 50
|
||||
\\# HELP precipitation_mm Precipitation (Rainfall) in mm
|
||||
\\precipitation_mm{forecast="current"} 0.0
|
||||
\\# HELP pressure_hpa Air pressure in hPa
|
||||
\\pressure_hpa{forecast="current"} 1013
|
||||
\\# HELP temperature_celsius Temperature in Celsius
|
||||
\\temperature_celsius{forecast="current"} 20
|
||||
\\# HELP temperature_fahrenheit Temperature in Fahrenheit
|
||||
\\temperature_fahrenheit{forecast="current"} 68
|
||||
\\# HELP uv_index Ultraviolet Radiation Index
|
||||
\\uv_index{forecast="current"} 0
|
||||
\\# HELP visibility Visible Distance in Kilometres
|
||||
\\visibility{forecast="current"} 10
|
||||
\\# HELP weather_code Code to describe Weather Condition
|
||||
\\weather_code{forecast="current"} 800
|
||||
\\# HELP winddir_degree Wind Direction in Degree
|
||||
\\winddir_degree{forecast="current"} 0
|
||||
\\# HELP windspeed_kmph Wind Speed in Kilometres per Hour
|
||||
\\windspeed_kmph{forecast="current"} 5
|
||||
\\# HELP windspeed_mph Wind Speed in Miles per Hour
|
||||
\\windspeed_mph{forecast="current"} 3.106856
|
||||
\\# HELP observation_time Minutes since start of the day the observation happened
|
||||
\\observation_time{forecast="current"} 0
|
||||
\\# HELP weather_desc Weather Description
|
||||
\\weather_desc{forecast="current", description="Clear"} 1
|
||||
\\# HELP winddir_16_point Wind Direction on a 16-wind compass rose
|
||||
\\winddir_16_point{forecast="current", description="N"} 1
|
||||
\\
|
||||
);
|
||||
}
|
||||
|
||||
test "handler: format v2" {
|
||||
const allocator = std.testing.allocator;
|
||||
const MockHarness = @import("Server.zig").MockHarness;
|
||||
|
||||
var harness = try MockHarness.init(allocator);
|
||||
defer harness.deinit();
|
||||
|
||||
var ht = httpz.testing.init(.{});
|
||||
defer ht.deinit();
|
||||
|
||||
ht.url("/73.158.64.1?format=v2");
|
||||
ht.param("location", "73.158.64.1");
|
||||
|
||||
var client_ip_buf: [47]u8 = undefined;
|
||||
const client_ip = try @import("Server.zig").getClientIp(ht.req, &client_ip_buf);
|
||||
try handleWeather(&harness.opts, ht.req, ht.res, client_ip);
|
||||
|
||||
try ht.expectStatus(200);
|
||||
// Should we have 2 empty lines?
|
||||
try ht.expectBody(
|
||||
\\Weather report: 73.158.64.1
|
||||
\\
|
||||
\\ Current conditions
|
||||
\\ Clear
|
||||
\\ 🌡️ 20.0°C (68.0°F)
|
||||
\\ 💧 50%
|
||||
\\ 🌬️ 5.0 km/h N
|
||||
\\ 🔽 1013.0 hPa
|
||||
\\ 💦 0.0 mm
|
||||
\\
|
||||
\\
|
||||
);
|
||||
}
|
||||
|
||||
test "handler: format custom (%c)" {
|
||||
const allocator = std.testing.allocator;
|
||||
const MockHarness = @import("Server.zig").MockHarness;
|
||||
|
||||
var harness = try MockHarness.init(allocator);
|
||||
defer harness.deinit();
|
||||
|
||||
var ht = httpz.testing.init(.{});
|
||||
defer ht.deinit();
|
||||
|
||||
ht.url("/73.158.64.1?format=%c");
|
||||
ht.param("location", "73.158.64.1");
|
||||
|
||||
var client_ip_buf: [47]u8 = undefined;
|
||||
const client_ip = try @import("Server.zig").getClientIp(ht.req, &client_ip_buf);
|
||||
try handleWeather(&harness.opts, ht.req, ht.res, client_ip);
|
||||
|
||||
try ht.expectStatus(200);
|
||||
try ht.expectBody("☀️");
|
||||
}
|
||||
|
||||
test "handler: format line 1" {
|
||||
const allocator = std.testing.allocator;
|
||||
const MockHarness = @import("Server.zig").MockHarness;
|
||||
|
||||
var harness = try MockHarness.init(allocator);
|
||||
defer harness.deinit();
|
||||
|
||||
var ht = httpz.testing.init(.{});
|
||||
defer ht.deinit();
|
||||
|
||||
ht.url("/73.158.64.1?format=1");
|
||||
ht.param("location", "73.158.64.1");
|
||||
|
||||
var client_ip_buf: [47]u8 = undefined;
|
||||
const client_ip = try @import("Server.zig").getClientIp(ht.req, &client_ip_buf);
|
||||
try handleWeather(&harness.opts, ht.req, ht.res, client_ip);
|
||||
|
||||
try ht.expectStatus(200);
|
||||
try ht.expectBody("☀️ +20°C");
|
||||
}
|
||||
|
||||
test "handler: format line 2" {
|
||||
const allocator = std.testing.allocator;
|
||||
const MockHarness = @import("Server.zig").MockHarness;
|
||||
|
||||
var harness = try MockHarness.init(allocator);
|
||||
defer harness.deinit();
|
||||
|
||||
var ht = httpz.testing.init(.{});
|
||||
defer ht.deinit();
|
||||
|
||||
ht.url("/73.158.64.1?format=2");
|
||||
ht.param("location", "73.158.64.1");
|
||||
|
||||
var client_ip_buf: [47]u8 = undefined;
|
||||
const client_ip = try @import("Server.zig").getClientIp(ht.req, &client_ip_buf);
|
||||
try handleWeather(&harness.opts, ht.req, ht.res, client_ip);
|
||||
|
||||
try ht.expectStatus(200);
|
||||
try ht.expectBody("☀️ 🌡️+20°C 🌬️↓5km/h");
|
||||
}
|
||||
|
||||
test "handler: format line 3" {
|
||||
const allocator = std.testing.allocator;
|
||||
const MockHarness = @import("Server.zig").MockHarness;
|
||||
|
||||
var harness = try MockHarness.init(allocator);
|
||||
defer harness.deinit();
|
||||
|
||||
var ht = httpz.testing.init(.{});
|
||||
defer ht.deinit();
|
||||
|
||||
ht.url("/73.158.64.1?format=3");
|
||||
ht.param("location", "73.158.64.1");
|
||||
|
||||
var client_ip_buf: [47]u8 = undefined;
|
||||
const client_ip = try @import("Server.zig").getClientIp(ht.req, &client_ip_buf);
|
||||
try handleWeather(&harness.opts, ht.req, ht.res, client_ip);
|
||||
|
||||
try ht.expectStatus(200);
|
||||
try ht.expectBody("Test: ☀️ +20°C");
|
||||
const params_lang = try QueryParams.parse(allocator, "lang=us");
|
||||
defer allocator.free(params_lang.lang.?);
|
||||
try std.testing.expectEqualStrings("us", params_lang.lang.?);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,14 +8,18 @@ const Astronomical = @import("../Astronomical.zig");
|
|||
const TimeZoneOffsets = @import("../location/timezone_offsets.zig");
|
||||
const Coordinates = @import("../Coordinates.zig");
|
||||
|
||||
pub fn render(writer: *std.Io.Writer, weather: types.WeatherData, format: []const u8, use_imperial: bool) !void {
|
||||
pub fn render(allocator: std.mem.Allocator, weather: types.WeatherData, format: []const u8, use_imperial: bool) ![]const u8 {
|
||||
var output: std.ArrayList(u8) = .empty;
|
||||
errdefer output.deinit(allocator);
|
||||
const writer = output.writer(allocator);
|
||||
|
||||
var i: usize = 0;
|
||||
while (i < format.len) {
|
||||
if (format[i] == '%' and i + 1 < format.len) {
|
||||
const code = format[i + 1];
|
||||
switch (code) {
|
||||
'c' => try writer.writeAll(emoji.getWeatherEmoji(weather.current.weather_code)),
|
||||
'C' => try writer.writeAll(weather.current.condition),
|
||||
'c' => try writer.print("{s}", .{emoji.getWeatherEmoji(weather.current.weather_code)}),
|
||||
'C' => try writer.print("{s}", .{weather.current.condition}),
|
||||
'h' => try writer.print("{d}%", .{weather.current.humidity}),
|
||||
't' => {
|
||||
const temp = if (use_imperial) weather.current.tempFahrenheit() else weather.current.temp_c;
|
||||
|
|
@ -42,7 +46,7 @@ pub fn render(writer: *std.Io.Writer, weather: types.WeatherData, format: []cons
|
|||
const unit = if (use_imperial) "mph" else "km/h";
|
||||
try writer.print("{d:.0} {s} {s}", .{ wind, unit, utils.degreeToDirection(weather.current.wind_deg) });
|
||||
},
|
||||
'l' => try writer.writeAll(weather.locationDisplayName()),
|
||||
'l' => try writer.print("{s}", .{weather.locationDisplayName()}),
|
||||
'p' => {
|
||||
const precip = if (use_imperial) weather.current.precip_mm * 0.0393701 else weather.current.precip_mm;
|
||||
const unit = if (use_imperial) "in" else "mm";
|
||||
|
|
@ -56,7 +60,7 @@ pub fn render(writer: *std.Io.Writer, weather: types.WeatherData, format: []cons
|
|||
'm' => {
|
||||
const now = try nowAt(weather.coords);
|
||||
const moon = Moon.getPhase(now);
|
||||
try writer.writeAll(moon.emoji());
|
||||
try writer.print("{s}", .{moon.emoji()});
|
||||
},
|
||||
'M' => {
|
||||
const now = try nowAt(weather.coords);
|
||||
|
|
@ -97,6 +101,8 @@ pub fn render(writer: *std.Io.Writer, weather: types.WeatherData, format: []cons
|
|||
i += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return output.toOwnedSlice(allocator);
|
||||
}
|
||||
|
||||
fn nowAt(coords: Coordinates) !i64 {
|
||||
|
|
@ -109,27 +115,6 @@ fn nowAt(coords: Coordinates) !i64 {
|
|||
return new.unixTimestamp();
|
||||
}
|
||||
|
||||
const test_weather = types.WeatherData{
|
||||
// SAFETY: allocator unused in these tests
|
||||
.allocator = undefined,
|
||||
.location = "Test",
|
||||
.display_name = null,
|
||||
.coords = .{ .latitude = 0, .longitude = 0 },
|
||||
.current = .{
|
||||
.temp_c = 10,
|
||||
.feels_like_c = 10,
|
||||
.condition = "Clear",
|
||||
.weather_code = .clear,
|
||||
.humidity = 50,
|
||||
.wind_kph = 10,
|
||||
.wind_deg = 0,
|
||||
.pressure_mb = 1013,
|
||||
.precip_mm = 0,
|
||||
.visibility_km = 10,
|
||||
},
|
||||
.forecast = &.{},
|
||||
};
|
||||
|
||||
test "render custom format with location and temp" {
|
||||
const allocator = std.testing.allocator;
|
||||
|
||||
|
|
@ -152,12 +137,8 @@ test "render custom format with location and temp" {
|
|||
.allocator = allocator,
|
||||
};
|
||||
|
||||
var output_buf: [1024]u8 = undefined;
|
||||
var writer = std.Io.Writer.fixed(&output_buf);
|
||||
|
||||
try render(&writer, weather, "%l: %c %t", false);
|
||||
|
||||
const output = output_buf[0..writer.end];
|
||||
const output = try render(allocator, weather, "%l: %c %t", false);
|
||||
defer allocator.free(output);
|
||||
|
||||
try std.testing.expect(std.mem.indexOf(u8, output, "London") != null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, output, "+7.0°C") != null);
|
||||
|
|
@ -185,12 +166,8 @@ test "render custom format with newline" {
|
|||
.allocator = allocator,
|
||||
};
|
||||
|
||||
var output_buf: [1024]u8 = undefined;
|
||||
var writer = std.Io.Writer.fixed(&output_buf);
|
||||
|
||||
try render(&writer, weather, "%l%n%C", false);
|
||||
|
||||
const output = output_buf[0..writer.end];
|
||||
const output = try render(allocator, weather, "%l%n%C", false);
|
||||
defer allocator.free(output);
|
||||
|
||||
try std.testing.expect(std.mem.indexOf(u8, output, "Paris\nClear") != null);
|
||||
}
|
||||
|
|
@ -217,12 +194,8 @@ test "render custom format with humidity and pressure" {
|
|||
.allocator = allocator,
|
||||
};
|
||||
|
||||
var output_buf: [1024]u8 = undefined;
|
||||
var writer = std.Io.Writer.fixed(&output_buf);
|
||||
|
||||
try render(&writer, weather, "Humidity: %h, Pressure: %P", false);
|
||||
|
||||
const output = output_buf[0..writer.end];
|
||||
const output = try render(allocator, weather, "Humidity: %h, Pressure: %P", false);
|
||||
defer allocator.free(output);
|
||||
|
||||
try std.testing.expect(std.mem.indexOf(u8, output, "85%") != null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, output, "1012") != null);
|
||||
|
|
@ -250,119 +223,10 @@ test "render custom format with imperial units" {
|
|||
.allocator = allocator,
|
||||
};
|
||||
|
||||
var output_buf: [1024]u8 = undefined;
|
||||
var writer = std.Io.Writer.fixed(&output_buf);
|
||||
|
||||
try render(&writer, weather, "%t %w %p", true);
|
||||
|
||||
const output = output_buf[0..writer.end];
|
||||
const output = try render(allocator, weather, "%t %w %p", true);
|
||||
defer allocator.free(output);
|
||||
|
||||
try std.testing.expect(std.mem.indexOf(u8, output, "+50.0°F") != null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, output, "mph") != null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, output, "in") != null);
|
||||
}
|
||||
|
||||
test "render custom format with feels like temp" {
|
||||
var output_buf: [1024]u8 = undefined;
|
||||
var writer = std.Io.Writer.fixed(&output_buf);
|
||||
|
||||
try render(&writer, test_weather, "%f", false);
|
||||
|
||||
const output = output_buf[0..writer.end];
|
||||
try std.testing.expectEqualStrings("+10.0°C", output);
|
||||
}
|
||||
|
||||
test "render custom format with moon phase" {
|
||||
var output_buf: [1024]u8 = undefined;
|
||||
var writer = std.Io.Writer.fixed(&output_buf);
|
||||
|
||||
try render(&writer, test_weather, "%m", false);
|
||||
|
||||
const output = output_buf[0..writer.end];
|
||||
try std.testing.expectEqualStrings("🌗", output);
|
||||
}
|
||||
|
||||
test "render custom format with moon day" {
|
||||
var output_buf: [1024]u8 = undefined;
|
||||
var writer = std.Io.Writer.fixed(&output_buf);
|
||||
|
||||
try render(&writer, test_weather, "%M", false);
|
||||
|
||||
const output = output_buf[0..writer.end];
|
||||
try std.testing.expectEqualStrings("21", output);
|
||||
}
|
||||
|
||||
test "render custom format with astronomical dawn" {
|
||||
var test_weather_astro = test_weather;
|
||||
test_weather_astro.coords = .{ .latitude = 45.0, .longitude = -122.0 };
|
||||
|
||||
var output_buf: [1024]u8 = undefined;
|
||||
var writer = std.Io.Writer.fixed(&output_buf);
|
||||
|
||||
try render(&writer, test_weather_astro, "%D", false);
|
||||
|
||||
const output = output_buf[0..writer.end];
|
||||
try std.testing.expectEqualStrings("07:12", output);
|
||||
}
|
||||
|
||||
test "render custom format with astronomical sunrise" {
|
||||
var test_weather_astro = test_weather;
|
||||
test_weather_astro.coords = .{ .latitude = 45.0, .longitude = -122.0 };
|
||||
|
||||
var output_buf: [1024]u8 = undefined;
|
||||
var writer = std.Io.Writer.fixed(&output_buf);
|
||||
|
||||
try render(&writer, test_weather_astro, "%S", false);
|
||||
|
||||
const output = output_buf[0..writer.end];
|
||||
try std.testing.expectEqualStrings("07:45", output);
|
||||
}
|
||||
|
||||
test "render custom format with astronomical zenith" {
|
||||
var test_weather_astro = test_weather;
|
||||
test_weather_astro.coords = .{ .latitude = 45.0, .longitude = -122.0 };
|
||||
|
||||
var output_buf: [1024]u8 = undefined;
|
||||
var writer = std.Io.Writer.fixed(&output_buf);
|
||||
|
||||
try render(&writer, test_weather_astro, "%z", false);
|
||||
|
||||
const output = output_buf[0..writer.end];
|
||||
try std.testing.expectEqualStrings("12:14", output);
|
||||
}
|
||||
|
||||
test "render custom format with astronomical sunset" {
|
||||
var test_weather_astro = test_weather;
|
||||
test_weather_astro.coords = .{ .latitude = 45.0, .longitude = -122.0 };
|
||||
|
||||
var output_buf: [1024]u8 = undefined;
|
||||
var writer = std.Io.Writer.fixed(&output_buf);
|
||||
|
||||
try render(&writer, test_weather_astro, "%s", false);
|
||||
|
||||
const output = output_buf[0..writer.end];
|
||||
try std.testing.expectEqualStrings("16:44", output);
|
||||
}
|
||||
|
||||
test "render custom format with astronomical dusk" {
|
||||
var test_weather_astro = test_weather;
|
||||
test_weather_astro.coords = .{ .latitude = 45.0, .longitude = -122.0 };
|
||||
|
||||
var output_buf: [1024]u8 = undefined;
|
||||
var writer = std.Io.Writer.fixed(&output_buf);
|
||||
|
||||
try render(&writer, test_weather_astro, "%d", false);
|
||||
|
||||
const output = output_buf[0..writer.end];
|
||||
try std.testing.expectEqualStrings("17:17", output);
|
||||
}
|
||||
|
||||
test "render custom format with percent sign" {
|
||||
var output_buf: [1024]u8 = undefined;
|
||||
var writer = std.Io.Writer.fixed(&output_buf);
|
||||
|
||||
try render(&writer, test_weather, "%%", false);
|
||||
|
||||
const output = output_buf[0..writer.end];
|
||||
try std.testing.expectEqualStrings("%", output);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
const std = @import("std");
|
||||
const types = @import("../weather/types.zig");
|
||||
const zeit = @import("zeit");
|
||||
const utils = @import("utils.zig");
|
||||
|
||||
/// 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
|
||||
|
|
@ -47,6 +46,13 @@ fn selectHourlyForecasts(all_hours: []const types.HourlyForecast, buf: []?types.
|
|||
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,
|
||||
|
|
@ -100,8 +106,11 @@ pub const RenderOptions = struct {
|
|||
format: Format = .ansi,
|
||||
};
|
||||
|
||||
pub fn render(writer: *std.Io.Writer, data: types.WeatherData, options: RenderOptions) !void {
|
||||
const w = writer;
|
||||
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.format == .html) try w.writeAll("<pre>");
|
||||
if (!options.super_quiet)
|
||||
try w.print(
|
||||
|
|
@ -119,6 +128,8 @@ pub fn render(writer: *std.Io.Writer, data: types.WeatherData, options: RenderOp
|
|||
}
|
||||
}
|
||||
if (options.format == .html) try w.writeAll("</pre>");
|
||||
|
||||
return output.toOwnedSlice();
|
||||
}
|
||||
|
||||
fn renderCurrent(w: *std.Io.Writer, current: types.CurrentCondition, options: RenderOptions) !void {
|
||||
|
|
@ -140,7 +151,7 @@ fn renderCurrent(w: *std.Io.Writer, current: types.CurrentCondition, options: Re
|
|||
.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], utils.degreeToArrow(current.wind_deg), wind_speed, wind_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";
|
||||
|
|
@ -157,7 +168,7 @@ fn renderCurrent(w: *std.Io.Writer, current: types.CurrentCondition, options: Re
|
|||
|
||||
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], utils.degreeToArrow(current.wind_deg), wind_color_code, wind_speed, reset, wind_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";
|
||||
|
|
@ -173,7 +184,7 @@ fn renderCurrent(w: *std.Io.Writer, current: types.CurrentCondition, options: Re
|
|||
|
||||
try w.print("{s} {s}\n", .{ art[0], current.condition });
|
||||
try w.print("{s} <span style=\"color:{s}\">{c}{d:.0}({c}{d:.0})</span> {s}\n", .{ art[1], temp_color, sign, abs_temp, fl_sign, abs_fl, temp_unit });
|
||||
try w.print("{s} {s} <span style=\"color:{s}\">{d:.0}</span> {s}\n", .{ art[2], utils.degreeToArrow(current.wind_deg), wind_color, wind_speed, wind_unit });
|
||||
try w.print("{s} {s} <span style=\"color:{s}\">{d:.0}</span> {s}\n", .{ art[2], degreeToArrow(current.wind_deg), wind_color, wind_speed, wind_unit });
|
||||
if (current.visibility_km) |_| {
|
||||
const visibility = if (options.use_imperial) current.visiblityMph().? else current.visibility_km.?;
|
||||
const vis_unit = if (options.use_imperial) "mi" else "km";
|
||||
|
|
@ -323,7 +334,7 @@ fn renderHourlyCell(w: *std.Io.Writer, hour: types.HourlyForecast, line: usize,
|
|||
.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 = utils.degreeToArrow(hour.wind_deg);
|
||||
const arrow = degreeToArrow(hour.wind_deg);
|
||||
switch (options.format) {
|
||||
.ansi => {
|
||||
const color = windColor(hour.wind_kph);
|
||||
|
|
@ -667,12 +678,8 @@ test "render with imperial units" {
|
|||
.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];
|
||||
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);
|
||||
|
|
@ -730,12 +737,12 @@ test "partly cloudy weather art" {
|
|||
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 output = try render(
|
||||
std.testing.allocator,
|
||||
data,
|
||||
.{ .format = format },
|
||||
);
|
||||
defer std.testing.allocator.free(output);
|
||||
|
||||
const target = getWeatherArt(
|
||||
data.current.weather_code,
|
||||
|
|
@ -937,23 +944,15 @@ test "temperature matches between ansi and custom format" {
|
|||
.allocator = std.testing.allocator,
|
||||
};
|
||||
|
||||
var ansi_buf: [4096]u8 = undefined;
|
||||
var ansi_writer = std.Io.Writer.fixed(&ansi_buf);
|
||||
const ansi_output = try render(std.testing.allocator, data, .{ .use_imperial = true });
|
||||
defer std.testing.allocator.free(ansi_output);
|
||||
|
||||
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];
|
||||
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, output, "55.6°F") != null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, custom_output, "55.6°F") != null);
|
||||
}
|
||||
|
||||
test "tempColor returns correct colors for temperature ranges" {
|
||||
|
|
@ -1038,12 +1037,8 @@ test "plain text format - MetNo real data" {
|
|||
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 output = try render(allocator, weather_data, .{ .format = .plain_text, .days = 3 });
|
||||
defer allocator.free(output);
|
||||
|
||||
const expected =
|
||||
\\Weather report: 47.6038,-122.3301
|
||||
|
|
@ -1250,12 +1245,8 @@ test "ansi format - MetNo real data - phoenix" {
|
|||
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 output = try render(allocator, weather_data, .{ .format = .ansi, .days = 3, .use_imperial = true });
|
||||
defer allocator.free(output);
|
||||
|
||||
const expected = @embedFile("../tests/metno-phoenix.ansi");
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
const std = @import("std");
|
||||
const types = @import("../weather/types.zig");
|
||||
|
||||
pub fn render(writer: *std.Io.Writer, weather: types.WeatherData) !void {
|
||||
pub fn render(allocator: std.mem.Allocator, weather: types.WeatherData) ![]const u8 {
|
||||
const data = .{
|
||||
.current_condition = .{
|
||||
.temp_C = weather.current.temp_c,
|
||||
|
|
@ -16,7 +16,7 @@ pub fn render(writer: *std.Io.Writer, weather: types.WeatherData) !void {
|
|||
.weather = weather.forecast,
|
||||
};
|
||||
|
||||
try writer.print("{f}", .{std.json.fmt(data, .{})});
|
||||
return try std.fmt.allocPrint(allocator, "{f}", .{std.json.fmt(data, .{})});
|
||||
}
|
||||
|
||||
test "render json format" {
|
||||
|
|
@ -41,12 +41,8 @@ test "render json format" {
|
|||
.allocator = allocator,
|
||||
};
|
||||
|
||||
var output_buf: [4096]u8 = undefined;
|
||||
var writer = std.Io.Writer.fixed(&output_buf);
|
||||
|
||||
try render(&writer, weather);
|
||||
|
||||
const output = output_buf[0..writer.end];
|
||||
const output = try render(allocator, weather);
|
||||
defer allocator.free(output);
|
||||
|
||||
try std.testing.expect(output.len > 0);
|
||||
try std.testing.expect(std.mem.indexOf(u8, output, "temp_C") != null);
|
||||
|
|
|
|||
|
|
@ -3,146 +3,195 @@ const types = @import("../weather/types.zig");
|
|||
const emoji = @import("emoji.zig");
|
||||
const utils = @import("utils.zig");
|
||||
|
||||
const Format = enum(u3) {
|
||||
@"1" = 1,
|
||||
@"2" = 2,
|
||||
@"3" = 3,
|
||||
@"4" = 4,
|
||||
};
|
||||
|
||||
pub fn render(writer: *std.Io.Writer, data: types.WeatherData, format: Format, use_imperial: bool) !void {
|
||||
const temp = if (use_imperial) data.current.tempFahrenheit() else data.current.temp_c;
|
||||
const unit = if (use_imperial) "°F" else "°C";
|
||||
const sign: []const u8 = if (temp >= 0) "+" else if (temp < 0) "-" else "";
|
||||
const abs_temp = @abs(temp);
|
||||
const wind = if (use_imperial) data.current.windMph() else data.current.wind_kph;
|
||||
const wind_unit = if (use_imperial) "mph" else "km/h";
|
||||
|
||||
switch (format) {
|
||||
.@"1" => {
|
||||
try writer.print("{s} {s}{d:.0}{s}", .{
|
||||
emoji.getWeatherEmoji(data.current.weather_code),
|
||||
sign,
|
||||
abs_temp,
|
||||
unit,
|
||||
});
|
||||
},
|
||||
.@"2" => {
|
||||
try writer.print("{s} 🌡️{s}{d:.0}{s} 🌬️{s}{d:.0}{s}", .{
|
||||
emoji.getWeatherEmoji(data.current.weather_code),
|
||||
sign,
|
||||
abs_temp,
|
||||
unit,
|
||||
utils.degreeToArrow(data.current.wind_deg),
|
||||
wind,
|
||||
wind_unit,
|
||||
});
|
||||
},
|
||||
.@"3" => {
|
||||
try writer.print("{s}: {s} {s}{d:.0}{s}", .{
|
||||
data.location,
|
||||
emoji.getWeatherEmoji(data.current.weather_code),
|
||||
sign,
|
||||
abs_temp,
|
||||
unit,
|
||||
});
|
||||
},
|
||||
.@"4" => {
|
||||
try writer.print("{s}: {s} 🌡️{s}{d:.0}{s} 🌬️{s}{d:.0}{s}", .{
|
||||
data.location,
|
||||
emoji.getWeatherEmoji(data.current.weather_code),
|
||||
sign,
|
||||
abs_temp,
|
||||
unit,
|
||||
utils.degreeToArrow(data.current.wind_deg),
|
||||
wind,
|
||||
wind_unit,
|
||||
});
|
||||
},
|
||||
pub fn render(allocator: std.mem.Allocator, data: types.WeatherData, format: []const u8, use_imperial: bool) ![]const u8 {
|
||||
if (std.mem.eql(u8, format, "1")) {
|
||||
const temp = if (use_imperial) data.current.tempFahrenheit() else data.current.temp_c;
|
||||
const unit = if (use_imperial) "°F" else "°C";
|
||||
return std.fmt.allocPrint(allocator, "{s}: {s} {d:.0}{s}", .{
|
||||
data.location,
|
||||
emoji.getWeatherEmoji(data.current.weather_code),
|
||||
temp,
|
||||
unit,
|
||||
});
|
||||
} else if (std.mem.eql(u8, format, "2")) {
|
||||
const temp = if (use_imperial) data.current.tempFahrenheit() else data.current.temp_c;
|
||||
const unit = if (use_imperial) "°F" else "°C";
|
||||
const wind = if (use_imperial) data.current.windMph() else data.current.wind_kph;
|
||||
const wind_unit = if (use_imperial) "mph" else "km/h";
|
||||
return std.fmt.allocPrint(allocator, "{s}: {s} {d:.0}{s} {s}{s}{d:.0}{s}", .{
|
||||
data.location,
|
||||
emoji.getWeatherEmoji(data.current.weather_code),
|
||||
temp,
|
||||
unit,
|
||||
"🌬️",
|
||||
utils.degreeToDirection(data.current.wind_deg),
|
||||
wind,
|
||||
wind_unit,
|
||||
});
|
||||
} else if (std.mem.eql(u8, format, "3")) {
|
||||
const temp = if (use_imperial) data.current.tempFahrenheit() else data.current.temp_c;
|
||||
const unit = if (use_imperial) "°F" else "°C";
|
||||
const wind = if (use_imperial) data.current.windMph() else data.current.wind_kph;
|
||||
const wind_unit = if (use_imperial) "mph" else "km/h";
|
||||
return std.fmt.allocPrint(allocator, "{s}: {s} {d:.0}{s} {s}{s}{d:.0}{s} {s}{d}%%", .{
|
||||
data.location,
|
||||
emoji.getWeatherEmoji(data.current.weather_code),
|
||||
temp,
|
||||
unit,
|
||||
"🌬️",
|
||||
utils.degreeToDirection(data.current.wind_deg),
|
||||
wind,
|
||||
wind_unit,
|
||||
"💧",
|
||||
data.current.humidity,
|
||||
});
|
||||
} else if (std.mem.eql(u8, format, "4")) {
|
||||
const temp = if (use_imperial) data.current.tempFahrenheit() else data.current.temp_c;
|
||||
const unit = if (use_imperial) "°F" else "°C";
|
||||
const wind = if (use_imperial) data.current.windMph() else data.current.wind_kph;
|
||||
const wind_unit = if (use_imperial) "mph" else "km/h";
|
||||
return std.fmt.allocPrint(allocator, "{s}: {s} {d:.0}{s} {s}{s}{d:.0}{s} {s}{d}%% {s}", .{
|
||||
data.location,
|
||||
emoji.getWeatherEmoji(data.current.weather_code),
|
||||
temp,
|
||||
unit,
|
||||
"🌬️",
|
||||
utils.degreeToDirection(data.current.wind_deg),
|
||||
wind,
|
||||
wind_unit,
|
||||
"💧",
|
||||
data.current.humidity,
|
||||
"☀️",
|
||||
});
|
||||
} else {
|
||||
return renderCustom(allocator, data, format, use_imperial);
|
||||
}
|
||||
}
|
||||
|
||||
const test_data = types.WeatherData{
|
||||
.location = "London",
|
||||
.coords = .{ .latitude = 0, .longitude = 0 },
|
||||
.current = .{
|
||||
.temp_c = 15.0,
|
||||
.feels_like_c = 15.0,
|
||||
.condition = "Clear",
|
||||
.weather_code = .clear,
|
||||
.humidity = 65,
|
||||
.wind_kph = 10.0,
|
||||
.wind_deg = 0.0,
|
||||
.pressure_mb = 1013.0,
|
||||
.precip_mm = 0.0,
|
||||
.visibility_km = null,
|
||||
},
|
||||
.forecast = &.{},
|
||||
.allocator = std.testing.allocator,
|
||||
};
|
||||
fn renderCustom(allocator: std.mem.Allocator, data: types.WeatherData, format: []const u8, use_imperial: bool) ![]const u8 {
|
||||
var output: std.ArrayList(u8) = .empty;
|
||||
errdefer output.deinit(allocator);
|
||||
|
||||
var i: usize = 0;
|
||||
while (i < format.len) {
|
||||
if (format[i] == '%' and i + 1 < format.len) {
|
||||
const code = format[i + 1];
|
||||
switch (code) {
|
||||
'c' => try output.appendSlice(allocator, emoji.getWeatherEmoji(data.current.weather_code)),
|
||||
'C' => try output.appendSlice(allocator, data.current.condition),
|
||||
'h' => try output.writer(allocator).print("{d}", .{data.current.humidity}),
|
||||
't' => {
|
||||
const temp = if (use_imperial) data.current.tempFahrenheit() else data.current.temp_c;
|
||||
try output.writer(allocator).print("{d:.0}", .{temp});
|
||||
},
|
||||
'f' => {
|
||||
const temp = if (use_imperial) data.current.tempFahrenheit() else data.current.temp_c;
|
||||
try output.writer(allocator).print("{d:.0}", .{temp});
|
||||
},
|
||||
'w' => {
|
||||
const wind = if (use_imperial) data.current.windMph() else data.current.wind_kph;
|
||||
const wind_unit = if (use_imperial) "mph" else "km/h";
|
||||
try output.writer(allocator).print("{s}{d:.0}{s}", .{ utils.degreeToDirection(data.current.wind_deg), wind, wind_unit });
|
||||
},
|
||||
'l' => try output.appendSlice(allocator, data.location),
|
||||
'p' => {
|
||||
const precip = if (use_imperial) data.current.precip_mm * 0.0393701 else data.current.precip_mm;
|
||||
try output.writer(allocator).print("{d:.1}", .{precip});
|
||||
},
|
||||
'P' => {
|
||||
const pressure = if (use_imperial) data.current.pressure_mb * 0.02953 else data.current.pressure_mb;
|
||||
try output.writer(allocator).print("{d:.0}", .{pressure});
|
||||
},
|
||||
'%' => try output.append(allocator, '%'),
|
||||
else => {
|
||||
try output.append(allocator, '%');
|
||||
try output.append(allocator, code);
|
||||
},
|
||||
}
|
||||
i += 2;
|
||||
} else {
|
||||
try output.append(allocator, format[i]);
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return output.toOwnedSlice(allocator);
|
||||
}
|
||||
|
||||
test "format 1" {
|
||||
var output_buf: [1024]u8 = undefined;
|
||||
var writer = std.Io.Writer.fixed(&output_buf);
|
||||
const data = types.WeatherData{
|
||||
.location = "London",
|
||||
.coords = .{ .latitude = 0, .longitude = 0 },
|
||||
.current = .{
|
||||
.temp_c = 15.0,
|
||||
.feels_like_c = 15.0,
|
||||
.condition = "Clear",
|
||||
.weather_code = .clear,
|
||||
.humidity = 65,
|
||||
.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 render(&writer, test_data, .@"1", false);
|
||||
const output = try render(std.testing.allocator, data, "1", false);
|
||||
defer std.testing.allocator.free(output);
|
||||
|
||||
const output = output_buf[0..writer.end];
|
||||
try std.testing.expectEqualStrings("London: ☀️ 15°C", output);
|
||||
}
|
||||
|
||||
try std.testing.expectEqualStrings("☀️ +15°C", output);
|
||||
test "custom format" {
|
||||
const data = types.WeatherData{
|
||||
.location = "London",
|
||||
.coords = .{ .latitude = 0, .longitude = 0 },
|
||||
.current = .{
|
||||
.temp_c = 15.0,
|
||||
.feels_like_c = 15.0,
|
||||
.condition = "Clear",
|
||||
.weather_code = .clear,
|
||||
.humidity = 65,
|
||||
.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, "%l: %c %t°C", false);
|
||||
defer std.testing.allocator.free(output);
|
||||
|
||||
try std.testing.expectEqualStrings("London: ☀️ 15°C", output);
|
||||
}
|
||||
|
||||
test "format 2 with imperial units" {
|
||||
var output_buf: [1024]u8 = undefined;
|
||||
var writer = std.Io.Writer.fixed(&output_buf);
|
||||
const data = types.WeatherData{
|
||||
.location = "Portland",
|
||||
.coords = .{ .latitude = 0, .longitude = 0 },
|
||||
.current = .{
|
||||
.temp_c = 10.0,
|
||||
.feels_like_c = 10.0,
|
||||
.condition = "Cloudy",
|
||||
.weather_code = .clouds_overcast,
|
||||
.humidity = 70,
|
||||
.wind_kph = 20.0,
|
||||
.wind_deg = 135.0,
|
||||
.pressure_mb = 1013.0,
|
||||
.precip_mm = 0.0,
|
||||
.visibility_km = null,
|
||||
},
|
||||
.forecast = &.{},
|
||||
.allocator = std.testing.allocator,
|
||||
};
|
||||
|
||||
try render(&writer, test_data, .@"2", true);
|
||||
const output = try render(std.testing.allocator, data, "2", true);
|
||||
defer std.testing.allocator.free(output);
|
||||
|
||||
const output = output_buf[0..writer.end];
|
||||
|
||||
try std.testing.expectEqualStrings("☀️ 🌡️+59°F 🌬️↓6mph", output);
|
||||
}
|
||||
|
||||
test "format 3 with metric units" {
|
||||
var output_buf: [1024]u8 = undefined;
|
||||
var writer = std.Io.Writer.fixed(&output_buf);
|
||||
|
||||
try render(&writer, test_data, .@"3", false);
|
||||
|
||||
const output = output_buf[0..writer.end];
|
||||
|
||||
try std.testing.expectEqualStrings("London: ☀️ +15°C", output);
|
||||
}
|
||||
|
||||
test "format 3 with imperial units" {
|
||||
var output_buf: [1024]u8 = undefined;
|
||||
var writer = std.Io.Writer.fixed(&output_buf);
|
||||
|
||||
try render(&writer, test_data, .@"3", true);
|
||||
|
||||
const output = output_buf[0..writer.end];
|
||||
|
||||
try std.testing.expectEqualStrings("London: ☀️ +59°F", output);
|
||||
}
|
||||
|
||||
test "format 4 with metric units" {
|
||||
var output_buf: [1024]u8 = undefined;
|
||||
var writer = std.Io.Writer.fixed(&output_buf);
|
||||
|
||||
try render(&writer, test_data, .@"4", false);
|
||||
|
||||
const output = output_buf[0..writer.end];
|
||||
|
||||
try std.testing.expectEqualStrings("London: ☀️ 🌡️+15°C 🌬️↓10km/h", output);
|
||||
}
|
||||
|
||||
test "format 4 with imperial units" {
|
||||
var output_buf: [1024]u8 = undefined;
|
||||
var writer = std.Io.Writer.fixed(&output_buf);
|
||||
|
||||
try render(&writer, test_data, .@"4", true);
|
||||
|
||||
const output = output_buf[0..writer.end];
|
||||
|
||||
try std.testing.expectEqualStrings("London: ☀️ 🌡️+59°F 🌬️↓6mph", output);
|
||||
try std.testing.expectEqualStrings("Portland: ☁️ 50°F 🌬️SE12mph", output);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,193 +0,0 @@
|
|||
const std = @import("std");
|
||||
const types = @import("../weather/types.zig");
|
||||
const Moon = @import("../Moon.zig");
|
||||
const utils = @import("utils.zig");
|
||||
|
||||
pub fn render(writer: *std.Io.Writer, weather: types.WeatherData) !void {
|
||||
|
||||
// Current conditions
|
||||
try writer.print("# HELP temperature_feels_like_celsius Feels Like Temperature in Celsius\n", .{});
|
||||
try writer.print("temperature_feels_like_celsius{{forecast=\"current\"}} {d}\n", .{weather.current.feels_like_c});
|
||||
|
||||
try writer.print("# HELP temperature_feels_like_fahrenheit Feels Like Temperature in Fahrenheit\n", .{});
|
||||
try writer.print("temperature_feels_like_fahrenheit{{forecast=\"current\"}} {d}\n", .{weather.current.tempFahrenheit()});
|
||||
|
||||
try writer.print("# HELP cloudcover_percentage Cloud Coverage in Percent\n", .{});
|
||||
try writer.print("cloudcover_percentage{{forecast=\"current\"}} 0\n", .{}); // Not in our data
|
||||
|
||||
try writer.print("# HELP humidity_percentage Humidity in Percent\n", .{});
|
||||
try writer.print("humidity_percentage{{forecast=\"current\"}} {d}\n", .{weather.current.humidity});
|
||||
|
||||
try writer.print("# HELP precipitation_mm Precipitation (Rainfall) in mm\n", .{});
|
||||
try writer.print("precipitation_mm{{forecast=\"current\"}} {d:.1}\n", .{weather.current.precip_mm});
|
||||
|
||||
try writer.print("# HELP pressure_hpa Air pressure in hPa\n", .{});
|
||||
try writer.print("pressure_hpa{{forecast=\"current\"}} {d}\n", .{weather.current.pressure_mb});
|
||||
|
||||
try writer.print("# HELP temperature_celsius Temperature in Celsius\n", .{});
|
||||
try writer.print("temperature_celsius{{forecast=\"current\"}} {d}\n", .{weather.current.temp_c});
|
||||
|
||||
try writer.print("# HELP temperature_fahrenheit Temperature in Fahrenheit\n", .{});
|
||||
try writer.print("temperature_fahrenheit{{forecast=\"current\"}} {d}\n", .{weather.current.tempFahrenheit()});
|
||||
|
||||
try writer.print("# HELP uv_index Ultraviolet Radiation Index\n", .{});
|
||||
try writer.print("uv_index{{forecast=\"current\"}} 0\n", .{}); // Not in our data
|
||||
|
||||
if (weather.current.visibility_km) |vis| {
|
||||
try writer.print("# HELP visibility Visible Distance in Kilometres\n", .{});
|
||||
try writer.print("visibility{{forecast=\"current\"}} {d}\n", .{vis});
|
||||
}
|
||||
|
||||
try writer.print("# HELP weather_code Code to describe Weather Condition\n", .{});
|
||||
try writer.print("weather_code{{forecast=\"current\"}} {d}\n", .{@intFromEnum(weather.current.weather_code)});
|
||||
|
||||
try writer.print("# HELP winddir_degree Wind Direction in Degree\n", .{});
|
||||
try writer.print("winddir_degree{{forecast=\"current\"}} {d}\n", .{weather.current.wind_deg});
|
||||
|
||||
try writer.print("# HELP windspeed_kmph Wind Speed in Kilometres per Hour\n", .{});
|
||||
try writer.print("windspeed_kmph{{forecast=\"current\"}} {d}\n", .{weather.current.wind_kph});
|
||||
|
||||
try writer.print("# HELP windspeed_mph Wind Speed in Miles per Hour\n", .{});
|
||||
try writer.print("windspeed_mph{{forecast=\"current\"}} {d}\n", .{weather.current.windMph()});
|
||||
|
||||
try writer.print("# HELP observation_time Minutes since start of the day the observation happened\n", .{});
|
||||
try writer.print("observation_time{{forecast=\"current\"}} 0\n", .{}); // Not tracked
|
||||
|
||||
try writer.print("# HELP weather_desc Weather Description\n", .{});
|
||||
try writer.print("weather_desc{{forecast=\"current\", description=\"{s}\"}} 1\n", .{weather.current.condition});
|
||||
|
||||
try writer.print("# HELP winddir_16_point Wind Direction on a 16-wind compass rose\n", .{});
|
||||
const wind_dir = utils.degreeToDirection(weather.current.wind_deg);
|
||||
try writer.print("winddir_16_point{{forecast=\"current\", description=\"{s}\"}} 1\n", .{wind_dir});
|
||||
|
||||
// Forecast days
|
||||
for (weather.forecast, 0..) |day, i| {
|
||||
var buf: [16]u8 = undefined;
|
||||
const forecast_label = try std.fmt.bufPrint(&buf, "{d}d", .{i});
|
||||
|
||||
try writer.print("uv_index{{forecast=\"{s}\"}} 0\n", .{forecast_label}); // Not in our data
|
||||
|
||||
try writer.print("# HELP temperature_celsius_maximum Maximum Temperature in Celsius\n", .{});
|
||||
try writer.print("temperature_celsius_maximum{{forecast=\"{s}\"}} {d}\n", .{ forecast_label, day.max_temp_c });
|
||||
|
||||
try writer.print("# HELP temperature_fahrenheit_maximum Maximum Temperature in Fahrenheit\n", .{});
|
||||
try writer.print("temperature_fahrenheit_maximum{{forecast=\"{s}\"}} {d}\n", .{ forecast_label, day.maxTempFahrenheit() });
|
||||
|
||||
try writer.print("# HELP temperature_celsius_minimum Minimum Temperature in Celsius\n", .{});
|
||||
try writer.print("temperature_celsius_minimum{{forecast=\"{s}\"}} {d}\n", .{ forecast_label, day.min_temp_c });
|
||||
|
||||
try writer.print("# HELP temperature_fahrenheit_minimum Minimum Temperature in Fahrenheit\n", .{});
|
||||
try writer.print("temperature_fahrenheit_minimum{{forecast=\"{s}\"}} {d}\n", .{ forecast_label, day.minTempFahrenheit() });
|
||||
|
||||
try writer.print("# HELP sun_hour Hours of sunlight\n", .{});
|
||||
try writer.print("sun_hour{{forecast=\"{s}\"}} 0.0\n", .{forecast_label}); // Not calculated
|
||||
|
||||
try writer.print("# HELP snowfall_cm Total snowfall in cm\n", .{});
|
||||
try writer.print("snowfall_cm{{forecast=\"{s}\"}} 0.0\n", .{forecast_label}); // Not in our data
|
||||
|
||||
// Moon phase - use current time for simplicity
|
||||
const timestamp = std.time.timestamp();
|
||||
const moon = Moon.getPhase(timestamp);
|
||||
|
||||
try writer.print("# HELP astronomy_moon_illumination Percentage of the moon illuminated\n", .{});
|
||||
try writer.print("astronomy_moon_illumination{{forecast=\"{s}\"}} {d}\n", .{ forecast_label, moon.illuminated * 100 });
|
||||
|
||||
try writer.print("# HELP astronomy_moon_phase Phase of the moon\n", .{});
|
||||
try writer.print("astronomy_moon_phase{{forecast=\"{s}\", description=\"{f}\"}} 1\n", .{ forecast_label, moon });
|
||||
|
||||
try writer.print("# HELP astronomy_moonrise_min Minutes since start of the day until the moon appears above the horizon\n", .{});
|
||||
try writer.print("astronomy_moonrise_min{{forecast=\"{s}\"}} 0\n", .{forecast_label}); // Not calculated
|
||||
|
||||
try writer.print("# HELP astronomy_moonset_min Minutes since start of the day until the moon disappears below the horizon\n", .{});
|
||||
try writer.print("astronomy_moonset_min{{forecast=\"{s}\"}} 0\n", .{forecast_label}); // Not calculated
|
||||
|
||||
try writer.print("# HELP astronomy_sunrise_min Minutes since start of the day until the sun appears above the horizon\n", .{});
|
||||
try writer.print("astronomy_sunrise_min{{forecast=\"{s}\"}} 0\n", .{forecast_label}); // Not calculated
|
||||
|
||||
try writer.print("# HELP astronomy_sunset_min Minutes since start of the day until the moon disappears below the horizon\n", .{});
|
||||
try writer.print("astronomy_sunset_min{{forecast=\"{s}\"}} 0\n", .{forecast_label}); // Not calculated
|
||||
}
|
||||
}
|
||||
|
||||
test "prometheus format includes required metrics" {
|
||||
const allocator = std.testing.allocator;
|
||||
|
||||
var forecast_days = [_]types.ForecastDay{
|
||||
.{
|
||||
.date = .{ .year = 2024, .month = .jan, .day = 1 },
|
||||
.max_temp_c = 12.0,
|
||||
.min_temp_c = 5.0,
|
||||
.condition = "Partly cloudy",
|
||||
.weather_code = .clouds_scattered,
|
||||
.hourly = &[_]types.HourlyForecast{},
|
||||
},
|
||||
};
|
||||
|
||||
const weather = types.WeatherData{
|
||||
.location = "London",
|
||||
.coords = .{ .latitude = 51.5074, .longitude = -0.1278 },
|
||||
.current = .{
|
||||
.temp_c = 10.0,
|
||||
.feels_like_c = 8.0,
|
||||
.condition = "Partly cloudy",
|
||||
.weather_code = .clouds_scattered,
|
||||
.humidity = 75,
|
||||
.wind_kph = 15.0,
|
||||
.wind_deg = 225.0,
|
||||
.pressure_mb = 1013.0,
|
||||
.precip_mm = 0.5,
|
||||
.visibility_km = 10.0,
|
||||
},
|
||||
.forecast = &forecast_days,
|
||||
.allocator = allocator,
|
||||
};
|
||||
|
||||
var output_buf: [8192]u8 = undefined;
|
||||
var writer = std.Io.Writer.fixed(&output_buf);
|
||||
|
||||
try render(&writer, weather);
|
||||
|
||||
const output = output_buf[0..writer.end];
|
||||
|
||||
// Check for key metrics
|
||||
try std.testing.expect(std.mem.indexOf(u8, output, "temperature_celsius{forecast=\"current\"}") != null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, output, "humidity_percentage{forecast=\"current\"}") != null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, output, "windspeed_kmph{forecast=\"current\"}") != null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, output, "temperature_celsius_maximum{forecast=\"0d\"}") != null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, output, "astronomy_moon_illumination{forecast=\"0d\"}") != null);
|
||||
}
|
||||
|
||||
test "prometheus format has proper help comments" {
|
||||
const allocator = std.testing.allocator;
|
||||
|
||||
const weather = 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 = 1000.0,
|
||||
.precip_mm = 0.0,
|
||||
.visibility_km = null,
|
||||
},
|
||||
.forecast = &[_]types.ForecastDay{},
|
||||
.allocator = allocator,
|
||||
};
|
||||
|
||||
var output_buf: [4096]u8 = undefined;
|
||||
var writer = std.Io.Writer.fixed(&output_buf);
|
||||
|
||||
try render(&writer, weather);
|
||||
|
||||
const output = output_buf[0..writer.end];
|
||||
|
||||
// Check for HELP comments
|
||||
try std.testing.expect(std.mem.indexOf(u8, output, "# HELP temperature_celsius Temperature in Celsius") != null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, output, "# HELP humidity_percentage Humidity in Percent") != null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, output, "# HELP pressure_hpa Air pressure in hPa") != null);
|
||||
}
|
||||
|
|
@ -2,7 +2,11 @@ const std = @import("std");
|
|||
const types = @import("../weather/types.zig");
|
||||
const utils = @import("utils.zig");
|
||||
|
||||
pub fn render(writer: *std.Io.Writer, weather: types.WeatherData, use_imperial: bool) !void {
|
||||
pub fn render(allocator: std.mem.Allocator, weather: types.WeatherData, use_imperial: bool) ![]const u8 {
|
||||
var output: std.ArrayList(u8) = .empty;
|
||||
errdefer output.deinit(allocator);
|
||||
const writer = output.writer(allocator);
|
||||
|
||||
// Header with location
|
||||
try writer.print("Weather report: {s}\n\n", .{weather.locationDisplayName()});
|
||||
|
||||
|
|
@ -58,6 +62,8 @@ pub fn render(writer: *std.Io.Writer, weather: types.WeatherData, use_imperial:
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
return output.toOwnedSlice(allocator);
|
||||
}
|
||||
|
||||
test "render v2 format" {
|
||||
|
|
@ -82,12 +88,8 @@ test "render v2 format" {
|
|||
.allocator = allocator,
|
||||
};
|
||||
|
||||
var output_buf: [2048]u8 = undefined;
|
||||
var writer = std.Io.Writer.fixed(&output_buf);
|
||||
|
||||
try render(&writer, weather, false);
|
||||
|
||||
const output = output_buf[0..writer.end];
|
||||
const output = try render(allocator, weather, false);
|
||||
defer allocator.free(output);
|
||||
|
||||
try std.testing.expect(output.len > 0);
|
||||
try std.testing.expect(std.mem.indexOf(u8, output, "Munich") != null);
|
||||
|
|
@ -117,12 +119,8 @@ test "render v2 format with imperial units" {
|
|||
.allocator = allocator,
|
||||
};
|
||||
|
||||
var output_buf: [2048]u8 = undefined;
|
||||
var writer = std.Io.Writer.fixed(&output_buf);
|
||||
|
||||
try render(&writer, weather, true);
|
||||
|
||||
const output = output_buf[0..writer.end];
|
||||
const output = try render(allocator, weather, true);
|
||||
defer allocator.free(output);
|
||||
|
||||
try std.testing.expect(std.mem.indexOf(u8, output, "50.0°F") != null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, output, "mph") != null);
|
||||
|
|
|
|||
|
|
@ -3,11 +3,11 @@ const types = @import("../weather/types.zig");
|
|||
pub fn getWeatherEmoji(code: types.WeatherCode) []const u8 {
|
||||
return switch (@intFromEnum(code)) {
|
||||
200...232 => "⛈️", // Thunderstorm
|
||||
300...321 => "🌦️", // Drizzle
|
||||
300...321 => "🌦", // Drizzle
|
||||
500...531 => "🌧️", // Rain
|
||||
600...610, 617...622 => "❄️", // Snow
|
||||
611...616 => "🌨️", // Sleet
|
||||
701, 741 => "🌁", // Mist/fog. Also could be 🌫 / 🌫️
|
||||
600...610, 617...622 => "🌨️", // Snow
|
||||
611...616 => "🌧", // Sleet
|
||||
701, 741 => "🌁", // Mist/fog. Also could be 🌫
|
||||
800 => "☀️", // Clear
|
||||
801, 802 => "⛅️", // Few/scattered clouds
|
||||
803, 804 => "☁️", // Broken/overcast clouds
|
||||
|
|
|
|||
|
|
@ -7,13 +7,6 @@ pub fn degreeToDirection(deg: f32) []const u8 {
|
|||
return directions[@min(idx, 7)];
|
||||
}
|
||||
|
||||
pub 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)];
|
||||
}
|
||||
|
||||
test "degreeToDirection" {
|
||||
try std.testing.expectEqualStrings("N", degreeToDirection(0));
|
||||
try std.testing.expectEqualStrings("NE", degreeToDirection(45));
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue