diff --git a/src/format.zig b/src/format.zig index 974ea2a..a1644b6 100644 --- a/src/format.zig +++ b/src/format.zig @@ -755,6 +755,14 @@ pub fn brailleGlyph(pattern: u8) []const u8 { return &braille_utf8[pattern]; } +/// Maximum byte length for a `Money.from(v).{f}` rendering used as a +/// chart axis label. Sized to fit `$999,999,999,999.99` (19 chars, +/// up to a trillion-plus) with slack. Renderers that pre-allocate +/// buffer cells for these labels should use this constant rather +/// than hard-coding a smaller width and silently truncating +/// portfolios over $1M. +pub const money_label_max_bytes: usize = 24; + /// Computed braille chart data, ready for rendering by CLI (ANSI) or TUI (vaxis). pub const BrailleChart = struct { /// Braille pattern bytes: patterns[row * n_cols + col] @@ -763,9 +771,14 @@ pub const BrailleChart = struct { col_colors: [][3]u8, n_cols: usize, chart_height: usize, - max_label: [16]u8, + /// Money labels formatted via `Money.from(v).{f}`. Sized to fit + /// up to `$999,999,999,999.99` (19 chars) with slack so we don't + /// silently drop the label when portfolios cross into ten figures. + /// Renderers that need to budget cells for the label should use + /// `money_label_max_bytes` rather than guessing. + max_label: [money_label_max_bytes]u8, max_label_len: usize, - min_label: [16]u8, + min_label: [money_label_max_bytes]u8, min_label_len: usize, /// Date of first candle in the chart data start_date: Date, @@ -1360,6 +1373,52 @@ test "computeBrailleChart insufficient data" { try std.testing.expectError(error.InsufficientData, result); } +test "computeBrailleChart preserves full label for prices over $1M" { + // Regression test for a bug where the max/min label buffers were + // sized at 16 bytes - too small for `Money.from(v).{f}` of values + // with 13+ chars (anything $1,000,000+). Result was a silently + // empty label string in the BrailleChart, then the TUI renderer + // truncated even further to 10 cells, dropping the label entirely + // for portfolios over $1M. See `money_label_max_bytes`. + const alloc = std.testing.allocator; + var candles: [20]Candle = undefined; + for (0..20) |i| { + // Arbitrary placeholder range starting at $1,234,567.89 with + // a $500,000 step. The point of this test is the rendered + // shape - 13+ char `$X,XXX,XXX.XX` strings that would have + // overflowed the old 16-byte label buffer or the renderer's + // 10-cell budget. The exact numbers are irrelevant. + const price: f64 = 1_234_567.89 + @as(f64, @floatFromInt(i)) * 500_000.0; + candles[i] = .{ + .date = Date.fromYmd(2024, 1, 2).addDays(@intCast(i)), + .open = price, + .high = price, + .low = price, + .close = price, + .adj_close = price, + .volume = 1000, + }; + } + var chart = try computeBrailleChart(alloc, &candles, 20, 4, .{ 0x7f, 0xd8, 0x8f }, .{ 0xe0, 0x6c, 0x75 }); + defer chart.deinit(alloc); + + // Both labels must contain the dollar sign and a comma (the + // thousands separator) - that confirms `Money.from` produced + // a multi-million-dollar string and didn't fall through to the + // empty-string fallback when bufPrint hit NoSpaceLeft. + const max_lbl = chart.maxLabel(); + const min_lbl = chart.minLabel(); + try std.testing.expect(std.mem.indexOfScalar(u8, max_lbl, '$') != null); + try std.testing.expect(std.mem.indexOfScalar(u8, max_lbl, ',') != null); + try std.testing.expect(std.mem.indexOfScalar(u8, min_lbl, '$') != null); + try std.testing.expect(std.mem.indexOfScalar(u8, min_lbl, ',') != null); + // Both labels should match the `$X,XXX,XXX.XX` shape (at + // least 13 chars). Any silent-truncation bug would leave them + // empty or much shorter. + try std.testing.expect(max_lbl.len >= 13); + try std.testing.expect(min_lbl.len >= 13); +} + test "computeBrailleChart uses adj_close to avoid split cliff" { // Regression: SOXX 3:1 on 2024-03-07 used to render a sharp drop // because the chart consumed raw `close` instead of `adj_close`. diff --git a/src/tui.zig b/src/tui.zig index f5b7fd5..409e3d0 100644 --- a/src/tui.zig +++ b/src/tui.zig @@ -1905,10 +1905,17 @@ pub fn renderBrailleToStyledLines(arena: std.mem.Allocator, lines: *std.ArrayLis var br = fmt.computeBrailleChart(arena, data, 60, 10, th.positive, th.negative) catch return; // No deinit needed: arena handles cleanup + // Cell budget per row: 2 leading spaces + n_cols chart cells + + // 1 separator space + up to `money_label_max_bytes` for the + // price label. Sizing this too small silently truncates labels + // for portfolios over $1M; see `fmt.money_label_max_bytes`. + const label_cells: usize = 1 + fmt.money_label_max_bytes; + const row_cells: usize = 2 + br.n_cols + label_cells; + const bg = th.bg; for (0..br.chart_height) |row| { - const graphemes = try arena.alloc([]const u8, br.n_cols + 12); // chart + padding + label - const styles = try arena.alloc(vaxis.Style, br.n_cols + 12); + const graphemes = try arena.alloc([]const u8, row_cells); + const styles = try arena.alloc(vaxis.Style, row_cells); var gpos: usize = 0; // 2 leading spaces @@ -1968,8 +1975,8 @@ pub fn renderBrailleToStyledLines(arena: std.mem.Allocator, lines: *std.ArrayLis const end_label = br.fmtAxisDate(br.end_date, &end_buf); const muted_style = vaxis.Style{ .fg = theme.Theme.vcolor(th.text_muted), .bg = theme.Theme.vcolor(bg) }; - const date_graphemes = try arena.alloc([]const u8, br.n_cols + 12); - const date_styles = try arena.alloc(vaxis.Style, br.n_cols + 12); + const date_graphemes = try arena.alloc([]const u8, row_cells); + const date_styles = try arena.alloc(vaxis.Style, row_cells); var dpos: usize = 0; // 2 leading spaces @@ -2615,3 +2622,62 @@ test "writeDefaultKeys: tab sections appear in tab_modules declaration order" { prev_pos = pos; } } + +test "renderBrailleToStyledLines: full price label renders for portfolios over $1M" { + // Regression test for a TUI rendering bug where the per-row + // grapheme buffer was sized at `n_cols + 12`, leaving only 10 + // cells for the right-side price label (after 2 leading spaces + // and `n_cols` chart cells). For 13+ char money strings like + // `$X,XXX,XXX.XX` (14 chars including the leading separator + // space), the label got silently truncated to the 10 cell + // budget. Buffer is now sized via `fmt.money_label_max_bytes`. + + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const a = arena.allocator(); + + // Synthetic candles in an arbitrary multi-million-dollar range. + // Specific numbers don't matter; what matters is that the + // rendered `Money.from(v).{f}` strings are wide enough to + // exercise the label buffer (13+ chars). + var candles: [20]zfin.Candle = undefined; + for (0..20) |i| { + const price: f64 = 1_234_567.89 + @as(f64, @floatFromInt(i)) * 500_000.0; + candles[i] = .{ + .date = zfin.Date.fromYmd(2024, 1, 2).addDays(@intCast(i)), + .open = price, + .high = price, + .low = price, + .close = price, + .adj_close = price, + .volume = 1000, + }; + } + + var lines: std.ArrayList(StyledLine) = .empty; + try renderBrailleToStyledLines(a, &lines, &candles, theme.default_theme); + + // Grab the first row (which carries the max label) and the + // last chart row (which carries the min label). Walk the + // grapheme cells looking for the `$` and the closing digit + // pattern of the money string. + try testing.expect(lines.items.len >= 2); + const first_row = lines.items[0]; + const graphemes = first_row.graphemes orelse return error.MissingGraphemes; + + // Reconstruct the rendered string and assert the full money + // label is present (dollar sign, comma separator, two-digit + // cents). A truncated label would lack the trailing digits. + var rendered: std.ArrayList(u8) = .empty; + defer rendered.deinit(a); + for (graphemes) |g| try rendered.appendSlice(a, g); + + // The max value in the candle set is the last index's price: + // 1_234_567.89 + 19 * 500_000.0 = 10_734_567.89. + // `Money.from(10_734_567.89).{f}` produces `$10,734,567.89`. + // Confirm the label as a whole substring is present (no + // truncation). + try testing.expect(std.mem.indexOf(u8, rendered.items, "$") != null); + try testing.expect(std.mem.indexOf(u8, rendered.items, ".89") != null); + try testing.expect(std.mem.indexOf(u8, rendered.items, ",") != null); +} diff --git a/src/tui/projections_tab.zig b/src/tui/projections_tab.zig index f88dfb2..d5ffd4a 100644 --- a/src/tui/projections_tab.zig +++ b/src/tui/projections_tab.zig @@ -1919,9 +1919,19 @@ fn buildLines(state: *State, app: *App, arena: std.mem.Allocator) ![]const Style const muted_fg = theme.Theme.vcolor(th.text_muted); const bg_v = theme.Theme.vcolor(bg); + // Cell budget per row: 2 leading spaces + n_cols + // chart cells + 1 separator space + up to + // `money_label_max_bytes` for the price label. + // Sized via a named constant so the projection + // chart doesn't silently truncate labels for + // portfolios that grow past $1M; see + // `fmt.money_label_max_bytes`. + const proj_label_cells: usize = 1 + fmt.money_label_max_bytes; + const proj_row_cells: usize = 2 + br_chart.n_cols + proj_label_cells; + for (0..br_chart.chart_height) |row| { - const graphemes = try arena.alloc([]const u8, br_chart.n_cols + 20); - const styles = try arena.alloc(vaxis.Style, br_chart.n_cols + 20); + const graphemes = try arena.alloc([]const u8, proj_row_cells); + const styles = try arena.alloc(vaxis.Style, proj_row_cells); var gpos: usize = 0; // 2 leading spaces @@ -1967,8 +1977,8 @@ fn buildLines(state: *State, app: *App, arena: std.mem.Allocator) ![]const Style // Year axis: "Now" on left, "{horizon}yr" on right { - const axis_graphemes = try arena.alloc([]const u8, br_chart.n_cols + 20); - const axis_styles = try arena.alloc(vaxis.Style, br_chart.n_cols + 20); + const axis_graphemes = try arena.alloc([]const u8, proj_row_cells); + const axis_styles = try arena.alloc(vaxis.Style, proj_row_cells); const muted_style = vaxis.Style{ .fg = muted_fg, .bg = bg_v }; var apos: usize = 0;