bump size of chart numbers on history tui tab
This commit is contained in:
parent
7f204747ad
commit
79ffbeb078
3 changed files with 145 additions and 10 deletions
|
|
@ -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`.
|
||||
|
|
|
|||
74
src/tui.zig
74
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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue