590 lines
21 KiB
Zig
590 lines
21 KiB
Zig
//! Chart-to-PNG export.
|
|
//!
|
|
//! The CLI's `--export-chart <path>` flag funnels through this
|
|
//! module. Each chart-bearing command (`quote`, `projections`)
|
|
//! has a corresponding `export*` function here that:
|
|
//!
|
|
//! 1. Calls the relevant `renderToSurface` (in `charts/chart.zig` or
|
|
//! `charts/projection_chart.zig`) to draw the chart into a z2d
|
|
//! `Surface`.
|
|
//! 2. Calls `z2d.png_exporter.writeToPNGFile` to land the surface
|
|
//! as a PNG file at the user-supplied path.
|
|
//! 3. Frees the surface.
|
|
//!
|
|
//! Default export resolution is 1920x1080 - matches the TUI's
|
|
//! `chart_config.max_width`/`max_height` defaults so the exported
|
|
//! image has the same fidelity the user sees in the terminal.
|
|
//! The TUI's adaptive chart sizing isn't used here because the
|
|
//! export target is a file, not a cell grid.
|
|
//!
|
|
//! Each `export*` takes the `theme.Theme` to render with - the CLI
|
|
//! resolves it from `--theme <PATH>` (a `theme.srf`), defaulting to
|
|
//! the built-in theme so exports keep the TUI's visual identity unless
|
|
//! the user opts into a custom or presentation-friendly palette.
|
|
|
|
const std = @import("std");
|
|
const z2d = @import("z2d");
|
|
const zfin = @import("root.zig");
|
|
const chart = @import("charts/chart.zig");
|
|
const projection_chart = @import("charts/projection_chart.zig");
|
|
const line_chart = @import("charts/line_chart.zig");
|
|
const projections = @import("analytics/projections.zig");
|
|
const forecast = @import("analytics/forecast_evaluation.zig");
|
|
const forecast_chart = @import("charts/forecast_chart.zig");
|
|
const compare_chart = @import("charts/compare_chart.zig");
|
|
const theme = @import("tui/theme.zig");
|
|
|
|
/// Default PNG export resolution. Matches `charts/chart.zig`'s
|
|
/// `ChartConfig.max_width/max_height` defaults so an exported
|
|
/// image carries the same fidelity as a maximally-sized TUI
|
|
/// chart.
|
|
pub const default_width: u32 = 1920;
|
|
pub const default_height: u32 = 1080;
|
|
|
|
/// Export a price+Bollinger+RSI chart for a single symbol, showing the
|
|
/// most recent `display_count` candles. The overlays are computed with a
|
|
/// warmup lookback (via `chart.computeIndicatorsWarmup`) so they're valid
|
|
/// from the first displayed candle. Wraps `renderToSurface` + `writeToPNGFile`.
|
|
pub fn exportSymbolChart(
|
|
io: std.Io,
|
|
alloc: std.mem.Allocator,
|
|
candles: []const zfin.Candle,
|
|
display_count: usize,
|
|
th: theme.Theme,
|
|
path: []const u8,
|
|
) !void {
|
|
var cached = chart.computeIndicatorsWarmup(alloc, candles, display_count, 20) catch |err| switch (err) {
|
|
error.InsufficientData => return error.InsufficientData,
|
|
else => return err,
|
|
};
|
|
defer cached.deinit(alloc);
|
|
const n = @min(candles.len, display_count);
|
|
const display = candles[candles.len - n ..];
|
|
|
|
var rendered = chart.renderToSurface(
|
|
io,
|
|
alloc,
|
|
display,
|
|
null,
|
|
default_width,
|
|
default_height,
|
|
th,
|
|
&cached,
|
|
true,
|
|
) catch |err| switch (err) {
|
|
error.InsufficientData => return error.InsufficientData,
|
|
else => return err,
|
|
};
|
|
defer rendered.deinit(alloc);
|
|
|
|
try z2d.png_exporter.writeToPNGFile(io, rendered.surface, path, .{});
|
|
}
|
|
|
|
/// Export a projection percentile-band chart with optional actuals
|
|
/// overlay. Wraps `projection_chart.renderToSurface` +
|
|
/// `writeToPNGFile`. `retirement_boundary_year` (pass
|
|
/// `ResolvedRetirement.boundaryYear()`) draws the
|
|
/// accumulation/distribution divider when set.
|
|
pub fn exportProjectionChart(
|
|
io: std.Io,
|
|
alloc: std.mem.Allocator,
|
|
bands: []const projections.YearPercentiles,
|
|
actuals: ?projection_chart.ActualsOverlay,
|
|
retirement_boundary_year: ?u16,
|
|
th: theme.Theme,
|
|
path: []const u8,
|
|
) !void {
|
|
var rendered = projection_chart.renderToSurface(
|
|
io,
|
|
alloc,
|
|
bands,
|
|
default_width,
|
|
default_height,
|
|
th,
|
|
actuals,
|
|
true,
|
|
retirement_boundary_year,
|
|
) catch |err| switch (err) {
|
|
error.InsufficientData => return error.InsufficientData,
|
|
else => return err,
|
|
};
|
|
defer rendered.deinit(alloc);
|
|
|
|
try z2d.png_exporter.writeToPNGFile(io, rendered.surface, path, .{});
|
|
}
|
|
|
|
/// Export a single-series portfolio-value timeline as a PNG. Wraps
|
|
/// `line_chart.renderToSurface` + `writeToPNGFile`. `baseline` selects
|
|
/// whether the y-axis fits the data or anchors at zero.
|
|
pub fn exportTimelineChart(
|
|
io: std.Io,
|
|
alloc: std.mem.Allocator,
|
|
points: []const line_chart.LinePoint,
|
|
baseline: line_chart.Baseline,
|
|
th: theme.Theme,
|
|
path: []const u8,
|
|
) !void {
|
|
var rendered = line_chart.renderToSurface(
|
|
io,
|
|
alloc,
|
|
points,
|
|
default_width,
|
|
default_height,
|
|
th,
|
|
.{ .baseline = baseline, .axis_labels = true },
|
|
) catch |err| switch (err) {
|
|
error.InsufficientData => return error.InsufficientData,
|
|
else => return err,
|
|
};
|
|
defer rendered.deinit(alloc);
|
|
|
|
try z2d.png_exporter.writeToPNGFile(io, rendered.surface, path, .{});
|
|
}
|
|
|
|
/// Export the convergence forecast chart (years-until-retirement vs.
|
|
/// observation date) as a PNG. Wraps
|
|
/// `forecast_chart.renderConvergenceToSurface` + `writeToPNGFile`.
|
|
pub fn exportConvergenceChart(
|
|
io: std.Io,
|
|
alloc: std.mem.Allocator,
|
|
points: []const forecast.ConvergencePoint,
|
|
th: theme.Theme,
|
|
path: []const u8,
|
|
) !void {
|
|
var rendered = forecast_chart.renderConvergenceToSurface(
|
|
io,
|
|
alloc,
|
|
points,
|
|
default_width,
|
|
default_height,
|
|
th,
|
|
true,
|
|
) catch |err| switch (err) {
|
|
error.InsufficientData => return error.InsufficientData,
|
|
else => return err,
|
|
};
|
|
defer rendered.deinit(alloc);
|
|
|
|
try z2d.png_exporter.writeToPNGFile(io, rendered.surface, path, .{});
|
|
}
|
|
|
|
/// Export the return back-test forecast chart (expected vs. realized
|
|
/// forward CAGR by anchor) as a PNG. Wraps
|
|
/// `forecast_chart.renderBacktestToSurface` + `writeToPNGFile`.
|
|
pub fn exportBacktestChart(
|
|
io: std.Io,
|
|
alloc: std.mem.Allocator,
|
|
anchors: []const forecast.BacktestAnchor,
|
|
th: theme.Theme,
|
|
path: []const u8,
|
|
) !void {
|
|
var rendered = forecast_chart.renderBacktestToSurface(
|
|
io,
|
|
alloc,
|
|
anchors,
|
|
default_width,
|
|
default_height,
|
|
th,
|
|
true,
|
|
) catch |err| switch (err) {
|
|
error.InsufficientData => return error.InsufficientData,
|
|
else => return err,
|
|
};
|
|
defer rendered.deinit(alloc);
|
|
|
|
try z2d.png_exporter.writeToPNGFile(io, rendered.surface, path, .{});
|
|
}
|
|
|
|
/// Export the `--vs` projection comparison overlay - two percentile-
|
|
/// band envelopes ("then" and "now") on one chart. Wraps
|
|
/// `compare_chart.renderToSurface` + `writeToPNGFile`.
|
|
pub fn exportCompareChart(
|
|
io: std.Io,
|
|
alloc: std.mem.Allocator,
|
|
then_bands: []const projections.YearPercentiles,
|
|
now_bands: []const projections.YearPercentiles,
|
|
th: theme.Theme,
|
|
path: []const u8,
|
|
) !void {
|
|
var rendered = compare_chart.renderToSurface(
|
|
io,
|
|
alloc,
|
|
then_bands,
|
|
now_bands,
|
|
default_width,
|
|
default_height,
|
|
th,
|
|
true,
|
|
) catch |err| switch (err) {
|
|
error.InsufficientData => return error.InsufficientData,
|
|
else => return err,
|
|
};
|
|
defer rendered.deinit(alloc);
|
|
|
|
try z2d.png_exporter.writeToPNGFile(io, rendered.surface, path, .{});
|
|
}
|
|
|
|
// ── Tests ─────────────────────────────────────────────────────────────
|
|
|
|
test "exportSymbolChart writes a non-empty PNG file" {
|
|
const Date = @import("Date.zig");
|
|
const alloc = std.testing.allocator;
|
|
const io = std.testing.io;
|
|
|
|
// Minimum candle count (renderToSurface requires >= 20)
|
|
var candles: [25]zfin.Candle = undefined;
|
|
for (0..25) |i| {
|
|
const price: f64 = 100.0 + @as(f64, @floatFromInt(i));
|
|
candles[i] = .{
|
|
.date = Date.fromYmd(2024, 1, 2).addDays(@intCast(i)),
|
|
.open = price,
|
|
.high = price + 1,
|
|
.low = price - 1,
|
|
.close = price,
|
|
.adj_close = price,
|
|
.volume = 1000,
|
|
};
|
|
}
|
|
|
|
var tmp = std.testing.tmpDir(.{});
|
|
defer tmp.cleanup();
|
|
var path_buf: [std.fs.max_path_bytes]u8 = undefined;
|
|
const dir_len = try tmp.dir.realPathFile(io, ".", &path_buf);
|
|
const dir_path = path_buf[0..dir_len];
|
|
const path = try std.fs.path.join(alloc, &.{ dir_path, "test_export_symbol.png" });
|
|
defer alloc.free(path);
|
|
|
|
try exportSymbolChart(io, alloc, &candles, 60, theme.default_theme, path);
|
|
|
|
// Verify the file exists, starts with the PNG magic, and is
|
|
// big enough to plausibly contain a chart (not just headers).
|
|
var file = try tmp.dir.openFile(io, "test_export_symbol.png", .{});
|
|
defer file.close(io);
|
|
const size = (try file.stat(io)).size;
|
|
try std.testing.expect(size > 1024); // arbitrary lower bound
|
|
|
|
var magic: [8]u8 = undefined;
|
|
var reader = file.reader(io, &.{});
|
|
_ = try reader.interface.readSliceShort(&magic);
|
|
try std.testing.expectEqualSlices(u8, "\x89PNG\x0D\x0A\x1A\x0A", &magic);
|
|
}
|
|
|
|
test "exportSymbolChart returns InsufficientData on too-few candles" {
|
|
const Date = @import("Date.zig");
|
|
const alloc = std.testing.allocator;
|
|
const io = std.testing.io;
|
|
|
|
var candles: [10]zfin.Candle = undefined;
|
|
for (0..10) |i| {
|
|
candles[i] = .{
|
|
.date = Date.fromYmd(2024, 1, 2).addDays(@intCast(i)),
|
|
.open = 100,
|
|
.high = 100,
|
|
.low = 100,
|
|
.close = 100,
|
|
.adj_close = 100,
|
|
.volume = 0,
|
|
};
|
|
}
|
|
|
|
var tmp = std.testing.tmpDir(.{});
|
|
defer tmp.cleanup();
|
|
var path_buf: [std.fs.max_path_bytes]u8 = undefined;
|
|
const dir_len = try tmp.dir.realPathFile(io, ".", &path_buf);
|
|
const dir_path = path_buf[0..dir_len];
|
|
const path = try std.fs.path.join(alloc, &.{ dir_path, "test_export_symbol_insufficient.png" });
|
|
defer alloc.free(path);
|
|
|
|
try std.testing.expectError(
|
|
error.InsufficientData,
|
|
exportSymbolChart(io, alloc, &candles, 60, theme.default_theme, path),
|
|
);
|
|
}
|
|
|
|
test "exportProjectionChart writes a non-empty PNG file" {
|
|
const alloc = std.testing.allocator;
|
|
const io = std.testing.io;
|
|
|
|
// Synthetic 11-year horizon: bands grow linearly, all
|
|
// percentiles spread around p50.
|
|
var bands: [11]projections.YearPercentiles = undefined;
|
|
for (0..11) |i| {
|
|
const base: f64 = 100_000.0 * (1.0 + 0.07 * @as(f64, @floatFromInt(i)));
|
|
bands[i] = .{
|
|
.year = @intCast(i),
|
|
.p10 = base * 0.6,
|
|
.p25 = base * 0.8,
|
|
.p50 = base,
|
|
.p75 = base * 1.2,
|
|
.p90 = base * 1.5,
|
|
};
|
|
}
|
|
|
|
var tmp = std.testing.tmpDir(.{});
|
|
defer tmp.cleanup();
|
|
var path_buf: [std.fs.max_path_bytes]u8 = undefined;
|
|
const dir_len = try tmp.dir.realPathFile(io, ".", &path_buf);
|
|
const dir_path = path_buf[0..dir_len];
|
|
const path = try std.fs.path.join(alloc, &.{ dir_path, "test_export_projection.png" });
|
|
defer alloc.free(path);
|
|
|
|
try exportProjectionChart(io, alloc, &bands, null, null, theme.default_theme, path);
|
|
|
|
var file = try tmp.dir.openFile(io, "test_export_projection.png", .{});
|
|
defer file.close(io);
|
|
const size = (try file.stat(io)).size;
|
|
try std.testing.expect(size > 1024);
|
|
|
|
var magic: [8]u8 = undefined;
|
|
var reader = file.reader(io, &.{});
|
|
_ = try reader.interface.readSliceShort(&magic);
|
|
try std.testing.expectEqualSlices(u8, "\x89PNG\x0D\x0A\x1A\x0A", &magic);
|
|
}
|
|
|
|
test "exportProjectionChart returns InsufficientData with single band" {
|
|
const alloc = std.testing.allocator;
|
|
const io = std.testing.io;
|
|
|
|
var bands: [1]projections.YearPercentiles = .{.{
|
|
.year = 0,
|
|
.p10 = 100,
|
|
.p25 = 110,
|
|
.p50 = 120,
|
|
.p75 = 130,
|
|
.p90 = 140,
|
|
}};
|
|
|
|
var tmp = std.testing.tmpDir(.{});
|
|
defer tmp.cleanup();
|
|
var path_buf: [std.fs.max_path_bytes]u8 = undefined;
|
|
const dir_len = try tmp.dir.realPathFile(io, ".", &path_buf);
|
|
const dir_path = path_buf[0..dir_len];
|
|
const path = try std.fs.path.join(alloc, &.{ dir_path, "test_export_projection_insufficient.png" });
|
|
defer alloc.free(path);
|
|
|
|
try std.testing.expectError(
|
|
error.InsufficientData,
|
|
exportProjectionChart(io, alloc, &bands, null, null, theme.default_theme, path),
|
|
);
|
|
}
|
|
|
|
test "exportTimelineChart writes a non-empty PNG file" {
|
|
const Date = @import("Date.zig");
|
|
const alloc = std.testing.allocator;
|
|
const io = std.testing.io;
|
|
|
|
var points: [12]line_chart.LinePoint = undefined;
|
|
for (0..12) |i| {
|
|
points[i] = .{
|
|
.date = Date.fromYmd(2025, 1, 1).addDays(@intCast(i * 30)),
|
|
.value = 1_000_000.0 + 25_000.0 * @as(f64, @floatFromInt(i)),
|
|
};
|
|
}
|
|
|
|
var tmp = std.testing.tmpDir(.{});
|
|
defer tmp.cleanup();
|
|
var path_buf: [std.fs.max_path_bytes]u8 = undefined;
|
|
const dir_len = try tmp.dir.realPathFile(io, ".", &path_buf);
|
|
const dir_path = path_buf[0..dir_len];
|
|
const path = try std.fs.path.join(alloc, &.{ dir_path, "test_export_timeline.png" });
|
|
defer alloc.free(path);
|
|
|
|
try exportTimelineChart(io, alloc, &points, .fit, theme.default_theme, path);
|
|
|
|
var file = try tmp.dir.openFile(io, "test_export_timeline.png", .{});
|
|
defer file.close(io);
|
|
const size = (try file.stat(io)).size;
|
|
try std.testing.expect(size > 1024);
|
|
|
|
var magic: [8]u8 = undefined;
|
|
var reader = file.reader(io, &.{});
|
|
_ = try reader.interface.readSliceShort(&magic);
|
|
try std.testing.expectEqualSlices(u8, "\x89PNG\x0D\x0A\x1A\x0A", &magic);
|
|
}
|
|
|
|
test "exportTimelineChart returns InsufficientData with a single point" {
|
|
const Date = @import("Date.zig");
|
|
const alloc = std.testing.allocator;
|
|
const io = std.testing.io;
|
|
|
|
var points: [1]line_chart.LinePoint = .{.{ .date = Date.fromYmd(2025, 1, 1), .value = 1_000_000 }};
|
|
|
|
var tmp = std.testing.tmpDir(.{});
|
|
defer tmp.cleanup();
|
|
var path_buf: [std.fs.max_path_bytes]u8 = undefined;
|
|
const dir_len = try tmp.dir.realPathFile(io, ".", &path_buf);
|
|
const dir_path = path_buf[0..dir_len];
|
|
const path = try std.fs.path.join(alloc, &.{ dir_path, "test_export_timeline_insufficient.png" });
|
|
defer alloc.free(path);
|
|
|
|
try std.testing.expectError(
|
|
error.InsufficientData,
|
|
exportTimelineChart(io, alloc, &points, .fit, theme.default_theme, path),
|
|
);
|
|
}
|
|
|
|
test "exportConvergenceChart writes a non-empty PNG file" {
|
|
const Date = @import("Date.zig");
|
|
const alloc = std.testing.allocator;
|
|
const io = std.testing.io;
|
|
|
|
const points = [_]forecast.ConvergencePoint{
|
|
.{ .observation_date = Date.fromYmd(2020, 1, 1), .projected_date = Date.fromYmd(2032, 1, 1), .years_until_retirement = 12.0, .reached = false },
|
|
.{ .observation_date = Date.fromYmd(2022, 1, 1), .projected_date = Date.fromYmd(2031, 1, 1), .years_until_retirement = 9.0, .reached = false },
|
|
.{ .observation_date = Date.fromYmd(2025, 1, 1), .projected_date = Date.fromYmd(2030, 1, 1), .years_until_retirement = 5.0, .reached = false },
|
|
};
|
|
|
|
var tmp = std.testing.tmpDir(.{});
|
|
defer tmp.cleanup();
|
|
var path_buf: [std.fs.max_path_bytes]u8 = undefined;
|
|
const dir_len = try tmp.dir.realPathFile(io, ".", &path_buf);
|
|
const dir_path = path_buf[0..dir_len];
|
|
const path = try std.fs.path.join(alloc, &.{ dir_path, "test_export_convergence.png" });
|
|
defer alloc.free(path);
|
|
|
|
try exportConvergenceChart(io, alloc, &points, theme.default_theme, path);
|
|
|
|
var file = try tmp.dir.openFile(io, "test_export_convergence.png", .{});
|
|
defer file.close(io);
|
|
const size = (try file.stat(io)).size;
|
|
try std.testing.expect(size > 1024);
|
|
|
|
var magic: [8]u8 = undefined;
|
|
var reader = file.reader(io, &.{});
|
|
_ = try reader.interface.readSliceShort(&magic);
|
|
try std.testing.expectEqualSlices(u8, "\x89PNG\x0D\x0A\x1A\x0A", &magic);
|
|
}
|
|
|
|
test "exportConvergenceChart returns InsufficientData with a single point" {
|
|
const Date = @import("Date.zig");
|
|
const alloc = std.testing.allocator;
|
|
const io = std.testing.io;
|
|
|
|
const points = [_]forecast.ConvergencePoint{
|
|
.{ .observation_date = Date.fromYmd(2020, 1, 1), .projected_date = Date.fromYmd(2030, 1, 1), .years_until_retirement = 10.0, .reached = false },
|
|
};
|
|
|
|
var tmp = std.testing.tmpDir(.{});
|
|
defer tmp.cleanup();
|
|
var path_buf: [std.fs.max_path_bytes]u8 = undefined;
|
|
const dir_len = try tmp.dir.realPathFile(io, ".", &path_buf);
|
|
const dir_path = path_buf[0..dir_len];
|
|
const path = try std.fs.path.join(alloc, &.{ dir_path, "test_export_convergence_insufficient.png" });
|
|
defer alloc.free(path);
|
|
|
|
try std.testing.expectError(
|
|
error.InsufficientData,
|
|
exportConvergenceChart(io, alloc, &points, theme.default_theme, path),
|
|
);
|
|
}
|
|
|
|
test "exportBacktestChart writes a non-empty PNG file" {
|
|
const Date = @import("Date.zig");
|
|
const alloc = std.testing.allocator;
|
|
const io = std.testing.io;
|
|
|
|
const anchors = [_]forecast.BacktestAnchor{
|
|
.{ .anchor_date = Date.fromYmd(2016, 1, 1), .expected = 0.07, .realized_1y = 0.12, .realized_3y = 0.09, .realized_5y = 0.08 },
|
|
.{ .anchor_date = Date.fromYmd(2019, 1, 1), .expected = 0.08, .realized_1y = 0.18, .realized_3y = 0.10, .realized_5y = null },
|
|
.{ .anchor_date = Date.fromYmd(2022, 1, 1), .expected = 0.06, .realized_1y = -0.05, .realized_3y = null, .realized_5y = null },
|
|
};
|
|
|
|
var tmp = std.testing.tmpDir(.{});
|
|
defer tmp.cleanup();
|
|
var path_buf: [std.fs.max_path_bytes]u8 = undefined;
|
|
const dir_len = try tmp.dir.realPathFile(io, ".", &path_buf);
|
|
const dir_path = path_buf[0..dir_len];
|
|
const path = try std.fs.path.join(alloc, &.{ dir_path, "test_export_backtest.png" });
|
|
defer alloc.free(path);
|
|
|
|
try exportBacktestChart(io, alloc, &anchors, theme.default_theme, path);
|
|
|
|
var file = try tmp.dir.openFile(io, "test_export_backtest.png", .{});
|
|
defer file.close(io);
|
|
const size = (try file.stat(io)).size;
|
|
try std.testing.expect(size > 1024);
|
|
|
|
var magic: [8]u8 = undefined;
|
|
var reader = file.reader(io, &.{});
|
|
_ = try reader.interface.readSliceShort(&magic);
|
|
try std.testing.expectEqualSlices(u8, "\x89PNG\x0D\x0A\x1A\x0A", &magic);
|
|
}
|
|
|
|
test "exportBacktestChart returns InsufficientData with a single anchor" {
|
|
const Date = @import("Date.zig");
|
|
const alloc = std.testing.allocator;
|
|
const io = std.testing.io;
|
|
|
|
const anchors = [_]forecast.BacktestAnchor{
|
|
.{ .anchor_date = Date.fromYmd(2020, 1, 1), .expected = 0.10, .realized_1y = null, .realized_3y = null, .realized_5y = null },
|
|
};
|
|
|
|
var tmp = std.testing.tmpDir(.{});
|
|
defer tmp.cleanup();
|
|
var path_buf: [std.fs.max_path_bytes]u8 = undefined;
|
|
const dir_len = try tmp.dir.realPathFile(io, ".", &path_buf);
|
|
const dir_path = path_buf[0..dir_len];
|
|
const path = try std.fs.path.join(alloc, &.{ dir_path, "test_export_backtest_insufficient.png" });
|
|
defer alloc.free(path);
|
|
|
|
try std.testing.expectError(
|
|
error.InsufficientData,
|
|
exportBacktestChart(io, alloc, &anchors, theme.default_theme, path),
|
|
);
|
|
}
|
|
|
|
test "exportCompareChart writes a non-empty PNG file" {
|
|
const alloc = std.testing.allocator;
|
|
const io = std.testing.io;
|
|
|
|
var then_bands: [11]projections.YearPercentiles = undefined;
|
|
var now_bands: [11]projections.YearPercentiles = undefined;
|
|
for (0..11) |i| {
|
|
const t: f64 = 1_000_000.0 * (1.0 + 0.05 * @as(f64, @floatFromInt(i)));
|
|
const n: f64 = 1_200_000.0 * (1.0 + 0.06 * @as(f64, @floatFromInt(i)));
|
|
then_bands[i] = .{ .year = @intCast(i), .p10 = t * 0.6, .p25 = t * 0.8, .p50 = t, .p75 = t * 1.2, .p90 = t * 1.5 };
|
|
now_bands[i] = .{ .year = @intCast(i), .p10 = n * 0.6, .p25 = n * 0.8, .p50 = n, .p75 = n * 1.2, .p90 = n * 1.5 };
|
|
}
|
|
|
|
var tmp = std.testing.tmpDir(.{});
|
|
defer tmp.cleanup();
|
|
var path_buf: [std.fs.max_path_bytes]u8 = undefined;
|
|
const dir_len = try tmp.dir.realPathFile(io, ".", &path_buf);
|
|
const dir_path = path_buf[0..dir_len];
|
|
const path = try std.fs.path.join(alloc, &.{ dir_path, "test_export_compare.png" });
|
|
defer alloc.free(path);
|
|
|
|
try exportCompareChart(io, alloc, &then_bands, &now_bands, theme.default_theme, path);
|
|
|
|
var file = try tmp.dir.openFile(io, "test_export_compare.png", .{});
|
|
defer file.close(io);
|
|
const size = (try file.stat(io)).size;
|
|
try std.testing.expect(size > 1024);
|
|
|
|
var magic: [8]u8 = undefined;
|
|
var reader = file.reader(io, &.{});
|
|
_ = try reader.interface.readSliceShort(&magic);
|
|
try std.testing.expectEqualSlices(u8, "\x89PNG\x0D\x0A\x1A\x0A", &magic);
|
|
}
|
|
|
|
test "exportCompareChart returns InsufficientData with a single-year side" {
|
|
const alloc = std.testing.allocator;
|
|
const io = std.testing.io;
|
|
|
|
const then_bands = [_]projections.YearPercentiles{.{ .year = 0, .p10 = 1, .p25 = 2, .p50 = 3, .p75 = 4, .p90 = 5 }};
|
|
var now_bands: [3]projections.YearPercentiles = undefined;
|
|
for (0..3) |i| now_bands[i] = .{ .year = @intCast(i), .p10 = 1, .p25 = 2, .p50 = 3, .p75 = 4, .p90 = 5 };
|
|
|
|
var tmp = std.testing.tmpDir(.{});
|
|
defer tmp.cleanup();
|
|
var path_buf: [std.fs.max_path_bytes]u8 = undefined;
|
|
const dir_len = try tmp.dir.realPathFile(io, ".", &path_buf);
|
|
const dir_path = path_buf[0..dir_len];
|
|
const path = try std.fs.path.join(alloc, &.{ dir_path, "test_export_compare_insufficient.png" });
|
|
defer alloc.free(path);
|
|
|
|
try std.testing.expectError(
|
|
error.InsufficientData,
|
|
exportCompareChart(io, alloc, &then_bands, &now_bands, theme.default_theme, path),
|
|
);
|
|
}
|