From 8dc01e81aeb967bac5f9c76d9b81af0dbb1e74b6 Mon Sep 17 00:00:00 2001 From: Emil Lerch Date: Fri, 15 May 2026 15:18:46 -0700 Subject: [PATCH] add more tests in tui tabs --- src/tui.zig | 6 - src/tui/earnings_tab.zig | 56 ++++++++- src/tui/options_tab.zig | 243 +++++++++++++++++++++++++++++++++--- src/tui/performance_tab.zig | 122 +++++++++++++++++- src/tui/portfolio_tab.zig | 57 +++++++++ src/tui/quote_tab.zig | 61 ++++++++- 6 files changed, 513 insertions(+), 32 deletions(-) diff --git a/src/tui.zig b/src/tui.zig index 8e595f1..f655566 100644 --- a/src/tui.zig +++ b/src/tui.zig @@ -6,12 +6,6 @@ const views = @import("views/portfolio_sections.zig"); const cli = @import("commands/common.zig"); const keybinds = @import("tui/keybinds.zig"); const tab_framework = @import("tui/tab_framework.zig"); -// Touch tab_framework so its tests are reachable via the import -// graph. The framework is otherwise unused at this point in the -// migration; will be properly wired in step 3. -comptime { - _ = tab_framework; -} const theme = @import("tui/theme.zig"); const chart = @import("tui/chart.zig"); diff --git a/src/tui/earnings_tab.zig b/src/tui/earnings_tab.zig index 7308972..9b7db77 100644 --- a/src/tui/earnings_tab.zig +++ b/src/tui/earnings_tab.zig @@ -183,6 +183,26 @@ pub fn buildStyledLines(app: *App, arena: std.mem.Allocator) ![]const StyledLine return renderEarningsLines(arena, app.theme, app.symbol, state.disabled, state.data, state.timestamp, state.error_msg, now_s); } +/// Format the "earnings not available" message shown for ETFs and +/// indexes (which don't report earnings). +pub fn formatEarningsDisabled(arena: std.mem.Allocator, symbol: []const u8) ![]const u8 { + return std.fmt.allocPrint(arena, " Earnings not available for {s} (ETF/index)", .{symbol}); +} + +/// Format the earnings tab's header line. When `data_ago` is +/// non-null, includes a "(data Xs ago)" suffix; otherwise renders +/// just the title and symbol. +pub fn formatEarningsHeader( + arena: std.mem.Allocator, + symbol: []const u8, + data_ago: ?[]const u8, +) ![]const u8 { + return if (data_ago) |ago| + std.fmt.allocPrint(arena, " Earnings: {s} (data {s})", .{ symbol, ago }) + else + std.fmt.allocPrint(arena, " Earnings: {s}", .{symbol}); +} + /// Render earnings tab content. Pure function — no App dependency. /// /// `now_s` is the unix-epoch-seconds reference point for the @@ -207,17 +227,17 @@ pub fn renderEarningsLines( return lines.toOwnedSlice(arena); } if (earnings_disabled) { - try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Earnings not available for {s} (ETF/index)", .{symbol}), .style = th.mutedStyle() }); + try lines.append(arena, .{ .text = try formatEarningsDisabled(arena, symbol), .style = th.mutedStyle() }); return lines.toOwnedSlice(arena); } var earn_ago_buf: [16]u8 = undefined; const earn_ago = fmt.fmtTimeAgo(&earn_ago_buf, earnings_timestamp, now_s); - if (earn_ago.len > 0) { - try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Earnings: {s} (data {s})", .{ symbol, earn_ago }), .style = th.headerStyle() }); - } else { - try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Earnings: {s}", .{symbol}), .style = th.headerStyle() }); - } + const header_text = if (earn_ago.len > 0) + try formatEarningsHeader(arena, symbol, earn_ago) + else + try formatEarningsHeader(arena, symbol, null); + try lines.append(arena, .{ .text = header_text, .style = th.headerStyle() }); try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); const ev = earnings_data orelse { @@ -343,3 +363,27 @@ test "tab.init / deinit are idempotent" { tab.deinit(&state, &dummy_app); try testing.expect(state.data == null); } + +test "formatEarningsDisabled: includes symbol" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + const text = try formatEarningsDisabled(arena, "VTI"); + try testing.expectEqualStrings(" Earnings not available for VTI (ETF/index)", text); +} + +test "formatEarningsHeader: with data-ago suffix" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + const text = try formatEarningsHeader(arena, "AAPL", "2h ago"); + try testing.expectEqualStrings(" Earnings: AAPL (data 2h ago)", text); +} + +test "formatEarningsHeader: without data-ago suffix" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + const text = try formatEarningsHeader(arena, "MSFT", null); + try testing.expectEqualStrings(" Earnings: MSFT", text); +} diff --git a/src/tui/options_tab.zig b/src/tui/options_tab.zig index 079370a..5629bd2 100644 --- a/src/tui/options_tab.zig +++ b/src/tui/options_tab.zig @@ -153,11 +153,11 @@ pub const tab = struct { switch (action) { .collapse_all_calls => toggleAllCallsPuts(state, app, true), .collapse_all_puts => toggleAllCallsPuts(state, app, false), - .expand_collapse => toggleExpandAtCursor(state, app), + .expand_collapse => toggleExpandAtCursor(state, app.allocator), .filter_1, .filter_2, .filter_3, .filter_4, .filter_5, .filter_6, .filter_7, .filter_8, .filter_9 => { const n = @intFromEnum(action) - @intFromEnum(Action.filter_1) + 1; state.near_the_money = n; - rebuildRows(state, app); + rebuildRows(state, app.allocator); var status_buf: [32]u8 = undefined; const msg = std.fmt.bufPrint(&status_buf, "+/- {d} NTM strikes", .{n}) catch "Filter changed"; app.setStatus(msg); @@ -186,7 +186,7 @@ pub const tab = struct { if (orow.kind == .puts_header) current_line += 1; // extra blank if (current_line == target_line) { state.cursor = oi; - toggleExpandAtCursor(state, app); + toggleExpandAtCursor(state, app.allocator); return true; } current_line += 1; @@ -289,7 +289,7 @@ fn loadData(state: *State, app: *App) void { state.expanded = @splat(false); state.calls_collapsed = @splat(false); state.puts_collapsed = @splat(false); - rebuildRows(state, app); + rebuildRows(state, app.allocator); app.setStatus(if (result.source == .cached) "Cached (1hr TTL) | r/F5 to refresh" else "Fetched | r/F5 to refresh"); } @@ -308,7 +308,7 @@ fn loadData(state: *State, app: *App) void { // After any change, rebuilds the flat row list to reflect the new // layout. No-op if the cursor is out of range or rows are empty. -fn toggleExpandAtCursor(state: *State, app: *App) void { +fn toggleExpandAtCursor(state: *State, allocator: std.mem.Allocator) void { if (state.rows.items.len == 0) return; if (state.cursor >= state.rows.items.len) return; const row = state.rows.items[state.cursor]; @@ -316,19 +316,19 @@ fn toggleExpandAtCursor(state: *State, app: *App) void { .expiration => { if (row.exp_idx < state.expanded.len) { state.expanded[row.exp_idx] = !state.expanded[row.exp_idx]; - rebuildRows(state, app); + rebuildRows(state, allocator); } }, .calls_header => { if (row.exp_idx < state.calls_collapsed.len) { state.calls_collapsed[row.exp_idx] = !state.calls_collapsed[row.exp_idx]; - rebuildRows(state, app); + rebuildRows(state, allocator); } }, .puts_header => { if (row.exp_idx < state.puts_collapsed.len) { state.puts_collapsed[row.exp_idx] = !state.puts_collapsed[row.exp_idx]; - rebuildRows(state, app); + rebuildRows(state, allocator); } }, // Clicking on a contract does nothing (yet). @@ -338,20 +338,20 @@ fn toggleExpandAtCursor(state: *State, app: *App) void { // ── Row rebuilding (after expansion/collapse changes) ──────── -pub fn rebuildRows(state: *State, app: *App) void { +pub fn rebuildRows(state: *State, allocator: std.mem.Allocator) void { state.rows.clearRetainingCapacity(); const chains = state.chains orelse return; const atm_price = if (chains.len > 0) chains[0].underlying_price orelse 0 else @as(f64, 0); for (chains, 0..) |chain, ci| { - state.rows.append(app.allocator, .{ + state.rows.append(allocator, .{ .kind = .expiration, .exp_idx = ci, }) catch continue; if (ci < state.expanded.len and state.expanded[ci]) { // Calls header (always shown when expanded, acts as toggle) - state.rows.append(app.allocator, .{ + state.rows.append(allocator, .{ .kind = .calls_header, .exp_idx = ci, }) catch continue; @@ -360,7 +360,7 @@ pub fn rebuildRows(state: *State, app: *App) void { if (!(ci < state.calls_collapsed.len and state.calls_collapsed[ci])) { const filtered_calls = fmt.filterNearMoney(chain.calls, atm_price, state.near_the_money); for (filtered_calls) |cc| { - state.rows.append(app.allocator, .{ + state.rows.append(allocator, .{ .kind = .call, .exp_idx = ci, .contract = cc, @@ -369,7 +369,7 @@ pub fn rebuildRows(state: *State, app: *App) void { } // Puts header - state.rows.append(app.allocator, .{ + state.rows.append(allocator, .{ .kind = .puts_header, .exp_idx = ci, }) catch continue; @@ -378,7 +378,7 @@ pub fn rebuildRows(state: *State, app: *App) void { if (!(ci < state.puts_collapsed.len and state.puts_collapsed[ci])) { const filtered_puts = fmt.filterNearMoney(chain.puts, atm_price, state.near_the_money); for (filtered_puts) |p| { - state.rows.append(app.allocator, .{ + state.rows.append(allocator, .{ .kind = .put, .exp_idx = ci, .contract = p, @@ -420,7 +420,7 @@ fn toggleAllCallsPuts(state: *State, app: *App, is_calls: bool) void { state.puts_collapsed[ci] = new_state; } } - rebuildRows(state, app); + rebuildRows(state, app.allocator); if (is_calls) { app.setStatus(if (new_state) "All calls collapsed" else "All calls expanded"); } else { @@ -584,3 +584,216 @@ test "formatUnderlyingHeader: respects rebound keys" { const text = try formatUnderlyingHeader(arena, 100.0, 3, 1, "F1", "F9"); try testing.expect(std.mem.indexOf(u8, text, "(F1..F9 to change)") != null); } + +fn makeContract(strike: f64, kind: @import("../models/option.zig").ContractType) zfin.OptionContract { + return .{ + .contract_type = kind, + .strike = strike, + .expiration = zfin.Date.fromYmd(2024, 12, 20), + }; +} + +test "rebuildRows: empty chains produces empty rows" { + var state: State = .{}; + rebuildRows(&state, testing.allocator); + defer state.rows.deinit(testing.allocator); + try testing.expectEqual(@as(usize, 0), state.rows.items.len); +} + +test "rebuildRows: collapsed expirations emit only the expiration rows" { + const calls = [_]zfin.OptionContract{ + makeContract(100, .call), + makeContract(110, .call), + }; + const puts = [_]zfin.OptionContract{makeContract(95, .put)}; + var chain_arr = [_]zfin.OptionsChain{ + .{ + .underlying_symbol = "AAPL", + .underlying_price = 105, + .expiration = zfin.Date.fromYmd(2024, 12, 20), + .calls = &calls, + .puts = &puts, + }, + .{ + .underlying_symbol = "AAPL", + .underlying_price = 105, + .expiration = zfin.Date.fromYmd(2025, 1, 17), + .calls = &calls, + .puts = &puts, + }, + }; + var state: State = .{ .chains = &chain_arr }; + rebuildRows(&state, testing.allocator); + defer state.rows.deinit(testing.allocator); + // Two collapsed expirations = two rows. + try testing.expectEqual(@as(usize, 2), state.rows.items.len); + try testing.expectEqual(tui.OptionsRowKind.expiration, state.rows.items[0].kind); + try testing.expectEqual(tui.OptionsRowKind.expiration, state.rows.items[1].kind); +} + +test "rebuildRows: expanded expiration emits headers + filtered contracts" { + // 5 calls / 5 puts spread around strike 100 (ATM at 100). + const calls = [_]zfin.OptionContract{ + makeContract(90, .call), + makeContract(95, .call), + makeContract(100, .call), + makeContract(105, .call), + makeContract(110, .call), + }; + const puts = [_]zfin.OptionContract{ + makeContract(90, .put), + makeContract(95, .put), + makeContract(100, .put), + makeContract(105, .put), + makeContract(110, .put), + }; + var chain_arr = [_]zfin.OptionsChain{.{ + .underlying_symbol = "AAPL", + .underlying_price = 100, + .expiration = zfin.Date.fromYmd(2024, 12, 20), + .calls = &calls, + .puts = &puts, + }}; + var state: State = .{ + .chains = &chain_arr, + .near_the_money = 2, // +/- 2 strikes from ATM + }; + state.expanded[0] = true; // expand first expiration + + rebuildRows(&state, testing.allocator); + defer state.rows.deinit(testing.allocator); + + // Expected rows: expiration + calls_header + N calls + puts_header + N puts. + // filterNearMoney with near_the_money=2 picks ATM ± 2 strikes; + // exactly how many varies by impl, but headers are always present. + var found_calls_header = false; + var found_puts_header = false; + for (state.rows.items) |r| { + if (r.kind == .calls_header) found_calls_header = true; + if (r.kind == .puts_header) found_puts_header = true; + } + try testing.expect(found_calls_header); + try testing.expect(found_puts_header); + try testing.expect(state.rows.items.len > 3); // at least exp + 2 headers + 1 contract +} + +test "rebuildRows: calls_collapsed hides call contracts but keeps puts" { + const calls = [_]zfin.OptionContract{ + makeContract(100, .call), + }; + const puts = [_]zfin.OptionContract{ + makeContract(100, .put), + }; + var chain_arr = [_]zfin.OptionsChain{.{ + .underlying_symbol = "AAPL", + .underlying_price = 100, + .expiration = zfin.Date.fromYmd(2024, 12, 20), + .calls = &calls, + .puts = &puts, + }}; + var state: State = .{ + .chains = &chain_arr, + .near_the_money = 5, + }; + state.expanded[0] = true; + state.calls_collapsed[0] = true; // hide calls + + rebuildRows(&state, testing.allocator); + defer state.rows.deinit(testing.allocator); + + var call_count: usize = 0; + var put_count: usize = 0; + for (state.rows.items) |r| { + if (r.kind == .call) call_count += 1; + if (r.kind == .put) put_count += 1; + } + try testing.expectEqual(@as(usize, 0), call_count); + try testing.expect(put_count > 0); +} + +test "toggleExpandAtCursor: cursor on expiration row toggles expanded flag" { + const calls = [_]zfin.OptionContract{makeContract(100, .call)}; + const puts = [_]zfin.OptionContract{makeContract(100, .put)}; + var chain_arr = [_]zfin.OptionsChain{.{ + .underlying_symbol = "AAPL", + .underlying_price = 100, + .expiration = zfin.Date.fromYmd(2024, 12, 20), + .calls = &calls, + .puts = &puts, + }}; + var state: State = .{ + .chains = &chain_arr, + .near_the_money = 5, + }; + rebuildRows(&state, testing.allocator); + defer state.rows.deinit(testing.allocator); + + // Cursor starts on the expiration row (only row in collapsed state). + try testing.expect(!state.expanded[0]); + toggleExpandAtCursor(&state, testing.allocator); + try testing.expect(state.expanded[0]); + toggleExpandAtCursor(&state, testing.allocator); // cursor now on... still row 0 = expiration row + try testing.expect(!state.expanded[0]); +} + +test "toggleExpandAtCursor: empty rows is a no-op" { + var state: State = .{}; + toggleExpandAtCursor(&state, testing.allocator); + // No crash, no change. + try testing.expectEqual(@as(usize, 0), state.rows.items.len); +} + +test "stepCursor: positive direction advances within bounds" { + var cursor: usize = 0; + stepCursor(&cursor, 5, 1); + try testing.expectEqual(@as(usize, 1), cursor); + stepCursor(&cursor, 5, 3); + try testing.expectEqual(@as(usize, 2), cursor); +} + +test "stepCursor: positive direction clamped at row_count - 1" { + var cursor: usize = 4; + stepCursor(&cursor, 5, 1); + try testing.expectEqual(@as(usize, 4), cursor); +} + +test "stepCursor: negative direction decrements" { + var cursor: usize = 3; + stepCursor(&cursor, 5, -1); + try testing.expectEqual(@as(usize, 2), cursor); +} + +test "stepCursor: negative direction clamped at 0" { + var cursor: usize = 0; + stepCursor(&cursor, 5, -1); + try testing.expectEqual(@as(usize, 0), cursor); +} + +test "stepCursor: zero rows is a no-op" { + var cursor: usize = 0; + stepCursor(&cursor, 0, 1); + try testing.expectEqual(@as(usize, 0), cursor); +} + +test "ensureCursorVisible: cursor below viewport scrolls down" { + var state: State = .{ .cursor = 50, .header_lines = 2 }; + var scroll: usize = 0; + ensureCursorVisible(&state, &scroll, 10); + // cursor_row = 52, vis = 10, 52 >= 0+10 → scroll = 52 - 10 + 1 = 43. + try testing.expectEqual(@as(usize, 43), scroll); +} + +test "ensureCursorVisible: cursor above viewport scrolls up" { + var state: State = .{ .cursor = 5, .header_lines = 2 }; + var scroll: usize = 30; + ensureCursorVisible(&state, &scroll, 10); + // cursor_row = 7, scroll = 30 → 7 < 30 → scroll = 7. + try testing.expectEqual(@as(usize, 7), scroll); +} + +test "ensureCursorVisible: cursor inside viewport leaves scroll alone" { + var state: State = .{ .cursor = 3, .header_lines = 2 }; + var scroll: usize = 0; + ensureCursorVisible(&state, &scroll, 20); + try testing.expectEqual(@as(usize, 0), scroll); +} diff --git a/src/tui/performance_tab.zig b/src/tui/performance_tab.zig index 89a90ad..c605f42 100644 --- a/src/tui/performance_tab.zig +++ b/src/tui/performance_tab.zig @@ -151,6 +151,20 @@ fn loadData(state: *State, app: *App) void { // ── Rendering ───────────────────────────────────────────────── +/// Format the performance tab's header line. When `as_of_close` +/// is non-null, includes the close-of-day date suffix; otherwise +/// renders just the title and symbol. +pub fn formatPerformanceHeader( + arena: std.mem.Allocator, + symbol: []const u8, + as_of_close: ?zfin.Date, +) ![]const u8 { + return if (as_of_close) |d| + std.fmt.allocPrint(arena, " Trailing Returns: {s} (as of close on {f})", .{ symbol, d }) + else + std.fmt.allocPrint(arena, " Trailing Returns: {s}", .{symbol}); +} + pub fn buildStyledLines(app: *App, arena: std.mem.Allocator) ![]const StyledLine { const th = app.theme; var lines: std.ArrayList(StyledLine) = .empty; @@ -163,9 +177,9 @@ pub fn buildStyledLines(app: *App, arena: std.mem.Allocator) ![]const StyledLine } if (app.symbol_data.candleLastDate()) |d| { - try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Trailing Returns: {s} (as of close on {f})", .{ app.symbol, d }), .style = th.headerStyle() }); + try lines.append(arena, .{ .text = try formatPerformanceHeader(arena, app.symbol, d), .style = th.headerStyle() }); } else { - try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Trailing Returns: {s}", .{app.symbol}), .style = th.headerStyle() }); + try lines.append(arena, .{ .text = try formatPerformanceHeader(arena, app.symbol, null), .style = th.headerStyle() }); } try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); @@ -285,3 +299,107 @@ fn appendStyledReturnsTable( } } } + +// ── Tests ───────────────────────────────────────────────────── + +const testing = std.testing; +const Date = zfin.Date; + +fn makeResult(ret: f64, ann: ?f64) zfin.performance.PerformanceResult { + return .{ + .total_return = ret, + .annualized_return = ann, + .from = Date.fromYmd(2020, 1, 1), + .to = Date.fromYmd(2024, 1, 1), + }; +} + +test "appendStyledReturnsTable: price-only column shape" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + const price: zfin.performance.TrailingReturns = .{ + .one_year = makeResult(0.10, null), + .three_year = makeResult(0.30, 0.0914), + .five_year = makeResult(0.50, 0.0845), + .ten_year = null, + }; + + var lines: std.ArrayList(StyledLine) = .empty; + try appendStyledReturnsTable(arena, &lines, price, null, theme.default_theme); + + // Header + 4 rows = 5 lines. + try testing.expectEqual(@as(usize, 5), lines.items.len); + + // Header has "Price Only" but no "Total Return". + try testing.expect(std.mem.indexOf(u8, lines.items[0].text, "Price Only") != null); + try testing.expect(std.mem.indexOf(u8, lines.items[0].text, "Total Return") == null); + + // Each row labels its period. + try testing.expect(std.mem.indexOf(u8, lines.items[1].text, "1-Year Return:") != null); + try testing.expect(std.mem.indexOf(u8, lines.items[2].text, "3-Year Return:") != null); + try testing.expect(std.mem.indexOf(u8, lines.items[3].text, "5-Year Return:") != null); + try testing.expect(std.mem.indexOf(u8, lines.items[4].text, "10-Year Return:") != null); +} + +test "appendStyledReturnsTable: with total returns shows both columns" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + const price: zfin.performance.TrailingReturns = .{ + .one_year = makeResult(0.10, null), + .three_year = null, + .five_year = null, + .ten_year = null, + }; + const total: zfin.performance.TrailingReturns = .{ + .one_year = makeResult(0.12, null), + .three_year = null, + .five_year = null, + .ten_year = null, + }; + + var lines: std.ArrayList(StyledLine) = .empty; + try appendStyledReturnsTable(arena, &lines, price, total, theme.default_theme); + + try testing.expectEqual(@as(usize, 5), lines.items.len); + + // Header has both columns. + try testing.expect(std.mem.indexOf(u8, lines.items[0].text, "Price Only") != null); + try testing.expect(std.mem.indexOf(u8, lines.items[0].text, "Total Return") != null); +} + +test "appendStyledReturnsTable: missing data renders N/A" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + const empty: zfin.performance.TrailingReturns = .{}; + + var lines: std.ArrayList(StyledLine) = .empty; + try appendStyledReturnsTable(arena, &lines, empty, null, theme.default_theme); + + try testing.expectEqual(@as(usize, 5), lines.items.len); + // Each row should contain N/A or em-dash (depends on fmt impl). + // At minimum the rows should render without crashing and each + // includes its label. + try testing.expect(std.mem.indexOf(u8, lines.items[1].text, "1-Year Return:") != null); +} + +test "formatPerformanceHeader: with as-of date" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + const text = try formatPerformanceHeader(arena, "AAPL", Date.fromYmd(2024, 3, 15)); + try testing.expectEqualStrings(" Trailing Returns: AAPL (as of close on 2024-03-15)", text); +} + +test "formatPerformanceHeader: without as-of date" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + const text = try formatPerformanceHeader(arena, "VTI", null); + try testing.expectEqualStrings(" Trailing Returns: VTI", text); +} diff --git a/src/tui/portfolio_tab.zig b/src/tui/portfolio_tab.zig index 313c045..0ac0093 100644 --- a/src/tui/portfolio_tab.zig +++ b/src/tui/portfolio_tab.zig @@ -337,6 +337,10 @@ pub const tab = struct { } }; +/// Adjust `scroll_offset` so the cursor row (`state.cursor + +/// state.header_lines`) is visible within `visible_height`. Pure +/// over (state, scroll_offset, visible_height); mutates +/// `scroll_offset` only. fn ensureCursorVisible(state: *const State, scroll_offset: *usize, visible_height: usize) void { const cursor_row = state.cursor + state.header_lines; if (cursor_row < scroll_offset.*) { @@ -1017,6 +1021,9 @@ fn recomputeFilteredPositions(state: *State, app: *App) void { /// Check if a lot matches the active account filter. /// Returns true if no filter is active or the lot's account matches. +/// Returns true if `account` matches the active account filter. +/// When no filter is active, returns true (all accounts pass). +/// When an account is null but a filter is active, returns false. fn matchesAccountFilter(state: *const State, account: ?[]const u8) bool { const filter = state.account_filter orelse return true; const acct = account orelse return false; @@ -1949,3 +1956,53 @@ test "buildWelcomeScreenLines: respects rebound keys" { try testing.expect(std.mem.indexOf(u8, text, "h / l") == null); try testing.expect(std.mem.indexOf(u8, text, "j / k") == null); } + +test "matchesAccountFilter: no filter = pass-through" { + const state: State = .{}; + try testing.expect(matchesAccountFilter(&state, "Brokerage")); + try testing.expect(matchesAccountFilter(&state, null)); +} + +test "matchesAccountFilter: with filter, only matching account passes" { + const state: State = .{ .account_filter = "Brokerage" }; + try testing.expect(matchesAccountFilter(&state, "Brokerage")); + try testing.expect(!matchesAccountFilter(&state, "IRA")); +} + +test "matchesAccountFilter: with filter, null account fails" { + const state: State = .{ .account_filter = "Brokerage" }; + try testing.expect(!matchesAccountFilter(&state, null)); +} + +test "ensureCursorVisible: cursor above viewport scrolls up" { + var state: State = .{ .cursor = 5, .header_lines = 2 }; + var scroll: usize = 20; + ensureCursorVisible(&state, &scroll, 10); + // cursor_row = 5 + 2 = 7, which is < 20, so scroll_offset = 7. + try testing.expectEqual(@as(usize, 7), scroll); +} + +test "ensureCursorVisible: cursor below viewport scrolls down" { + var state: State = .{ .cursor = 50, .header_lines = 2 }; + var scroll: usize = 0; + ensureCursorVisible(&state, &scroll, 10); + // cursor_row = 52, scroll_offset = 0, vis = 10, 52 >= 0+10 + // → scroll = 52 - 10 + 1 = 43. + try testing.expectEqual(@as(usize, 43), scroll); +} + +test "ensureCursorVisible: cursor inside viewport leaves scroll alone" { + var state: State = .{ .cursor = 5, .header_lines = 2 }; + var scroll: usize = 0; + ensureCursorVisible(&state, &scroll, 20); + // cursor_row = 7, in [0, 20), no change. + try testing.expectEqual(@as(usize, 0), scroll); +} + +test "ensureCursorVisible: zero visible height is a no-op for the lower bound" { + var state: State = .{ .cursor = 0, .header_lines = 0 }; + var scroll: usize = 5; + ensureCursorVisible(&state, &scroll, 0); + // cursor_row = 0 < 5 → scroll = 0. Lower-bound branch fires. + try testing.expectEqual(@as(usize, 0), scroll); +} diff --git a/src/tui/quote_tab.zig b/src/tui/quote_tab.zig index 58d0042..34a0484 100644 --- a/src/tui/quote_tab.zig +++ b/src/tui/quote_tab.zig @@ -510,6 +510,32 @@ fn drawWithKittyChart(app: *App, ctx: vaxis.vxfw.DrawContext, buf: []vaxis.Cell, } } +/// Source-of-data hint shown in the quote tab's header line. +/// Determines which sub-form of the header is rendered. +pub const QuoteHeaderSource = union(enum) { + /// Live quote with a "refreshed Xs ago" suffix. + live: []const u8, + /// Close-of-day data with a date. + close: zfin.Date, + /// No timing info — just the symbol. + none, +}; + +/// Format the quote tab's header line. Pure function over +/// (arena, symbol, source). The three branches mirror the live / +/// close-of-day / no-data paths in the live builder. +pub fn formatQuoteHeader( + arena: std.mem.Allocator, + symbol: []const u8, + source: QuoteHeaderSource, +) ![]const u8 { + return switch (source) { + .live => |ago| std.fmt.allocPrint(arena, " {s} (live, ~15 min delay, refreshed {s})", .{ symbol, ago }), + .close => |date| std.fmt.allocPrint(arena, " {s} (as of close on {f})", .{ symbol, date }), + .none => std.fmt.allocPrint(arena, " {s}", .{symbol}), + }; +} + fn buildStyledLines(app: *App, arena: std.mem.Allocator) ![]const StyledLine { const th = app.theme; var lines: std.ArrayList(StyledLine) = .empty; @@ -526,11 +552,11 @@ fn buildStyledLines(app: *App, arena: std.mem.Allocator) ![]const StyledLine { // wall-clock required: per-frame "now" for the data-age readout. const now_s = std.Io.Timestamp.now(app.io, .real).toSeconds(); const ago_str = fmt.fmtTimeAgo(&ago_buf, app.states.quote.timestamp, now_s); - try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " {s} (live, ~15 min delay, refreshed {s})", .{ app.symbol, ago_str }), .style = th.headerStyle() }); + try lines.append(arena, .{ .text = try formatQuoteHeader(arena, app.symbol, .{ .live = ago_str }), .style = th.headerStyle() }); } else if (app.symbol_data.candleLastDate()) |d| { - try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " {s} (as of close on {f})", .{ app.symbol, d }), .style = th.headerStyle() }); + try lines.append(arena, .{ .text = try formatQuoteHeader(arena, app.symbol, .{ .close = d }), .style = th.headerStyle() }); } else { - try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " {s}", .{app.symbol}), .style = th.headerStyle() }); + try lines.append(arena, .{ .text = try formatQuoteHeader(arena, app.symbol, .none), .style = th.headerStyle() }); } try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); @@ -758,3 +784,32 @@ fn buildDetailColumns( }); } } + +// ── Tests ───────────────────────────────────────────────────── + +const testing = std.testing; +const Date = zfin.Date; + +test "formatQuoteHeader: live source includes refreshed-ago string" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + const text = try formatQuoteHeader(arena, "AAPL", .{ .live = "5s ago" }); + try testing.expectEqualStrings(" AAPL (live, ~15 min delay, refreshed 5s ago)", text); +} + +test "formatQuoteHeader: close source includes ISO date" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + const text = try formatQuoteHeader(arena, "VTI", .{ .close = Date.fromYmd(2024, 3, 15) }); + try testing.expectEqualStrings(" VTI (as of close on 2024-03-15)", text); +} + +test "formatQuoteHeader: none source renders just the symbol" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + const text = try formatQuoteHeader(arena, "BRK.B", .none); + try testing.expectEqualStrings(" BRK.B", text); +}