dedupe/centralize and add history view
This commit is contained in:
parent
5e6d2dd438
commit
8b010eb592
3 changed files with 372 additions and 9 deletions
|
|
@ -36,7 +36,7 @@ const atomic = @import("../atomic.zig");
|
||||||
const timeline = @import("../analytics/timeline.zig");
|
const timeline = @import("../analytics/timeline.zig");
|
||||||
const history_io = @import("../history.zig");
|
const history_io = @import("../history.zig");
|
||||||
const snapshot_model = @import("../models/snapshot.zig");
|
const snapshot_model = @import("../models/snapshot.zig");
|
||||||
const view = @import("../view/history.zig");
|
const view = @import("../views/history.zig");
|
||||||
const fmt = cli.fmt;
|
const fmt = cli.fmt;
|
||||||
const Date = @import("../models/date.zig").Date;
|
const Date = @import("../models/date.zig").Date;
|
||||||
|
|
||||||
|
|
@ -340,11 +340,16 @@ fn renderWindowsBlock(out: *std.Io.Writer, color: bool, ws: timeline.WindowSet)
|
||||||
var pbuf: [16]u8 = undefined;
|
var pbuf: [16]u8 = undefined;
|
||||||
const cells = view.buildWindowRowCells(row, &dbuf, &pbuf);
|
const cells = view.buildWindowRowCells(row, &dbuf, &pbuf);
|
||||||
|
|
||||||
// Whole row colored by sign. `missing`/`zero` use muted so the
|
// Whole row colored by style intent. `muted` covers both
|
||||||
// n/a and $0.00 rows don't visually shout green or red.
|
// zero and missing-anchor rows — neither deserves a
|
||||||
switch (cells.sign) {
|
// green/red shout.
|
||||||
.positive, .negative => try cli.setGainLoss(out, color, if (cells.sign == .positive) 1.0 else -1.0),
|
switch (cells.style) {
|
||||||
.zero, .missing => try cli.setFg(out, color, cli.CLR_MUTED),
|
.positive => try cli.setFg(out, color, cli.CLR_POSITIVE),
|
||||||
|
.negative => try cli.setFg(out, color, cli.CLR_NEGATIVE),
|
||||||
|
.muted => try cli.setFg(out, color, cli.CLR_MUTED),
|
||||||
|
// `normal` is unreachable in the windows block (build
|
||||||
|
// never emits it); no-op keeps the switch exhaustive.
|
||||||
|
.normal => {},
|
||||||
}
|
}
|
||||||
|
|
||||||
try out.print(" {s:<12} {s:>18} {s:>10}", .{
|
try out.print(" {s:<12} {s:>18} {s:>10}", .{
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@ const theme = @import("theme.zig");
|
||||||
const tui = @import("../tui.zig");
|
const tui = @import("../tui.zig");
|
||||||
const history_io = @import("../history.zig");
|
const history_io = @import("../history.zig");
|
||||||
const timeline = @import("../analytics/timeline.zig");
|
const timeline = @import("../analytics/timeline.zig");
|
||||||
const view = @import("../view/history.zig");
|
const view = @import("../views/history.zig");
|
||||||
const App = tui.App;
|
const App = tui.App;
|
||||||
const StyledLine = tui.StyledLine;
|
const StyledLine = tui.StyledLine;
|
||||||
|
|
||||||
|
|
@ -246,10 +246,14 @@ fn appendWindowsBlock(
|
||||||
" {s:<12} {s:>18} {s:>10}",
|
" {s:<12} {s:>18} {s:>10}",
|
||||||
.{ cells.label, cells.delta_str, cells.pct_str },
|
.{ cells.label, cells.delta_str, cells.pct_str },
|
||||||
);
|
);
|
||||||
const style: vaxis.Cell.Style = switch (cells.sign) {
|
const style: vaxis.Cell.Style = switch (cells.style) {
|
||||||
.positive => th.positiveStyle(),
|
.positive => th.positiveStyle(),
|
||||||
.negative => th.negativeStyle(),
|
.negative => th.negativeStyle(),
|
||||||
.zero, .missing => th.mutedStyle(),
|
.muted => th.mutedStyle(),
|
||||||
|
// `normal` is unreachable in the windows block (build
|
||||||
|
// never emits it); fall back to content style to keep
|
||||||
|
// the switch exhaustive.
|
||||||
|
.normal => th.contentStyle(),
|
||||||
};
|
};
|
||||||
try lines.append(arena, .{ .text = text, .style = style });
|
try lines.append(arena, .{ .text = text, .style = style });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
354
src/views/history.zig
Normal file
354
src/views/history.zig
Normal file
|
|
@ -0,0 +1,354 @@
|
||||||
|
//! `src/views/history.zig` — view models for the portfolio history UX.
|
||||||
|
//!
|
||||||
|
//! Sits alongside `views/portfolio_sections.zig` in the views layer,
|
||||||
|
//! which owns renderer-agnostic display data consumed by both CLI and
|
||||||
|
//! TUI. Column widths, format strings, computed values, and style
|
||||||
|
//! decisions are defined here; renderers are thin adapters that map
|
||||||
|
//! `StyleIntent` to platform-specific styles and emit pre-formatted
|
||||||
|
//! text.
|
||||||
|
//!
|
||||||
|
//! Contract: no IO, no writer, no ANSI, no `vaxis.Cell.Style`. Functions
|
||||||
|
//! consume `timeline` types and format primitives from `src/format.zig`,
|
||||||
|
//! and return formatted strings or structured data.
|
||||||
|
//!
|
||||||
|
//! Why this layer exists: the CLI and TUI render the same sections
|
||||||
|
//! (windows block, recent-snapshots table) from the same data. Before
|
||||||
|
//! this module, both reimplemented the formatting in parallel and
|
||||||
|
//! drifted (column widths didn't match, labels differed, row styling
|
||||||
|
//! diverged). Centralizing the row-cell layout here guarantees parity.
|
||||||
|
|
||||||
|
const std = @import("std");
|
||||||
|
const timeline = @import("../analytics/timeline.zig");
|
||||||
|
const fmt = @import("../format.zig");
|
||||||
|
const StyleIntent = fmt.StyleIntent;
|
||||||
|
|
||||||
|
// ── Column widths (shared by CLI + TUI) ──────────────────────
|
||||||
|
|
||||||
|
/// Width of the leftmost label column in the windows block.
|
||||||
|
/// Fits "10 years" + padding. `{s:<12}` (left-aligned).
|
||||||
|
pub const windows_label_width: usize = 12;
|
||||||
|
|
||||||
|
/// Width of the Δ (signed money) column. Fits worst-case like
|
||||||
|
/// `"+$99,999,999.99"` (15 chars) with slack. Right-aligned.
|
||||||
|
pub const windows_delta_width: usize = 18;
|
||||||
|
|
||||||
|
/// Width of the % column. Fits `"+999.99%"` with slack. Right-aligned.
|
||||||
|
pub const windows_pct_width: usize = 10;
|
||||||
|
|
||||||
|
/// Width of the date column in the recent-snapshots table.
|
||||||
|
/// ISO date `YYYY-MM-DD` is exactly 10 chars.
|
||||||
|
pub const table_date_width: usize = 10;
|
||||||
|
|
||||||
|
/// Width of each `"$value (±Δ)"` composite cell in the table. Fits
|
||||||
|
/// worst-case eight-figure totals with deltas. Right-aligned (padded
|
||||||
|
/// to this width by `fmtValueDeltaCell`).
|
||||||
|
pub const table_cell_width: usize = 28;
|
||||||
|
|
||||||
|
// ── Windows block row ────────────────────────────────────────
|
||||||
|
|
||||||
|
/// One row's worth of pre-formatted cells for the windows block.
|
||||||
|
/// Strings reference caller-owned buffers passed into
|
||||||
|
/// `buildWindowRowCells`; do not outlive them.
|
||||||
|
///
|
||||||
|
/// `style` is a semantic intent — the renderer maps it to the
|
||||||
|
/// platform's actual color. Zero-delta and missing-anchor rows both
|
||||||
|
/// resolve to `.muted` because neither deserves a green/red shout in
|
||||||
|
/// practice; no caller today needs to distinguish them.
|
||||||
|
pub const WindowRowCells = struct {
|
||||||
|
label: []const u8,
|
||||||
|
delta_str: []const u8,
|
||||||
|
pct_str: []const u8,
|
||||||
|
style: StyleIntent,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Render a WindowStat into displayable cells. `delta_buf` and
|
||||||
|
/// `pct_buf` are caller-owned stack buffers the returned strings
|
||||||
|
/// borrow from — they must outlive the returned struct.
|
||||||
|
///
|
||||||
|
/// Missing anchors (null `delta_abs`) produce `"n/a"` in both string
|
||||||
|
/// columns. A zero delta produces `"$0.00"` / `"0.00%"` and `.muted`
|
||||||
|
/// style. Positive/negative deltas map to `.positive` / `.negative`.
|
||||||
|
///
|
||||||
|
/// The pct column never shows a trailing-whitespace oddity: sign and
|
||||||
|
/// digits sit flush (`"+0.41%"`, not `"+ 0.41%"`). Column alignment
|
||||||
|
/// is the caller's job via `windows_pct_width`.
|
||||||
|
pub fn buildWindowRowCells(
|
||||||
|
row: timeline.WindowStat,
|
||||||
|
delta_buf: *[32]u8,
|
||||||
|
pct_buf: *[16]u8,
|
||||||
|
) WindowRowCells {
|
||||||
|
const style: StyleIntent = blk: {
|
||||||
|
const d = row.delta_abs orelse break :blk .muted;
|
||||||
|
if (d > 0) break :blk .positive;
|
||||||
|
if (d < 0) break :blk .negative;
|
||||||
|
break :blk .muted; // zero delta → muted (same as missing)
|
||||||
|
};
|
||||||
|
|
||||||
|
const delta_str: []const u8 = if (row.delta_abs) |d|
|
||||||
|
fmtSignedMoneyBuf(delta_buf, d)
|
||||||
|
else
|
||||||
|
"n/a";
|
||||||
|
|
||||||
|
const pct_str: []const u8 = if (row.delta_pct) |p|
|
||||||
|
fmtSignedPercentBuf(pct_buf, p)
|
||||||
|
else
|
||||||
|
// delta_abs missing OR start_value was 0 (division by zero).
|
||||||
|
// Both read as "pct undefined" for the user.
|
||||||
|
"n/a";
|
||||||
|
|
||||||
|
return .{
|
||||||
|
.label = row.label,
|
||||||
|
.delta_str = delta_str,
|
||||||
|
.pct_str = pct_str,
|
||||||
|
.style = style,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Format a signed dollar amount: `"+$1,234.56"`, `"-$1,234.56"`,
|
||||||
|
/// `"$0.00"`. Returns a slice of `buf`.
|
||||||
|
///
|
||||||
|
/// Separate from `fmt.fmtMoneyAbs` (which omits the sign) because the
|
||||||
|
/// windows block's Δ column needs the leading sign to distinguish
|
||||||
|
/// gains from losses at a glance.
|
||||||
|
pub fn fmtSignedMoneyBuf(buf: *[32]u8, value: f64) []const u8 {
|
||||||
|
const prefix: []const u8 = if (value > 0) "+" else if (value < 0) "-" else "";
|
||||||
|
var tmp: [24]u8 = undefined;
|
||||||
|
const abs_str = fmt.fmtMoneyAbs(&tmp, value);
|
||||||
|
return std.fmt.bufPrint(buf, "{s}{s}", .{ prefix, abs_str }) catch "?";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Format a signed percentage: `"+0.41%"`, `"-1.07%"`, `"0.00%"`.
|
||||||
|
/// Input is a ratio (0.0041 → "+0.41%"). Returns a slice of `buf`.
|
||||||
|
///
|
||||||
|
/// Sign sits flush against the digit — no internal whitespace
|
||||||
|
/// padding. Right-alignment in the column is the caller's job.
|
||||||
|
pub fn fmtSignedPercentBuf(buf: *[16]u8, ratio: f64) []const u8 {
|
||||||
|
const pct = ratio * 100.0;
|
||||||
|
const prefix: []const u8 = if (pct > 0) "+" else if (pct < 0) "-" else "";
|
||||||
|
return std.fmt.bufPrint(buf, "{s}{d:.2}%", .{ prefix, @abs(pct) }) catch "?";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Recent-snapshots table cell ──────────────────────────────
|
||||||
|
|
||||||
|
/// Format a composite `"$value (±Δ)"` cell for the recent-snapshots
|
||||||
|
/// table, right-padded (i.e. left-space-padded) to `width` characters.
|
||||||
|
/// Writes into `buf`; returns a slice covering the full width.
|
||||||
|
///
|
||||||
|
/// `delta_opt = null` (first row) renders as `"$value (—)"` — the
|
||||||
|
/// em-dash signals "no prior row to compare against" without wasting
|
||||||
|
/// a Δ column.
|
||||||
|
///
|
||||||
|
/// Overflow behavior: if the natural cell is wider than `width`, the
|
||||||
|
/// raw cell is returned un-truncated. Callers that need strict width
|
||||||
|
/// can measure the returned slice and decide.
|
||||||
|
pub fn fmtValueDeltaCell(
|
||||||
|
buf: []u8,
|
||||||
|
value: f64,
|
||||||
|
delta_opt: ?f64,
|
||||||
|
width: usize,
|
||||||
|
) []const u8 {
|
||||||
|
var val_buf: [24]u8 = undefined;
|
||||||
|
var delta_buf: [32]u8 = undefined;
|
||||||
|
const val_str = fmt.fmtMoneyAbs(&val_buf, value);
|
||||||
|
const d_str: []const u8 = if (delta_opt) |d|
|
||||||
|
fmtSignedMoneyBuf(&delta_buf, d)
|
||||||
|
else
|
||||||
|
"—";
|
||||||
|
|
||||||
|
// Build the natural cell into a scratch buffer first so we know
|
||||||
|
// its length; then left-pad into `buf`.
|
||||||
|
var natural_buf: [96]u8 = undefined;
|
||||||
|
const natural = std.fmt.bufPrint(&natural_buf, "{s} ({s})", .{ val_str, d_str }) catch return "?";
|
||||||
|
|
||||||
|
if (natural.len >= width) {
|
||||||
|
// No room to pad; just copy as much as fits.
|
||||||
|
const n = @min(natural.len, buf.len);
|
||||||
|
@memcpy(buf[0..n], natural[0..n]);
|
||||||
|
return buf[0..n];
|
||||||
|
}
|
||||||
|
|
||||||
|
const pad = width - natural.len;
|
||||||
|
if (pad + natural.len > buf.len) {
|
||||||
|
// Buf too small to hold the padded cell; return the natural
|
||||||
|
// slice so callers at least see the content.
|
||||||
|
const n = @min(natural.len, buf.len);
|
||||||
|
@memcpy(buf[0..n], natural[0..n]);
|
||||||
|
return buf[0..n];
|
||||||
|
}
|
||||||
|
@memset(buf[0..pad], ' ');
|
||||||
|
@memcpy(buf[pad .. pad + natural.len], natural);
|
||||||
|
return buf[0 .. pad + natural.len];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Resolution label ─────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Format the resolution label shown in the recent-snapshots title:
|
||||||
|
/// `"(auto - daily)"` when `override` is null (auto; resolved to
|
||||||
|
/// `effective`)
|
||||||
|
/// `"(daily)"` / `"(weekly)"` / `"(monthly)"` when the user has
|
||||||
|
/// explicitly picked a resolution.
|
||||||
|
///
|
||||||
|
/// Returns a slice of `buf`.
|
||||||
|
pub fn fmtResolutionLabel(
|
||||||
|
buf: []u8,
|
||||||
|
override: ?timeline.Resolution,
|
||||||
|
effective: timeline.Resolution,
|
||||||
|
) []const u8 {
|
||||||
|
return if (override == null)
|
||||||
|
std.fmt.bufPrint(buf, "(auto - {s})", .{effective.label()}) catch "?"
|
||||||
|
else
|
||||||
|
std.fmt.bufPrint(buf, "({s})", .{effective.label()}) catch "?";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tests ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const testing = std.testing;
|
||||||
|
const Date = @import("../models/date.zig").Date;
|
||||||
|
|
||||||
|
fn makeWindowStat(
|
||||||
|
period: ?@import("../analytics/valuation.zig").HistoricalPeriod,
|
||||||
|
label: []const u8,
|
||||||
|
start_value: ?f64,
|
||||||
|
end_value: f64,
|
||||||
|
) timeline.WindowStat {
|
||||||
|
const delta_abs: ?f64 = if (start_value) |s| end_value - s else null;
|
||||||
|
const delta_pct: ?f64 = blk: {
|
||||||
|
const s = start_value orelse break :blk null;
|
||||||
|
if (s == 0) break :blk null;
|
||||||
|
break :blk (end_value - s) / s;
|
||||||
|
};
|
||||||
|
return .{
|
||||||
|
.period = period,
|
||||||
|
.label = label,
|
||||||
|
.short_label = label,
|
||||||
|
.anchor_date = if (start_value != null) Date.fromYmd(2026, 4, 17) else null,
|
||||||
|
.start_value = start_value,
|
||||||
|
.end_value = end_value,
|
||||||
|
.delta_abs = delta_abs,
|
||||||
|
.delta_pct = delta_pct,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── buildWindowRowCells ──
|
||||||
|
|
||||||
|
test "buildWindowRowCells: positive delta" {
|
||||||
|
var db: [32]u8 = undefined;
|
||||||
|
var pb: [16]u8 = undefined;
|
||||||
|
const row = makeWindowStat(null, "1 day", 1000, 1100);
|
||||||
|
const cells = buildWindowRowCells(row, &db, &pb);
|
||||||
|
try testing.expectEqualStrings("1 day", cells.label);
|
||||||
|
try testing.expectEqualStrings("+$100.00", cells.delta_str);
|
||||||
|
try testing.expectEqualStrings("+10.00%", cells.pct_str);
|
||||||
|
try testing.expectEqual(StyleIntent.positive, cells.style);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "buildWindowRowCells: negative delta" {
|
||||||
|
var db: [32]u8 = undefined;
|
||||||
|
var pb: [16]u8 = undefined;
|
||||||
|
const row = makeWindowStat(null, "1 day", 1000, 900);
|
||||||
|
const cells = buildWindowRowCells(row, &db, &pb);
|
||||||
|
try testing.expectEqualStrings("-$100.00", cells.delta_str);
|
||||||
|
try testing.expectEqualStrings("-10.00%", cells.pct_str);
|
||||||
|
try testing.expectEqual(StyleIntent.negative, cells.style);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "buildWindowRowCells: zero delta renders muted" {
|
||||||
|
var db: [32]u8 = undefined;
|
||||||
|
var pb: [16]u8 = undefined;
|
||||||
|
const row = makeWindowStat(null, "1 day", 1000, 1000);
|
||||||
|
const cells = buildWindowRowCells(row, &db, &pb);
|
||||||
|
try testing.expectEqualStrings("$0.00", cells.delta_str);
|
||||||
|
try testing.expectEqualStrings("0.00%", cells.pct_str);
|
||||||
|
try testing.expectEqual(StyleIntent.muted, cells.style);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "buildWindowRowCells: missing anchor renders muted with n/a strings" {
|
||||||
|
var db: [32]u8 = undefined;
|
||||||
|
var pb: [16]u8 = undefined;
|
||||||
|
const row = makeWindowStat(null, "1 year", null, 1100);
|
||||||
|
const cells = buildWindowRowCells(row, &db, &pb);
|
||||||
|
try testing.expectEqualStrings("n/a", cells.delta_str);
|
||||||
|
try testing.expectEqualStrings("n/a", cells.pct_str);
|
||||||
|
try testing.expectEqual(StyleIntent.muted, cells.style);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "buildWindowRowCells: zero start_value → pct n/a, delta present" {
|
||||||
|
var db: [32]u8 = undefined;
|
||||||
|
var pb: [16]u8 = undefined;
|
||||||
|
const row = makeWindowStat(null, "All-time", 0, 100);
|
||||||
|
const cells = buildWindowRowCells(row, &db, &pb);
|
||||||
|
// Delta still present (100 - 0 = 100), pct is undefined so renders n/a.
|
||||||
|
try testing.expectEqualStrings("+$100.00", cells.delta_str);
|
||||||
|
try testing.expectEqualStrings("n/a", cells.pct_str);
|
||||||
|
try testing.expectEqual(StyleIntent.positive, cells.style);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── fmtSignedMoneyBuf ──
|
||||||
|
|
||||||
|
test "fmtSignedMoneyBuf: signs + zero + thousands" {
|
||||||
|
var buf: [32]u8 = undefined;
|
||||||
|
try testing.expectEqualStrings("+$1,234.56", fmtSignedMoneyBuf(&buf, 1234.56));
|
||||||
|
try testing.expectEqualStrings("-$1,234.56", fmtSignedMoneyBuf(&buf, -1234.56));
|
||||||
|
try testing.expectEqualStrings("$0.00", fmtSignedMoneyBuf(&buf, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── fmtSignedPercentBuf ──
|
||||||
|
|
||||||
|
test "fmtSignedPercentBuf: signs + zero + flush digits" {
|
||||||
|
var buf: [16]u8 = undefined;
|
||||||
|
try testing.expectEqualStrings("+0.41%", fmtSignedPercentBuf(&buf, 0.0041));
|
||||||
|
try testing.expectEqualStrings("-1.07%", fmtSignedPercentBuf(&buf, -0.0107));
|
||||||
|
try testing.expectEqualStrings("0.00%", fmtSignedPercentBuf(&buf, 0));
|
||||||
|
// Pct larger than 100% still renders cleanly.
|
||||||
|
try testing.expectEqualStrings("+123.45%", fmtSignedPercentBuf(&buf, 1.2345));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── fmtValueDeltaCell ──
|
||||||
|
|
||||||
|
test "fmtValueDeltaCell: padded to width, positive delta" {
|
||||||
|
var buf: [64]u8 = undefined;
|
||||||
|
const s = fmtValueDeltaCell(&buf, 1000, 50, 28);
|
||||||
|
try testing.expectEqual(@as(usize, 28), s.len);
|
||||||
|
// Content ends with the raw cell; leading spaces pad.
|
||||||
|
try testing.expect(std.mem.endsWith(u8, s, "$1,000.00 (+$50.00)"));
|
||||||
|
// Starts with at least one space (padding present).
|
||||||
|
try testing.expectEqual(@as(u8, ' '), s[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "fmtValueDeltaCell: null delta renders em-dash" {
|
||||||
|
var buf: [64]u8 = undefined;
|
||||||
|
const s = fmtValueDeltaCell(&buf, 1000, null, 28);
|
||||||
|
try testing.expectEqual(@as(usize, 28), s.len);
|
||||||
|
try testing.expect(std.mem.endsWith(u8, s, "$1,000.00 (—)"));
|
||||||
|
}
|
||||||
|
|
||||||
|
test "fmtValueDeltaCell: negative delta" {
|
||||||
|
var buf: [64]u8 = undefined;
|
||||||
|
const s = fmtValueDeltaCell(&buf, 1000, -50, 28);
|
||||||
|
try testing.expect(std.mem.endsWith(u8, s, "$1,000.00 (-$50.00)"));
|
||||||
|
}
|
||||||
|
|
||||||
|
test "fmtValueDeltaCell: overflow returns un-truncated natural cell" {
|
||||||
|
// 10-char width is too small for "$1,000.00 (+$50.00)" (19 chars).
|
||||||
|
var buf: [64]u8 = undefined;
|
||||||
|
const s = fmtValueDeltaCell(&buf, 1000, 50, 10);
|
||||||
|
// No padding applied (natural len >= width); full content returned.
|
||||||
|
try testing.expect(s.len >= 19);
|
||||||
|
try testing.expect(std.mem.startsWith(u8, s, "$1,000.00"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── fmtResolutionLabel ──
|
||||||
|
|
||||||
|
test "fmtResolutionLabel: auto reveals the effective resolution" {
|
||||||
|
var buf: [32]u8 = undefined;
|
||||||
|
try testing.expectEqualStrings("(auto - daily)", fmtResolutionLabel(&buf, null, .daily));
|
||||||
|
try testing.expectEqualStrings("(auto - weekly)", fmtResolutionLabel(&buf, null, .weekly));
|
||||||
|
try testing.expectEqualStrings("(auto - monthly)", fmtResolutionLabel(&buf, null, .monthly));
|
||||||
|
}
|
||||||
|
|
||||||
|
test "fmtResolutionLabel: explicit override hides the 'auto' prefix" {
|
||||||
|
var buf: [32]u8 = undefined;
|
||||||
|
try testing.expectEqualStrings("(daily)", fmtResolutionLabel(&buf, .daily, .daily));
|
||||||
|
try testing.expectEqualStrings("(weekly)", fmtResolutionLabel(&buf, .weekly, .weekly));
|
||||||
|
try testing.expectEqualStrings("(monthly)", fmtResolutionLabel(&buf, .monthly, .monthly));
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue