add name to quote command/tab
This commit is contained in:
parent
2ad9a0fb40
commit
7e6102cc5f
4 changed files with 289 additions and 29 deletions
|
|
@ -150,23 +150,96 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
|
|||
};
|
||||
} else |_| {}
|
||||
|
||||
try display(ctx.allocator, candles, quote, parsed.symbol, ctx.today, ctx.color, ctx.out);
|
||||
// Resolve a human-readable security name the same way the TUI
|
||||
// 'K' overlay and quote tab do: the curated `metadata.srf`
|
||||
// `name::` field first, then the ETF profile's fund name. The
|
||||
// name is copied into `name_buf` so its lifetime is independent
|
||||
// of the (transient) classification map / ETF fetch result.
|
||||
var name_buf: [256]u8 = undefined;
|
||||
var name: ?[]const u8 = null;
|
||||
{
|
||||
var cm_opt = loadClassificationMap(ctx);
|
||||
defer if (cm_opt) |*cm| cm.deinit();
|
||||
const cm_ptr: ?*const zfin.classification.ClassificationMap = if (cm_opt) |*cm| cm else null;
|
||||
if (zfin.classification.resolveSecurityName(parsed.symbol, cm_ptr, null)) |nm| {
|
||||
name = clampName(&name_buf, nm);
|
||||
}
|
||||
}
|
||||
if (name == null) {
|
||||
// Fallback: the ETF profile's fund name, gated on isEtf() so
|
||||
// it matches the TUI (which only retains fund-shaped
|
||||
// profiles in symbol_data). Plain stocks not in metadata.srf
|
||||
// therefore render symbol-only, exactly like the 'K' overlay.
|
||||
// Only fetched when metadata yielded nothing, so already-named
|
||||
// holdings don't pay for an EDGAR round-trip.
|
||||
if (svc.getEtfProfile(parsed.symbol, opts)) |etf_result| {
|
||||
defer etf_result.deinit();
|
||||
if (etf_result.data.isEtf()) {
|
||||
if (etf_result.data.name) |nm| name = clampName(&name_buf, nm);
|
||||
}
|
||||
} else |_| {}
|
||||
}
|
||||
|
||||
try display(ctx.allocator, candles, quote, parsed.symbol, name, ctx.today, ctx.color, ctx.out);
|
||||
}
|
||||
|
||||
pub fn display(allocator: std.mem.Allocator, candles: []const zfin.Candle, quote: ?QuoteData, symbol: []const u8, as_of: zfin.Date, color: bool, out: *std.Io.Writer) !void {
|
||||
/// Copy `s` (clamped to `buf`'s capacity) into `buf` and return the
|
||||
/// written slice. Fund/security names fit easily in 256 bytes.
|
||||
fn clampName(buf: []u8, s: []const u8) []const u8 {
|
||||
const n = @min(s.len, buf.len);
|
||||
@memcpy(buf[0..n], s[0..n]);
|
||||
return buf[0..n];
|
||||
}
|
||||
|
||||
/// Quietly load the `metadata.srf` classification map that sits
|
||||
/// beside the resolved portfolio anchor. Best-effort: returns null
|
||||
/// (printing nothing) when there's no portfolio, no `metadata.srf`,
|
||||
/// or it fails to parse. Unlike `cli.loadPortfolio` this never emits
|
||||
/// "no portfolio" noise — `quote` works fine without one, and the
|
||||
/// map is only used to enrich the header with a name. Caller owns
|
||||
/// the returned map and must `deinit()` it.
|
||||
fn loadClassificationMap(ctx: *framework.RunCtx) ?zfin.classification.ClassificationMap {
|
||||
var resolved = framework.resolvePatterns(
|
||||
ctx.io,
|
||||
ctx.allocator,
|
||||
ctx.config,
|
||||
ctx.globals.portfolio_patterns,
|
||||
) catch return null;
|
||||
defer resolved.deinit();
|
||||
if (resolved.paths.len == 0) return null;
|
||||
|
||||
// metadata.srf lives in the same directory as the portfolio
|
||||
// anchor (see AGENTS.md "Portfolio auto-detection").
|
||||
const anchor_path = resolved.paths[0];
|
||||
const dir_end = if (std.mem.lastIndexOfScalar(u8, anchor_path, std.fs.path.sep)) |idx| idx + 1 else 0;
|
||||
const meta_path = std.fmt.allocPrint(ctx.allocator, "{s}metadata.srf", .{anchor_path[0..dir_end]}) catch return null;
|
||||
defer ctx.allocator.free(meta_path);
|
||||
|
||||
const meta_data = std.Io.Dir.cwd().readFileAlloc(ctx.io, meta_path, ctx.allocator, .limited(1024 * 1024)) catch return null;
|
||||
defer ctx.allocator.free(meta_data);
|
||||
|
||||
return zfin.classification.parseClassificationFile(ctx.allocator, meta_data) catch null;
|
||||
}
|
||||
|
||||
pub fn display(allocator: std.mem.Allocator, candles: []const zfin.Candle, quote: ?QuoteData, symbol: []const u8, name: ?[]const u8, as_of: zfin.Date, color: bool, out: *std.Io.Writer) !void {
|
||||
const has_quote = quote != null;
|
||||
|
||||
// Header
|
||||
// Header. The security name (when resolved) renders between the
|
||||
// symbol and the price, matching the TUI quote tab.
|
||||
try cli.setBold(out, color);
|
||||
try out.print("\n{s}", .{symbol});
|
||||
if (name) |nm| {
|
||||
if (nm.len > 0) try out.print(" {s}", .{nm});
|
||||
}
|
||||
if (quote) |q| {
|
||||
try out.print("\n{s} {f}\n", .{ symbol, Money.from(q.price) });
|
||||
try out.print(" {f}\n", .{Money.from(q.price)});
|
||||
} else if (candles.len > 0) {
|
||||
try out.print("\n{s} {f} (close)\n", .{ symbol, Money.from(candles[candles.len - 1].close) });
|
||||
try out.print(" {f} (close)\n", .{Money.from(candles[candles.len - 1].close)});
|
||||
} else {
|
||||
try out.print("\n{s}\n", .{symbol});
|
||||
try out.print("\n", .{});
|
||||
}
|
||||
try cli.reset(out, color);
|
||||
try out.print("========================================\n", .{});
|
||||
try out.print("======================================================================\n", .{});
|
||||
|
||||
// Quote details
|
||||
const price = if (quote) |q| q.price else if (candles.len > 0) candles[candles.len - 1].close else @as(f64, 0);
|
||||
|
|
@ -257,7 +330,7 @@ test "display with candles only" {
|
|||
.{ .date = .{ .days = 20000 }, .open = 150.0, .high = 155.0, .low = 149.0, .close = 153.0, .adj_close = 153.0, .volume = 50_000_000 },
|
||||
.{ .date = .{ .days = 20001 }, .open = 153.0, .high = 158.0, .low = 152.0, .close = 156.0, .adj_close = 156.0, .volume = 45_000_000 },
|
||||
};
|
||||
try display(std.testing.allocator, &candles, null, "AAPL", zfin.Date.fromYmd(2026, 5, 8), false, &w);
|
||||
try display(std.testing.allocator, &candles, null, "AAPL", null, zfin.Date.fromYmd(2026, 5, 8), false, &w);
|
||||
const out = w.buffered();
|
||||
try std.testing.expect(std.mem.indexOf(u8, out, "AAPL") != null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, out, "(close)") != null);
|
||||
|
|
@ -278,7 +351,7 @@ test "display with quote data" {
|
|||
.prev_close = 172.00,
|
||||
.date = .{ .days = 20001 },
|
||||
};
|
||||
try display(std.testing.allocator, &candles, quote, "AAPL", zfin.Date.fromYmd(2026, 5, 8), false, &w);
|
||||
try display(std.testing.allocator, &candles, quote, "AAPL", null, zfin.Date.fromYmd(2026, 5, 8), false, &w);
|
||||
const out = w.buffered();
|
||||
try std.testing.expect(std.mem.indexOf(u8, out, "AAPL") != null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, out, "Change") != null);
|
||||
|
|
@ -286,13 +359,37 @@ test "display with quote data" {
|
|||
try std.testing.expect(std.mem.indexOf(u8, out, "(close)") == null);
|
||||
}
|
||||
|
||||
test "display renders the security name when provided" {
|
||||
var buf: [8192]u8 = undefined;
|
||||
var w: std.Io.Writer = .fixed(&buf);
|
||||
const candles = [_]zfin.Candle{
|
||||
.{ .date = .{ .days = 20000 }, .open = 150.0, .high = 155.0, .low = 149.0, .close = 153.0, .adj_close = 153.0, .volume = 50_000_000 },
|
||||
};
|
||||
try display(std.testing.allocator, &candles, null, "AAPL", "Apple Inc.", zfin.Date.fromYmd(2026, 5, 8), false, &w);
|
||||
const out = w.buffered();
|
||||
// Name appears between the symbol and the price.
|
||||
try std.testing.expect(std.mem.indexOf(u8, out, "AAPL Apple Inc.") != null);
|
||||
}
|
||||
|
||||
test "display omits an empty name" {
|
||||
var buf: [8192]u8 = undefined;
|
||||
var w: std.Io.Writer = .fixed(&buf);
|
||||
const candles = [_]zfin.Candle{
|
||||
.{ .date = .{ .days = 20000 }, .open = 150.0, .high = 155.0, .low = 149.0, .close = 153.0, .adj_close = 153.0, .volume = 50_000_000 },
|
||||
};
|
||||
try display(std.testing.allocator, &candles, null, "AAPL", "", zfin.Date.fromYmd(2026, 5, 8), false, &w);
|
||||
const out = w.buffered();
|
||||
// No double-space orphan where the name would have gone.
|
||||
try std.testing.expect(std.mem.indexOf(u8, out, "AAPL $") != null);
|
||||
}
|
||||
|
||||
test "display no ANSI without color" {
|
||||
var buf: [8192]u8 = undefined;
|
||||
var w: std.Io.Writer = .fixed(&buf);
|
||||
const candles = [_]zfin.Candle{
|
||||
.{ .date = .{ .days = 20000 }, .open = 100.0, .high = 105.0, .low = 99.0, .close = 103.0, .adj_close = 103.0, .volume = 1_000_000 },
|
||||
};
|
||||
try display(std.testing.allocator, &candles, null, "SPY", zfin.Date.fromYmd(2026, 5, 8), false, &w);
|
||||
try display(std.testing.allocator, &candles, null, "SPY", null, zfin.Date.fromYmd(2026, 5, 8), false, &w);
|
||||
const out = w.buffered();
|
||||
try std.testing.expect(std.mem.indexOf(u8, out, "\x1b[") == null);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -146,6 +146,43 @@ pub fn deriveBucket(entry: ClassificationEntry, allocator: std.mem.Allocator) ![
|
|||
return try allocator.dupe(u8, "Unclassified");
|
||||
}
|
||||
|
||||
/// Resolve a human-readable security name for `symbol`, applying
|
||||
/// the project-wide name-source policy:
|
||||
/// 1. The curated `name::` field from `metadata.srf` (via the
|
||||
/// classification map) — the same source the TUI 'K' overlay
|
||||
/// uses. Wins whenever present.
|
||||
/// 2. `fallback_name` (typically the ETF profile's fund name)
|
||||
/// when metadata has no name for the symbol.
|
||||
///
|
||||
/// Returns a slice borrowed from `cm` or `fallback_name` (the
|
||||
/// caller must keep those alive for as long as the result is used),
|
||||
/// or null when neither source yields a name.
|
||||
///
|
||||
/// This is the single source of truth for the security name shown
|
||||
/// by the 'K' overlay, the quote tab header, and the CLI `quote`
|
||||
/// command. Symbol comparison is exact (`std.mem.eql`); every caller
|
||||
/// normalizes symbols to upper-case before calling, matching the
|
||||
/// upper-case symbols `metadata.srf` carries.
|
||||
pub fn resolveSecurityName(
|
||||
symbol: []const u8,
|
||||
cm: ?*const ClassificationMap,
|
||||
fallback_name: ?[]const u8,
|
||||
) ?[]const u8 {
|
||||
if (cm) |m| {
|
||||
for (m.entries) |*e| {
|
||||
if (std.mem.eql(u8, e.symbol, symbol)) {
|
||||
// First matching entry that actually carries a name
|
||||
// wins. Blended-fund symbols repeat across rows with
|
||||
// the same name, so this is well-defined.
|
||||
if (e.name) |n| {
|
||||
if (n.len > 0) return n;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return fallback_name;
|
||||
}
|
||||
|
||||
test "parse classification file" {
|
||||
const data =
|
||||
\\#!srfv1
|
||||
|
|
@ -210,6 +247,66 @@ test "parse classification file: bucket round-trips" {
|
|||
try std.testing.expectEqualStrings("Equity / Corporate", cm.entries[0].sector.?);
|
||||
}
|
||||
|
||||
test "resolveSecurityName: metadata name wins" {
|
||||
var entries = [_]ClassificationEntry{
|
||||
.{ .symbol = "AMZN", .name = "Amazon" },
|
||||
.{ .symbol = "VTI", .name = "Vanguard Total Stock Market ETF" },
|
||||
};
|
||||
const cm: ClassificationMap = .{ .entries = &entries, .allocator = std.testing.allocator };
|
||||
// Metadata hit; fallback ignored.
|
||||
try std.testing.expectEqualStrings(
|
||||
"Amazon",
|
||||
resolveSecurityName("AMZN", &cm, "ignored fallback").?,
|
||||
);
|
||||
try std.testing.expectEqualStrings(
|
||||
"Vanguard Total Stock Market ETF",
|
||||
resolveSecurityName("VTI", &cm, null).?,
|
||||
);
|
||||
}
|
||||
|
||||
test "resolveSecurityName: falls back when entry has no name" {
|
||||
var entries = [_]ClassificationEntry{
|
||||
.{ .symbol = "SOXX", .name = null }, // pre-name metadata row
|
||||
};
|
||||
const cm: ClassificationMap = .{ .entries = &entries, .allocator = std.testing.allocator };
|
||||
try std.testing.expectEqualStrings(
|
||||
"iShares Semiconductor ETF",
|
||||
resolveSecurityName("SOXX", &cm, "iShares Semiconductor ETF").?,
|
||||
);
|
||||
// No fallback either → null.
|
||||
try std.testing.expect(resolveSecurityName("SOXX", &cm, null) == null);
|
||||
}
|
||||
|
||||
test "resolveSecurityName: falls back when symbol absent from map" {
|
||||
var entries = [_]ClassificationEntry{
|
||||
.{ .symbol = "AMZN", .name = "Amazon" },
|
||||
};
|
||||
const cm: ClassificationMap = .{ .entries = &entries, .allocator = std.testing.allocator };
|
||||
try std.testing.expectEqualStrings(
|
||||
"SPDR S&P 500 ETF Trust",
|
||||
resolveSecurityName("SPY", &cm, "SPDR S&P 500 ETF Trust").?,
|
||||
);
|
||||
}
|
||||
|
||||
test "resolveSecurityName: null map uses fallback, null everything is null" {
|
||||
try std.testing.expectEqualStrings(
|
||||
"Apple Inc.",
|
||||
resolveSecurityName("AAPL", null, "Apple Inc.").?,
|
||||
);
|
||||
try std.testing.expect(resolveSecurityName("AAPL", null, null) == null);
|
||||
}
|
||||
|
||||
test "resolveSecurityName: empty metadata name treated as absent" {
|
||||
var entries = [_]ClassificationEntry{
|
||||
.{ .symbol = "AAPL", .name = "" },
|
||||
};
|
||||
const cm: ClassificationMap = .{ .entries = &entries, .allocator = std.testing.allocator };
|
||||
try std.testing.expectEqualStrings(
|
||||
"Apple Inc.",
|
||||
resolveSecurityName("AAPL", &cm, "Apple Inc.").?,
|
||||
);
|
||||
}
|
||||
|
||||
test "deriveBucket: returns user-curated bucket when set" {
|
||||
const e: ClassificationEntry = .{
|
||||
.symbol = "SPY",
|
||||
|
|
|
|||
23
src/tui.zig
23
src/tui.zig
|
|
@ -1797,16 +1797,27 @@ pub const App = struct {
|
|||
break :blk null;
|
||||
};
|
||||
|
||||
const display_name: []const u8 = if (ce_opt) |ce| (ce.name orelse sym) else sym;
|
||||
// Name: same policy as the quote tab and CLI `quote` —
|
||||
// metadata.srf `name::` first, then the ETF profile's fund
|
||||
// name. The ETF fallback only applies when the overlay
|
||||
// targets the active symbol, since `symbol_data` holds the
|
||||
// profile for `self.symbol`, not necessarily `sym`.
|
||||
const etf_fallback: ?[]const u8 = if (std.mem.eql(u8, sym, self.symbol))
|
||||
(if (self.symbol_data.etf_profile) |p| p.name else null)
|
||||
else
|
||||
null;
|
||||
const resolved_name = zfin.classification.resolveSecurityName(sym, cm_opt, etf_fallback);
|
||||
try lines.append(arena, .{
|
||||
.text = try std.fmt.allocPrint(arena, " {s}", .{sym}),
|
||||
.style = th.headerStyle(),
|
||||
});
|
||||
if (display_name.ptr != sym.ptr and display_name.len > 0) {
|
||||
try lines.append(arena, .{
|
||||
.text = try std.fmt.allocPrint(arena, " {s}", .{display_name}),
|
||||
.style = th.contentStyle(),
|
||||
});
|
||||
if (resolved_name) |nm| {
|
||||
if (nm.len > 0 and !std.mem.eql(u8, nm, sym)) {
|
||||
try lines.append(arena, .{
|
||||
.text = try std.fmt.allocPrint(arena, " {s}", .{nm}),
|
||||
.style = th.contentStyle(),
|
||||
});
|
||||
}
|
||||
}
|
||||
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
||||
|
||||
|
|
|
|||
|
|
@ -266,9 +266,16 @@ fn drawWithKittyChart(app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, wi
|
|||
var lines: std.ArrayList(StyledLine) = .empty;
|
||||
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
||||
|
||||
// Symbol (+ resolved name) label, shared by the live and
|
||||
// close-of-day header variants below.
|
||||
const sym_label: []const u8 = if (quoteTabName(app)) |n|
|
||||
try std.fmt.allocPrint(arena, "{s} {s}", .{ app.symbol, n })
|
||||
else
|
||||
app.symbol;
|
||||
|
||||
// Symbol + price header
|
||||
if (app.states.quote.live) |q| {
|
||||
const price_str = try std.fmt.allocPrint(arena, " {s} ${d:.2}", .{ app.symbol, q.close });
|
||||
const price_str = try std.fmt.allocPrint(arena, " {s} ${d:.2}", .{ sym_label, q.close });
|
||||
try lines.append(arena, .{ .text = price_str, .style = th.headerStyle() });
|
||||
if (q.previous_close > 0) {
|
||||
const change = q.close - q.previous_close;
|
||||
|
|
@ -279,7 +286,7 @@ fn drawWithKittyChart(app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, wi
|
|||
}
|
||||
} else if (c.len > 0) {
|
||||
const last = c[c.len - 1];
|
||||
const price_str = try std.fmt.allocPrint(arena, " {s} ${d:.2} (close)", .{ app.symbol, last.close });
|
||||
const price_str = try std.fmt.allocPrint(arena, " {s} ${d:.2} (close)", .{ sym_label, last.close });
|
||||
try lines.append(arena, .{ .text = price_str, .style = th.headerStyle() });
|
||||
if (c.len >= 2) {
|
||||
const prev_close = c[c.len - 2].close;
|
||||
|
|
@ -588,20 +595,40 @@ pub const QuoteHeaderSource = union(enum) {
|
|||
};
|
||||
|
||||
/// 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.
|
||||
/// (arena, symbol, name, source). The three branches mirror the
|
||||
/// live / close-of-day / no-data paths in the live builder. When
|
||||
/// `name` is non-null and non-empty, it's rendered between the
|
||||
/// symbol and the timing suffix (e.g. "AAPL Apple Inc. (live …)").
|
||||
pub fn formatQuoteHeader(
|
||||
arena: std.mem.Allocator,
|
||||
symbol: []const u8,
|
||||
name: ?[]const u8,
|
||||
source: QuoteHeaderSource,
|
||||
) ![]const u8 {
|
||||
const name_part: []const u8 = if (name) |n|
|
||||
(if (n.len > 0) try std.fmt.allocPrint(arena, " {s}", .{n}) else "")
|
||||
else
|
||||
"";
|
||||
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}),
|
||||
.live => |ago| std.fmt.allocPrint(arena, " {s}{s} (live, ~15 min delay, refreshed {s})", .{ symbol, name_part, ago }),
|
||||
.close => |date| std.fmt.allocPrint(arena, " {s}{s} (as of close on {f})", .{ symbol, name_part, date }),
|
||||
.none => std.fmt.allocPrint(arena, " {s}{s}", .{ symbol, name_part }),
|
||||
};
|
||||
}
|
||||
|
||||
/// Resolve the active symbol's display name using the shared
|
||||
/// policy (metadata.srf `name::`, then the ETF profile's fund
|
||||
/// name). Mirrors the 'K' overlay and the CLI `quote` command, so
|
||||
/// the three surfaces always agree on the name they show. The ETF
|
||||
/// profile is loaded lazily on the performance tab, so the fallback
|
||||
/// only kicks in once that data is present; the metadata name is
|
||||
/// always available when enriched.
|
||||
fn quoteTabName(app: *App) ?[]const u8 {
|
||||
const cm = app.portfolio.classificationMap();
|
||||
const fallback: ?[]const u8 = if (app.symbol_data.etf_profile) |p| p.name else null;
|
||||
return zfin.classification.resolveSecurityName(app.symbol, cm, fallback);
|
||||
}
|
||||
|
||||
fn buildStyledLines(app: *App, arena: std.mem.Allocator) ![]const StyledLine {
|
||||
const th = app.theme;
|
||||
var lines: std.ArrayList(StyledLine) = .empty;
|
||||
|
|
@ -614,15 +641,16 @@ fn buildStyledLines(app: *App, arena: std.mem.Allocator) ![]const StyledLine {
|
|||
}
|
||||
|
||||
var ago_buf: [16]u8 = undefined;
|
||||
const name = quoteTabName(app);
|
||||
if (app.states.quote.live != null and app.states.quote.timestamp > 0) {
|
||||
// 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 formatQuoteHeader(arena, app.symbol, .{ .live = ago_str }), .style = th.headerStyle() });
|
||||
try lines.append(arena, .{ .text = try formatQuoteHeader(arena, app.symbol, name, .{ .live = ago_str }), .style = th.headerStyle() });
|
||||
} else if (app.symbol_data.candleLastDate()) |d| {
|
||||
try lines.append(arena, .{ .text = try formatQuoteHeader(arena, app.symbol, .{ .close = d }), .style = th.headerStyle() });
|
||||
try lines.append(arena, .{ .text = try formatQuoteHeader(arena, app.symbol, name, .{ .close = d }), .style = th.headerStyle() });
|
||||
} else {
|
||||
try lines.append(arena, .{ .text = try formatQuoteHeader(arena, app.symbol, .none), .style = th.headerStyle() });
|
||||
try lines.append(arena, .{ .text = try formatQuoteHeader(arena, app.symbol, name, .none), .style = th.headerStyle() });
|
||||
}
|
||||
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
||||
|
||||
|
|
@ -860,7 +888,7 @@ 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" });
|
||||
const text = try formatQuoteHeader(arena, "AAPL", null, .{ .live = "5s ago" });
|
||||
try testing.expectEqualStrings(" AAPL (live, ~15 min delay, refreshed 5s ago)", text);
|
||||
}
|
||||
|
||||
|
|
@ -868,7 +896,7 @@ 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) });
|
||||
const text = try formatQuoteHeader(arena, "VTI", null, .{ .close = Date.fromYmd(2024, 3, 15) });
|
||||
try testing.expectEqualStrings(" VTI (as of close on 2024-03-15)", text);
|
||||
}
|
||||
|
||||
|
|
@ -876,6 +904,33 @@ 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);
|
||||
const text = try formatQuoteHeader(arena, "BRK.B", null, .none);
|
||||
try testing.expectEqualStrings(" BRK.B", text);
|
||||
}
|
||||
|
||||
test "formatQuoteHeader: name renders between symbol and suffix" {
|
||||
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
|
||||
defer arena_state.deinit();
|
||||
const arena = arena_state.allocator();
|
||||
|
||||
try testing.expectEqualStrings(
|
||||
" AAPL Apple Inc. (live, ~15 min delay, refreshed 5s ago)",
|
||||
try formatQuoteHeader(arena, "AAPL", "Apple Inc.", .{ .live = "5s ago" }),
|
||||
);
|
||||
try testing.expectEqualStrings(
|
||||
" VTI Vanguard Total Stock Market ETF (as of close on 2024-03-15)",
|
||||
try formatQuoteHeader(arena, "VTI", "Vanguard Total Stock Market ETF", .{ .close = Date.fromYmd(2024, 3, 15) }),
|
||||
);
|
||||
try testing.expectEqualStrings(
|
||||
" BRK.B Berkshire Hathaway",
|
||||
try formatQuoteHeader(arena, "BRK.B", "Berkshire Hathaway", .none),
|
||||
);
|
||||
}
|
||||
|
||||
test "formatQuoteHeader: empty name is omitted" {
|
||||
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
|
||||
defer arena_state.deinit();
|
||||
const arena = arena_state.allocator();
|
||||
const text = try formatQuoteHeader(arena, "AAPL", "", .none);
|
||||
try testing.expectEqualStrings(" AAPL", text);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue