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
|
run: zig build --summary all
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
run: zig build test -Ddownload-geoip --summary all
|
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
|
- name: Package
|
||||||
run: zig build -Dtarget="$BUILD_TARGET" -Doptimize="$BUILD_OPTIMIZATION"
|
run: zig build -Dtarget="$BUILD_TARGET" -Doptimize="$BUILD_OPTIMIZATION"
|
||||||
- name: Upload
|
- name: Upload
|
||||||
|
|
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -1,4 +1,3 @@
|
||||||
*.swp
|
*.swp
|
||||||
.zig-cache/
|
.zig-cache/
|
||||||
zig-out/
|
zig-out/
|
||||||
coverage/
|
|
||||||
|
|
|
||||||
|
|
@ -2,20 +2,24 @@
|
||||||
|
|
||||||
Features not yet implemented in the Zig version:
|
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
|
- Render weather reports as PNG images
|
||||||
- Support transparency and custom styling
|
- Support transparency and custom styling
|
||||||
- Requires image rendering library integration
|
- Requires image rendering library integration
|
||||||
|
|
||||||
## 2. Language/Localization
|
## 3. Language/Localization
|
||||||
- Accept-Language header parsing
|
- Accept-Language header parsing
|
||||||
- lang query parameter support
|
- lang query parameter support
|
||||||
- Translation of weather conditions and text (54 languages)
|
- Translation of weather conditions and text (54 languages)
|
||||||
|
|
||||||
## 3. Json output
|
## 4. Json output
|
||||||
- Does not match wttr.in format
|
- Does not match wttr.in format
|
||||||
|
|
||||||
## 4. Moon endpoint
|
## 5. Moon endpoint
|
||||||
- `/Moon` and `/Moon@YYYY-MM-DD` endpoints not yet implemented
|
- `/Moon` and `/Moon@YYYY-MM-DD` endpoints not yet implemented
|
||||||
- Moon phase calculation is implemented and available in custom format (%m, %M)
|
- Moon phase calculation is implemented and available in custom format (%m, %M)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -178,6 +178,8 @@ The result will look like:
|
||||||
|
|
||||||
## Prometheus Metrics Output
|
## Prometheus Metrics Output
|
||||||
|
|
||||||
|
**Note:** Not yet implemented - see [MISSING_FEATURES.md](MISSING_FEATURES.md)
|
||||||
|
|
||||||
To fetch information in Prometheus format:
|
To fetch information in Prometheus format:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|
|
||||||
83
build.zig
83
build.zig
|
|
@ -1,6 +1,5 @@
|
||||||
const std = @import("std");
|
const std = @import("std");
|
||||||
const GitVersion = @import("build/GitVersion.zig");
|
const GitVersion = @import("build/GitVersion.zig");
|
||||||
const Coverage = @import("build/Coverage.zig");
|
|
||||||
|
|
||||||
pub fn build(b: *std.Build) void {
|
pub fn build(b: *std.Build) void {
|
||||||
const target = b.standardTargetOptions(.{});
|
const target = b.standardTargetOptions(.{});
|
||||||
|
|
@ -101,36 +100,32 @@ pub fn build(b: *std.Build) void {
|
||||||
maxminddb.installHeadersDirectory(maxminddb_upstream.path("include"), "", .{});
|
maxminddb.installHeadersDirectory(maxminddb_upstream.path("include"), "", .{});
|
||||||
|
|
||||||
const version = GitVersion.getVersion(b, .{});
|
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();
|
const build_options = b.addOptions();
|
||||||
build_options.addOption([]const u8, "version", version);
|
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(.{
|
const exe = b.addExecutable(.{
|
||||||
.name = "wttr",
|
.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);
|
b.installArtifact(exe);
|
||||||
|
|
||||||
|
|
@ -143,19 +138,35 @@ pub fn build(b: *std.Build) void {
|
||||||
const run_step = b.step("run", "Run the app");
|
const run_step = b.step("run", "Run the app");
|
||||||
run_step.dependOn(&run_cmd.step);
|
run_step.dependOn(&run_cmd.step);
|
||||||
|
|
||||||
const tests = b.addTest(.{ .root_module = root_module });
|
const download_geoip = b.option(bool, "download-geoip", "Download GeoIP database for tests") orelse false;
|
||||||
configureCompilationUnit(tests, libs);
|
|
||||||
|
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 run_tests = b.addRunArtifact(tests);
|
||||||
const test_step = b.step("test", "Run tests");
|
const test_step = b.step("test", "Run tests");
|
||||||
test_step.dependOn(&run_tests.step);
|
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 {
|
pub fn day(self: Phase) u8 {
|
||||||
return @intFromFloat(@round(self.age_days));
|
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 {
|
pub fn getPhase(timestamp: i64) Phase {
|
||||||
|
|
|
||||||
|
|
@ -149,25 +149,3 @@ test "parse transparency" {
|
||||||
const params_custom = try QueryParams.parse(allocator, "transparency=200");
|
const params_custom = try QueryParams.parse(allocator, "transparency=200");
|
||||||
try std.testing.expectEqual(@as(u8, 200), params_custom.transparency.?);
|
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);
|
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)
|
// Check X-Forwarded-For header first (for proxies)
|
||||||
if (req.header("x-forwarded-for")) |xff| {
|
if (req.header("x-forwarded-for")) |xff| {
|
||||||
return parseXForwardedFor(xff);
|
return parseXForwardedFor(xff);
|
||||||
|
|
@ -114,7 +114,7 @@ pub fn deinit(self: *Server) void {
|
||||||
self.httpz_server.deinit();
|
self.httpz_server.deinit();
|
||||||
}
|
}
|
||||||
|
|
||||||
pub const MockHarness = struct {
|
const MockHarness = struct {
|
||||||
allocator: std.mem.Allocator,
|
allocator: std.mem.Allocator,
|
||||||
config: Config,
|
config: Config,
|
||||||
geoip: *GeoIp,
|
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 handler.handleWeather(&harness.opts, ht.req, ht.res, client_ip);
|
||||||
|
|
||||||
try ht.expectStatus(200);
|
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" {
|
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 Json = @import("../render/Json.zig");
|
||||||
const V2 = @import("../render/V2.zig");
|
const V2 = @import("../render/V2.zig");
|
||||||
const Custom = @import("../render/Custom.zig");
|
const Custom = @import("../render/Custom.zig");
|
||||||
const Prometheus = @import("../render/Prometheus.zig");
|
|
||||||
const help = @import("help.zig");
|
const help = @import("help.zig");
|
||||||
|
|
||||||
const log = std.log.scoped(.handler);
|
const log = std.log.scoped(.handler);
|
||||||
|
|
@ -136,55 +135,34 @@ fn handleWeatherInternal(
|
||||||
render_options.use_imperial = true; // this is a US IP
|
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 });
|
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);
|
res.headers.add("X-Location-Coordinates", coords_header);
|
||||||
|
|
||||||
if (params.format) |fmt| {
|
res.body = blk: {
|
||||||
// Anything except the json will be plain text
|
if (params.format) |fmt| {
|
||||||
res.content_type = .TEXT;
|
// Anything except the json will be plain text
|
||||||
if (std.mem.eql(u8, fmt, "1")) {
|
res.content_type = .TEXT;
|
||||||
try Line.render(res.writer(), weather, .@"1", render_options.use_imperial);
|
if (std.mem.eql(u8, fmt, "j1")) {
|
||||||
return;
|
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 {
|
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;
|
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 allocator = std.testing.allocator;
|
||||||
const MockHarness = @import("Server.zig").MockHarness;
|
|
||||||
|
|
||||||
var harness = try MockHarness.init(allocator);
|
const params_u = try QueryParams.parse(allocator, "u");
|
||||||
defer harness.deinit();
|
try std.testing.expect(params_u.use_imperial.?);
|
||||||
|
|
||||||
var ht = httpz.testing.init(.{});
|
const params_m = try QueryParams.parse(allocator, "m");
|
||||||
defer ht.deinit();
|
try std.testing.expect(!params_m.use_imperial.?);
|
||||||
|
|
||||||
ht.url("/:help");
|
const params_lang = try QueryParams.parse(allocator, "lang=us");
|
||||||
ht.param("location", ":help");
|
defer allocator.free(params_lang.lang.?);
|
||||||
|
try std.testing.expectEqualStrings("us", params_lang.lang.?);
|
||||||
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");
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,14 +8,18 @@ const Astronomical = @import("../Astronomical.zig");
|
||||||
const TimeZoneOffsets = @import("../location/timezone_offsets.zig");
|
const TimeZoneOffsets = @import("../location/timezone_offsets.zig");
|
||||||
const Coordinates = @import("../Coordinates.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;
|
var i: usize = 0;
|
||||||
while (i < format.len) {
|
while (i < format.len) {
|
||||||
if (format[i] == '%' and i + 1 < format.len) {
|
if (format[i] == '%' and i + 1 < format.len) {
|
||||||
const code = format[i + 1];
|
const code = format[i + 1];
|
||||||
switch (code) {
|
switch (code) {
|
||||||
'c' => try writer.writeAll(emoji.getWeatherEmoji(weather.current.weather_code)),
|
'c' => try writer.print("{s}", .{emoji.getWeatherEmoji(weather.current.weather_code)}),
|
||||||
'C' => try writer.writeAll(weather.current.condition),
|
'C' => try writer.print("{s}", .{weather.current.condition}),
|
||||||
'h' => try writer.print("{d}%", .{weather.current.humidity}),
|
'h' => try writer.print("{d}%", .{weather.current.humidity}),
|
||||||
't' => {
|
't' => {
|
||||||
const temp = if (use_imperial) weather.current.tempFahrenheit() else weather.current.temp_c;
|
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";
|
const unit = if (use_imperial) "mph" else "km/h";
|
||||||
try writer.print("{d:.0} {s} {s}", .{ wind, unit, utils.degreeToDirection(weather.current.wind_deg) });
|
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' => {
|
'p' => {
|
||||||
const precip = if (use_imperial) weather.current.precip_mm * 0.0393701 else weather.current.precip_mm;
|
const precip = if (use_imperial) weather.current.precip_mm * 0.0393701 else weather.current.precip_mm;
|
||||||
const unit = if (use_imperial) "in" else "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' => {
|
'm' => {
|
||||||
const now = try nowAt(weather.coords);
|
const now = try nowAt(weather.coords);
|
||||||
const moon = Moon.getPhase(now);
|
const moon = Moon.getPhase(now);
|
||||||
try writer.writeAll(moon.emoji());
|
try writer.print("{s}", .{moon.emoji()});
|
||||||
},
|
},
|
||||||
'M' => {
|
'M' => {
|
||||||
const now = try nowAt(weather.coords);
|
const now = try nowAt(weather.coords);
|
||||||
|
|
@ -97,6 +101,8 @@ pub fn render(writer: *std.Io.Writer, weather: types.WeatherData, format: []cons
|
||||||
i += 1;
|
i += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return output.toOwnedSlice(allocator);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn nowAt(coords: Coordinates) !i64 {
|
fn nowAt(coords: Coordinates) !i64 {
|
||||||
|
|
@ -109,27 +115,6 @@ fn nowAt(coords: Coordinates) !i64 {
|
||||||
return new.unixTimestamp();
|
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" {
|
test "render custom format with location and temp" {
|
||||||
const allocator = std.testing.allocator;
|
const allocator = std.testing.allocator;
|
||||||
|
|
||||||
|
|
@ -152,12 +137,8 @@ test "render custom format with location and temp" {
|
||||||
.allocator = allocator,
|
.allocator = allocator,
|
||||||
};
|
};
|
||||||
|
|
||||||
var output_buf: [1024]u8 = undefined;
|
const output = try render(allocator, weather, "%l: %c %t", false);
|
||||||
var writer = std.Io.Writer.fixed(&output_buf);
|
defer allocator.free(output);
|
||||||
|
|
||||||
try render(&writer, weather, "%l: %c %t", false);
|
|
||||||
|
|
||||||
const output = output_buf[0..writer.end];
|
|
||||||
|
|
||||||
try std.testing.expect(std.mem.indexOf(u8, output, "London") != null);
|
try std.testing.expect(std.mem.indexOf(u8, output, "London") != null);
|
||||||
try std.testing.expect(std.mem.indexOf(u8, output, "+7.0°C") != 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,
|
.allocator = allocator,
|
||||||
};
|
};
|
||||||
|
|
||||||
var output_buf: [1024]u8 = undefined;
|
const output = try render(allocator, weather, "%l%n%C", false);
|
||||||
var writer = std.Io.Writer.fixed(&output_buf);
|
defer allocator.free(output);
|
||||||
|
|
||||||
try render(&writer, weather, "%l%n%C", false);
|
|
||||||
|
|
||||||
const output = output_buf[0..writer.end];
|
|
||||||
|
|
||||||
try std.testing.expect(std.mem.indexOf(u8, output, "Paris\nClear") != null);
|
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,
|
.allocator = allocator,
|
||||||
};
|
};
|
||||||
|
|
||||||
var output_buf: [1024]u8 = undefined;
|
const output = try render(allocator, weather, "Humidity: %h, Pressure: %P", false);
|
||||||
var writer = std.Io.Writer.fixed(&output_buf);
|
defer allocator.free(output);
|
||||||
|
|
||||||
try render(&writer, weather, "Humidity: %h, Pressure: %P", false);
|
|
||||||
|
|
||||||
const output = output_buf[0..writer.end];
|
|
||||||
|
|
||||||
try std.testing.expect(std.mem.indexOf(u8, output, "85%") != null);
|
try std.testing.expect(std.mem.indexOf(u8, output, "85%") != null);
|
||||||
try std.testing.expect(std.mem.indexOf(u8, output, "1012") != 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,
|
.allocator = allocator,
|
||||||
};
|
};
|
||||||
|
|
||||||
var output_buf: [1024]u8 = undefined;
|
const output = try render(allocator, weather, "%t %w %p", true);
|
||||||
var writer = std.Io.Writer.fixed(&output_buf);
|
defer allocator.free(output);
|
||||||
|
|
||||||
try render(&writer, weather, "%t %w %p", true);
|
|
||||||
|
|
||||||
const output = output_buf[0..writer.end];
|
|
||||||
|
|
||||||
try std.testing.expect(std.mem.indexOf(u8, output, "+50.0°F") != null);
|
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, "mph") != null);
|
||||||
try std.testing.expect(std.mem.indexOf(u8, output, "in") != 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 std = @import("std");
|
||||||
const types = @import("../weather/types.zig");
|
const types = @import("../weather/types.zig");
|
||||||
const zeit = @import("zeit");
|
const zeit = @import("zeit");
|
||||||
const utils = @import("utils.zig");
|
|
||||||
|
|
||||||
/// Select 4 hours representing morning (6am), noon (12pm), evening (6pm), night (12am) in LOCAL time
|
/// 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
|
/// 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;
|
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 {
|
pub const Format = enum {
|
||||||
plain_text,
|
plain_text,
|
||||||
ansi,
|
ansi,
|
||||||
|
|
@ -100,8 +106,11 @@ pub const RenderOptions = struct {
|
||||||
format: Format = .ansi,
|
format: Format = .ansi,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub fn render(writer: *std.Io.Writer, data: types.WeatherData, options: RenderOptions) !void {
|
pub fn render(allocator: std.mem.Allocator, data: types.WeatherData, options: RenderOptions) ![]const u8 {
|
||||||
const w = writer;
|
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.format == .html) try w.writeAll("<pre>");
|
||||||
if (!options.super_quiet)
|
if (!options.super_quiet)
|
||||||
try w.print(
|
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>");
|
if (options.format == .html) try w.writeAll("</pre>");
|
||||||
|
|
||||||
|
return output.toOwnedSlice();
|
||||||
}
|
}
|
||||||
|
|
||||||
fn renderCurrent(w: *std.Io.Writer, current: types.CurrentCondition, options: RenderOptions) !void {
|
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 => {
|
.plain_text => {
|
||||||
try w.print("{s} {s}\n", .{ art[0], current.condition });
|
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} {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) |_| {
|
if (current.visibility_km) |_| {
|
||||||
const visibility = if (options.use_imperial) current.visiblityMph().? else 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";
|
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} {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} \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) |_| {
|
if (current.visibility_km) |_| {
|
||||||
const visibility = if (options.use_imperial) current.visiblityMph().? else 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";
|
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} {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} <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) |_| {
|
if (current.visibility_km) |_| {
|
||||||
const visibility = if (options.use_imperial) current.visiblityMph().? else 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";
|
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 => {
|
.wind => {
|
||||||
const wind_speed = if (options.use_imperial) hour.windMph() else hour.wind_kph;
|
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 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) {
|
switch (options.format) {
|
||||||
.ansi => {
|
.ansi => {
|
||||||
const color = windColor(hour.wind_kph);
|
const color = windColor(hour.wind_kph);
|
||||||
|
|
@ -667,12 +678,8 @@ test "render with imperial units" {
|
||||||
.allocator = std.testing.allocator,
|
.allocator = std.testing.allocator,
|
||||||
};
|
};
|
||||||
|
|
||||||
var output_buf: [4096]u8 = undefined;
|
const output = try render(std.testing.allocator, data, .{ .use_imperial = true });
|
||||||
var writer = std.Io.Writer.fixed(&output_buf);
|
defer std.testing.allocator.free(output);
|
||||||
|
|
||||||
try render(&writer, data, .{ .use_imperial = true });
|
|
||||||
|
|
||||||
const output = output_buf[0..writer.end];
|
|
||||||
|
|
||||||
try std.testing.expect(std.mem.indexOf(u8, output, "+50") != null);
|
try std.testing.expect(std.mem.indexOf(u8, output, "+50") != null);
|
||||||
try std.testing.expect(std.mem.indexOf(u8, output, "°F") != 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 {
|
fn testArt(data: types.WeatherData) !void {
|
||||||
inline for (std.meta.fields(Format)) |f| {
|
inline for (std.meta.fields(Format)) |f| {
|
||||||
const format: Format = @enumFromInt(f.value);
|
const format: Format = @enumFromInt(f.value);
|
||||||
var output_buf: [8192]u8 = undefined;
|
const output = try render(
|
||||||
var writer = std.Io.Writer.fixed(&output_buf);
|
std.testing.allocator,
|
||||||
|
data,
|
||||||
try render(&writer, data, .{ .format = format });
|
.{ .format = format },
|
||||||
|
);
|
||||||
const output = output_buf[0..writer.end];
|
defer std.testing.allocator.free(output);
|
||||||
|
|
||||||
const target = getWeatherArt(
|
const target = getWeatherArt(
|
||||||
data.current.weather_code,
|
data.current.weather_code,
|
||||||
|
|
@ -937,23 +944,15 @@ test "temperature matches between ansi and custom format" {
|
||||||
.allocator = std.testing.allocator,
|
.allocator = std.testing.allocator,
|
||||||
};
|
};
|
||||||
|
|
||||||
var ansi_buf: [4096]u8 = undefined;
|
const ansi_output = try render(std.testing.allocator, data, .{ .use_imperial = true });
|
||||||
var ansi_writer = std.Io.Writer.fixed(&ansi_buf);
|
defer std.testing.allocator.free(ansi_output);
|
||||||
|
|
||||||
try render(&ansi_writer, data, .{ .use_imperial = true });
|
const custom_output = try custom.render(std.testing.allocator, data, "%t", true);
|
||||||
|
defer std.testing.allocator.free(custom_output);
|
||||||
const ansi_output = ansi_buf[0..ansi_writer.end];
|
|
||||||
|
|
||||||
var custom_buf: [1024]u8 = undefined;
|
|
||||||
var custom_writer = std.Io.Writer.fixed(&custom_buf);
|
|
||||||
|
|
||||||
try custom.render(&custom_writer, data, "%t", true);
|
|
||||||
|
|
||||||
const output = custom_buf[0..custom_writer.end];
|
|
||||||
|
|
||||||
// ANSI rounds to integer, custom shows decimal
|
// 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, 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" {
|
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);
|
const weather_data = try MetNo.parse(undefined, allocator, json_data);
|
||||||
defer weather_data.deinit();
|
defer weather_data.deinit();
|
||||||
|
|
||||||
var output_buf: [8192]u8 = undefined;
|
const output = try render(allocator, weather_data, .{ .format = .plain_text, .days = 3 });
|
||||||
var writer = std.Io.Writer.fixed(&output_buf);
|
defer allocator.free(output);
|
||||||
|
|
||||||
try render(&writer, weather_data, .{ .format = .plain_text, .days = 3 });
|
|
||||||
|
|
||||||
const output = output_buf[0..writer.end];
|
|
||||||
|
|
||||||
const expected =
|
const expected =
|
||||||
\\Weather report: 47.6038,-122.3301
|
\\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);
|
const weather_data = try MetNo.parse(undefined, allocator, json_data);
|
||||||
defer weather_data.deinit();
|
defer weather_data.deinit();
|
||||||
|
|
||||||
var output_buf: [16384]u8 = undefined;
|
const output = try render(allocator, weather_data, .{ .format = .ansi, .days = 3, .use_imperial = true });
|
||||||
var writer = std.Io.Writer.fixed(&output_buf);
|
defer allocator.free(output);
|
||||||
|
|
||||||
try render(&writer, weather_data, .{ .format = .ansi, .days = 3, .use_imperial = true });
|
|
||||||
|
|
||||||
const output = output_buf[0..writer.end];
|
|
||||||
|
|
||||||
const expected = @embedFile("../tests/metno-phoenix.ansi");
|
const expected = @embedFile("../tests/metno-phoenix.ansi");
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
const std = @import("std");
|
const std = @import("std");
|
||||||
const types = @import("../weather/types.zig");
|
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 = .{
|
const data = .{
|
||||||
.current_condition = .{
|
.current_condition = .{
|
||||||
.temp_C = weather.current.temp_c,
|
.temp_C = weather.current.temp_c,
|
||||||
|
|
@ -16,7 +16,7 @@ pub fn render(writer: *std.Io.Writer, weather: types.WeatherData) !void {
|
||||||
.weather = weather.forecast,
|
.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" {
|
test "render json format" {
|
||||||
|
|
@ -41,12 +41,8 @@ test "render json format" {
|
||||||
.allocator = allocator,
|
.allocator = allocator,
|
||||||
};
|
};
|
||||||
|
|
||||||
var output_buf: [4096]u8 = undefined;
|
const output = try render(allocator, weather);
|
||||||
var writer = std.Io.Writer.fixed(&output_buf);
|
defer allocator.free(output);
|
||||||
|
|
||||||
try render(&writer, weather);
|
|
||||||
|
|
||||||
const output = output_buf[0..writer.end];
|
|
||||||
|
|
||||||
try std.testing.expect(output.len > 0);
|
try std.testing.expect(output.len > 0);
|
||||||
try std.testing.expect(std.mem.indexOf(u8, output, "temp_C") != null);
|
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 emoji = @import("emoji.zig");
|
||||||
const utils = @import("utils.zig");
|
const utils = @import("utils.zig");
|
||||||
|
|
||||||
const Format = enum(u3) {
|
pub fn render(allocator: std.mem.Allocator, data: types.WeatherData, format: []const u8, use_imperial: bool) ![]const u8 {
|
||||||
@"1" = 1,
|
if (std.mem.eql(u8, format, "1")) {
|
||||||
@"2" = 2,
|
const temp = if (use_imperial) data.current.tempFahrenheit() else data.current.temp_c;
|
||||||
@"3" = 3,
|
const unit = if (use_imperial) "°F" else "°C";
|
||||||
@"4" = 4,
|
return std.fmt.allocPrint(allocator, "{s}: {s} {d:.0}{s}", .{
|
||||||
};
|
data.location,
|
||||||
|
emoji.getWeatherEmoji(data.current.weather_code),
|
||||||
pub fn render(writer: *std.Io.Writer, data: types.WeatherData, format: Format, use_imperial: bool) !void {
|
temp,
|
||||||
const temp = if (use_imperial) data.current.tempFahrenheit() else data.current.temp_c;
|
unit,
|
||||||
const unit = if (use_imperial) "°F" else "°C";
|
});
|
||||||
const sign: []const u8 = if (temp >= 0) "+" else if (temp < 0) "-" else "";
|
} else if (std.mem.eql(u8, format, "2")) {
|
||||||
const abs_temp = @abs(temp);
|
const temp = if (use_imperial) data.current.tempFahrenheit() else data.current.temp_c;
|
||||||
const wind = if (use_imperial) data.current.windMph() else data.current.wind_kph;
|
const unit = if (use_imperial) "°F" else "°C";
|
||||||
const wind_unit = if (use_imperial) "mph" else "km/h";
|
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) {
|
return std.fmt.allocPrint(allocator, "{s}: {s} {d:.0}{s} {s}{s}{d:.0}{s}", .{
|
||||||
.@"1" => {
|
data.location,
|
||||||
try writer.print("{s} {s}{d:.0}{s}", .{
|
emoji.getWeatherEmoji(data.current.weather_code),
|
||||||
emoji.getWeatherEmoji(data.current.weather_code),
|
temp,
|
||||||
sign,
|
unit,
|
||||||
abs_temp,
|
"🌬️",
|
||||||
unit,
|
utils.degreeToDirection(data.current.wind_deg),
|
||||||
});
|
wind,
|
||||||
},
|
wind_unit,
|
||||||
.@"2" => {
|
});
|
||||||
try writer.print("{s} 🌡️{s}{d:.0}{s} 🌬️{s}{d:.0}{s}", .{
|
} else if (std.mem.eql(u8, format, "3")) {
|
||||||
emoji.getWeatherEmoji(data.current.weather_code),
|
const temp = if (use_imperial) data.current.tempFahrenheit() else data.current.temp_c;
|
||||||
sign,
|
const unit = if (use_imperial) "°F" else "°C";
|
||||||
abs_temp,
|
const wind = if (use_imperial) data.current.windMph() else data.current.wind_kph;
|
||||||
unit,
|
const wind_unit = if (use_imperial) "mph" else "km/h";
|
||||||
utils.degreeToArrow(data.current.wind_deg),
|
return std.fmt.allocPrint(allocator, "{s}: {s} {d:.0}{s} {s}{s}{d:.0}{s} {s}{d}%%", .{
|
||||||
wind,
|
data.location,
|
||||||
wind_unit,
|
emoji.getWeatherEmoji(data.current.weather_code),
|
||||||
});
|
temp,
|
||||||
},
|
unit,
|
||||||
.@"3" => {
|
"🌬️",
|
||||||
try writer.print("{s}: {s} {s}{d:.0}{s}", .{
|
utils.degreeToDirection(data.current.wind_deg),
|
||||||
data.location,
|
wind,
|
||||||
emoji.getWeatherEmoji(data.current.weather_code),
|
wind_unit,
|
||||||
sign,
|
"💧",
|
||||||
abs_temp,
|
data.current.humidity,
|
||||||
unit,
|
});
|
||||||
});
|
} else if (std.mem.eql(u8, format, "4")) {
|
||||||
},
|
const temp = if (use_imperial) data.current.tempFahrenheit() else data.current.temp_c;
|
||||||
.@"4" => {
|
const unit = if (use_imperial) "°F" else "°C";
|
||||||
try writer.print("{s}: {s} 🌡️{s}{d:.0}{s} 🌬️{s}{d:.0}{s}", .{
|
const wind = if (use_imperial) data.current.windMph() else data.current.wind_kph;
|
||||||
data.location,
|
const wind_unit = if (use_imperial) "mph" else "km/h";
|
||||||
emoji.getWeatherEmoji(data.current.weather_code),
|
return std.fmt.allocPrint(allocator, "{s}: {s} {d:.0}{s} {s}{s}{d:.0}{s} {s}{d}%% {s}", .{
|
||||||
sign,
|
data.location,
|
||||||
abs_temp,
|
emoji.getWeatherEmoji(data.current.weather_code),
|
||||||
unit,
|
temp,
|
||||||
utils.degreeToArrow(data.current.wind_deg),
|
unit,
|
||||||
wind,
|
"🌬️",
|
||||||
wind_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{
|
fn renderCustom(allocator: std.mem.Allocator, data: types.WeatherData, format: []const u8, use_imperial: bool) ![]const u8 {
|
||||||
.location = "London",
|
var output: std.ArrayList(u8) = .empty;
|
||||||
.coords = .{ .latitude = 0, .longitude = 0 },
|
errdefer output.deinit(allocator);
|
||||||
.current = .{
|
|
||||||
.temp_c = 15.0,
|
var i: usize = 0;
|
||||||
.feels_like_c = 15.0,
|
while (i < format.len) {
|
||||||
.condition = "Clear",
|
if (format[i] == '%' and i + 1 < format.len) {
|
||||||
.weather_code = .clear,
|
const code = format[i + 1];
|
||||||
.humidity = 65,
|
switch (code) {
|
||||||
.wind_kph = 10.0,
|
'c' => try output.appendSlice(allocator, emoji.getWeatherEmoji(data.current.weather_code)),
|
||||||
.wind_deg = 0.0,
|
'C' => try output.appendSlice(allocator, data.current.condition),
|
||||||
.pressure_mb = 1013.0,
|
'h' => try output.writer(allocator).print("{d}", .{data.current.humidity}),
|
||||||
.precip_mm = 0.0,
|
't' => {
|
||||||
.visibility_km = null,
|
const temp = if (use_imperial) data.current.tempFahrenheit() else data.current.temp_c;
|
||||||
},
|
try output.writer(allocator).print("{d:.0}", .{temp});
|
||||||
.forecast = &.{},
|
},
|
||||||
.allocator = std.testing.allocator,
|
'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" {
|
test "format 1" {
|
||||||
var output_buf: [1024]u8 = undefined;
|
const data = types.WeatherData{
|
||||||
var writer = std.Io.Writer.fixed(&output_buf);
|
.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" {
|
test "format 2 with imperial units" {
|
||||||
var output_buf: [1024]u8 = undefined;
|
const data = types.WeatherData{
|
||||||
var writer = std.Io.Writer.fixed(&output_buf);
|
.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("Portland: ☁️ 50°F 🌬️SE12mph", output);
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 types = @import("../weather/types.zig");
|
||||||
const utils = @import("utils.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
|
// Header with location
|
||||||
try writer.print("Weather report: {s}\n\n", .{weather.locationDisplayName()});
|
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" {
|
test "render v2 format" {
|
||||||
|
|
@ -82,12 +88,8 @@ test "render v2 format" {
|
||||||
.allocator = allocator,
|
.allocator = allocator,
|
||||||
};
|
};
|
||||||
|
|
||||||
var output_buf: [2048]u8 = undefined;
|
const output = try render(allocator, weather, false);
|
||||||
var writer = std.Io.Writer.fixed(&output_buf);
|
defer allocator.free(output);
|
||||||
|
|
||||||
try render(&writer, weather, false);
|
|
||||||
|
|
||||||
const output = output_buf[0..writer.end];
|
|
||||||
|
|
||||||
try std.testing.expect(output.len > 0);
|
try std.testing.expect(output.len > 0);
|
||||||
try std.testing.expect(std.mem.indexOf(u8, output, "Munich") != null);
|
try std.testing.expect(std.mem.indexOf(u8, output, "Munich") != null);
|
||||||
|
|
@ -117,12 +119,8 @@ test "render v2 format with imperial units" {
|
||||||
.allocator = allocator,
|
.allocator = allocator,
|
||||||
};
|
};
|
||||||
|
|
||||||
var output_buf: [2048]u8 = undefined;
|
const output = try render(allocator, weather, true);
|
||||||
var writer = std.Io.Writer.fixed(&output_buf);
|
defer allocator.free(output);
|
||||||
|
|
||||||
try render(&writer, weather, true);
|
|
||||||
|
|
||||||
const output = output_buf[0..writer.end];
|
|
||||||
|
|
||||||
try std.testing.expect(std.mem.indexOf(u8, output, "50.0°F") != null);
|
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, "mph") != null);
|
||||||
|
|
|
||||||
|
|
@ -3,11 +3,11 @@ const types = @import("../weather/types.zig");
|
||||||
pub fn getWeatherEmoji(code: types.WeatherCode) []const u8 {
|
pub fn getWeatherEmoji(code: types.WeatherCode) []const u8 {
|
||||||
return switch (@intFromEnum(code)) {
|
return switch (@intFromEnum(code)) {
|
||||||
200...232 => "⛈️", // Thunderstorm
|
200...232 => "⛈️", // Thunderstorm
|
||||||
300...321 => "🌦️", // Drizzle
|
300...321 => "🌦", // Drizzle
|
||||||
500...531 => "🌧️", // Rain
|
500...531 => "🌧️", // Rain
|
||||||
600...610, 617...622 => "❄️", // Snow
|
600...610, 617...622 => "🌨️", // Snow
|
||||||
611...616 => "🌨️", // Sleet
|
611...616 => "🌧", // Sleet
|
||||||
701, 741 => "🌁", // Mist/fog. Also could be 🌫 / 🌫️
|
701, 741 => "🌁", // Mist/fog. Also could be 🌫
|
||||||
800 => "☀️", // Clear
|
800 => "☀️", // Clear
|
||||||
801, 802 => "⛅️", // Few/scattered clouds
|
801, 802 => "⛅️", // Few/scattered clouds
|
||||||
803, 804 => "☁️", // Broken/overcast clouds
|
803, 804 => "☁️", // Broken/overcast clouds
|
||||||
|
|
|
||||||
|
|
@ -7,13 +7,6 @@ pub fn degreeToDirection(deg: f32) []const u8 {
|
||||||
return directions[@min(idx, 7)];
|
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" {
|
test "degreeToDirection" {
|
||||||
try std.testing.expectEqualStrings("N", degreeToDirection(0));
|
try std.testing.expectEqualStrings("N", degreeToDirection(0));
|
||||||
try std.testing.expectEqualStrings("NE", degreeToDirection(45));
|
try std.testing.expectEqualStrings("NE", degreeToDirection(45));
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue