add more tests in tui tabs

This commit is contained in:
Emil Lerch 2026-05-15 15:18:46 -07:00
parent cdf6b9d6e1
commit 8dc01e81ae
Signed by: lobo
GPG key ID: A7B62D657EF764F8
6 changed files with 513 additions and 32 deletions

View file

@ -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");

View file

@ -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);
}

View file

@ -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);
}

View file

@ -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);
}

View file

@ -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);
}

View file

@ -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);
}