diff --git a/src/tui.zig b/src/tui.zig index fcc6b48..c94eab2 100644 --- a/src/tui.zig +++ b/src/tui.zig @@ -4032,3 +4032,116 @@ fn launchEditor(allocator: std.mem.Allocator, portfolio_path: ?[]const u8, watch child.spawn() catch return; _ = child.wait() catch {}; } + +// ── Tests ───────────────────────────────────────────────────────────── + +const testing = std.testing; + +test "colLabel plain left-aligned" { + var buf: [32]u8 = undefined; + const result = colLabel(&buf, "Name", 10, true, null); + try testing.expectEqualStrings("Name ", result); + try testing.expectEqual(@as(usize, 10), result.len); +} + +test "colLabel plain right-aligned" { + var buf: [32]u8 = undefined; + const result = colLabel(&buf, "Price", 10, false, null); + try testing.expectEqualStrings(" Price", result); +} + +test "colLabel with indicator left-aligned" { + var buf: [64]u8 = undefined; + const result = colLabel(&buf, "Name", 10, true, "\xe2\x96\xb2"); // ▲ = 3 bytes + // Indicator + text + padding. Display width is 10, byte length is 10 - 1 + 3 = 12 + try testing.expectEqual(@as(usize, 12), result.len); + try testing.expect(std.mem.startsWith(u8, result, "\xe2\x96\xb2")); // starts with ▲ + try testing.expect(std.mem.indexOf(u8, result, "Name") != null); +} + +test "colLabel with indicator right-aligned" { + var buf: [64]u8 = undefined; + const result = colLabel(&buf, "Price", 10, false, "\xe2\x96\xbc"); // ▼ + try testing.expectEqual(@as(usize, 12), result.len); + try testing.expect(std.mem.endsWith(u8, result, "Price")); +} + +test "glyph ASCII returns single-char slice" { + try testing.expectEqualStrings("A", glyph('A')); + try testing.expectEqualStrings(" ", glyph(' ')); + try testing.expectEqualStrings("0", glyph('0')); +} + +test "glyph non-ASCII returns space" { + try testing.expectEqualStrings(" ", glyph(200)); +} + +test "PortfolioSortField next/prev" { + // next from first field + try testing.expectEqual(PortfolioSortField.shares, PortfolioSortField.symbol.next().?); + // next from last field returns null + try testing.expectEqual(@as(?PortfolioSortField, null), PortfolioSortField.account.next()); + // prev from first returns null + try testing.expectEqual(@as(?PortfolioSortField, null), PortfolioSortField.symbol.prev()); + // prev from last + try testing.expectEqual(PortfolioSortField.weight, PortfolioSortField.account.prev().?); +} + +test "PortfolioSortField label" { + try testing.expectEqualStrings("Symbol", PortfolioSortField.symbol.label()); + try testing.expectEqualStrings("Market Value", PortfolioSortField.market_value.label()); +} + +test "SortDirection flip and indicator" { + try testing.expectEqual(SortDirection.desc, SortDirection.asc.flip()); + try testing.expectEqual(SortDirection.asc, SortDirection.desc.flip()); + try testing.expectEqualStrings("\xe2\x96\xb2", SortDirection.asc.indicator()); // ▲ + try testing.expectEqualStrings("\xe2\x96\xbc", SortDirection.desc.indicator()); // ▼ +} + +test "buildBlockBar empty" { + const bar = try App.buildBlockBar(testing.allocator, 0, 10); + defer testing.allocator.free(bar); + // All spaces + try testing.expectEqual(@as(usize, 10), bar.len); + try testing.expectEqualStrings(" ", bar); +} + +test "buildBlockBar full" { + const bar = try App.buildBlockBar(testing.allocator, 1.0, 5); + defer testing.allocator.free(bar); + // 5 full blocks, each 3 bytes UTF-8 (█ = E2 96 88) + try testing.expectEqual(@as(usize, 15), bar.len); + // Verify first block is █ + try testing.expectEqualStrings("\xe2\x96\x88", bar[0..3]); +} + +test "buildBlockBar partial" { + const bar = try App.buildBlockBar(testing.allocator, 0.5, 10); + defer testing.allocator.free(bar); + // 50% of 10 chars = 5 full blocks (no partial) + // 5 full blocks (15 bytes) + 5 spaces = 20 bytes + try testing.expectEqual(@as(usize, 20), bar.len); +} + +test "fmtBreakdownLine formats correctly" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + const item = zfin.analysis.BreakdownItem{ + .label = "US Stock", + .weight = 0.65, + .value = 130000, + }; + const line = try App.fmtBreakdownLine(arena, item, 10, 12); + // Should contain the label, percentage, and dollar amount + try testing.expect(std.mem.indexOf(u8, line, "US Stock") != null); + try testing.expect(std.mem.indexOf(u8, line, "65.0%") != null); + try testing.expect(std.mem.indexOf(u8, line, "$130,000") != null); +} + +test "Tab label" { + try testing.expectEqualStrings(" 1:Portfolio ", Tab.portfolio.label()); + try testing.expectEqualStrings(" 6:Analysis ", Tab.analysis.label()); +} diff --git a/src/tui/chart.zig b/src/tui/chart.zig index 5f8cc14..4d380f2 100644 --- a/src/tui/chart.zig +++ b/src/tui/chart.zig @@ -527,3 +527,80 @@ fn drawRect(ctx: *Context, alloc: std.mem.Allocator, x1: f64, y1: f64, x2: f64, try ctx.stroke(); ctx.setLineWidth(2.0); } + +// ── Tests ───────────────────────────────────────────────────────────── + +test "mapY maps value to pixel coordinate" { + // value at min → bottom + try std.testing.expectEqual(@as(f64, 500.0), mapY(0, 0, 100, 100, 500)); + // value at max → top + try std.testing.expectEqual(@as(f64, 100.0), mapY(100, 0, 100, 100, 500)); + // value at midpoint → midpoint + try std.testing.expectEqual(@as(f64, 300.0), mapY(50, 0, 100, 100, 500)); + // flat range → midpoint + try std.testing.expectEqual(@as(f64, 300.0), mapY(42, 42, 42, 100, 500)); +} + +test "blendColor alpha blending" { + const white = [3]u8{ 255, 255, 255 }; + const black = [3]u8{ 0, 0, 0 }; + + // Full alpha → foreground + const full = blendColor(white, 255, black); + try std.testing.expectEqual(@as(u8, 255), full.rgb.r); + try std.testing.expectEqual(@as(u8, 255), full.rgb.g); + + // Zero alpha → background + const zero = blendColor(white, 0, black); + try std.testing.expectEqual(@as(u8, 0), zero.rgb.r); + + // Half alpha → midpoint + const half = blendColor(white, 128, black); + // 255 * (128/255) + 0 * (127/255) ≈ 128 + try std.testing.expect(half.rgb.r >= 127 and half.rgb.r <= 129); +} + +test "opaqueColor wraps theme color" { + const px = opaqueColor(.{ 0x7f, 0xd8, 0x8f }); + try std.testing.expectEqual(@as(u8, 0x7f), px.rgb.r); + try std.testing.expectEqual(@as(u8, 0xd8), px.rgb.g); + try std.testing.expectEqual(@as(u8, 0x8f), px.rgb.b); +} + +test "ChartConfig.parse" { + // Named modes + const auto = ChartConfig.parse("auto").?; + try std.testing.expectEqual(ChartMode.auto, auto.mode); + + const braille = ChartConfig.parse("braille").?; + try std.testing.expectEqual(ChartMode.braille, braille.mode); + + // WxH format + const custom = ChartConfig.parse("800x600").?; + try std.testing.expectEqual(ChartMode.kitty, custom.mode); + try std.testing.expectEqual(@as(u32, 800), custom.max_width); + try std.testing.expectEqual(@as(u32, 600), custom.max_height); + + // Too small + try std.testing.expectEqual(@as(?ChartConfig, null), ChartConfig.parse("50x50")); + + // Invalid + try std.testing.expectEqual(@as(?ChartConfig, null), ChartConfig.parse("garbage")); +} + +test "Timeframe next/prev cycle" { + // next cycles through all values + try std.testing.expectEqual(Timeframe.ytd, Timeframe.@"6M".next()); + try std.testing.expectEqual(Timeframe.@"1Y", Timeframe.ytd.next()); + try std.testing.expectEqual(Timeframe.@"6M", Timeframe.@"5Y".next()); // wraps + + // prev is the reverse + try std.testing.expectEqual(Timeframe.@"5Y", Timeframe.@"6M".prev()); // wraps + try std.testing.expectEqual(Timeframe.@"6M", Timeframe.ytd.prev()); +} + +test "Timeframe tradingDays" { + try std.testing.expectEqual(@as(usize, 126), Timeframe.@"6M".tradingDays()); + try std.testing.expectEqual(@as(usize, 252), Timeframe.@"1Y".tradingDays()); + try std.testing.expectEqual(@as(usize, 1260), Timeframe.@"5Y".tradingDays()); +}