From 097fe68d35810295a202497e3613a4aae74e7add Mon Sep 17 00:00:00 2001 From: Emil Lerch Date: Thu, 25 Jun 2026 14:43:55 -0700 Subject: [PATCH] additional tests/bump coverage floor --- src/commands/contributions.zig | 100 +++++++++++++++++++++++++++++++++ src/commands/enrich.zig | 8 +++ src/commands/exposure.zig | 37 ++++++++++++ src/commands/milestones.zig | 83 +++++++++++++++++++++++++++ src/commands/review.zig | 49 ++++++++++++++++ src/net/http.zig | 57 +++++++++++++++++++ 6 files changed, 334 insertions(+) diff --git a/src/commands/contributions.zig b/src/commands/contributions.zig index 37579a0..5a8fdc7 100644 --- a/src/commands/contributions.zig +++ b/src/commands/contributions.zig @@ -5811,3 +5811,103 @@ test "printChangeLine: no ANSI when color=false" { try printChangeLine(&w, c, false, cli.CLR_POSITIVE); try std.testing.expect(std.mem.indexOf(u8, w.buffered(), "\x1b[") == null); } + +test "printReport: empty report says no changes" { + var buf: [512]u8 = undefined; + var w: std.Io.Writer = .fixed(&buf); + var account_totals = std.StringHashMap(Report.AccountTotal).init(testing.allocator); + defer account_totals.deinit(); + var cash_attr = std.StringHashMap(f64).init(testing.allocator); + defer cash_attr.deinit(); + var changes = [_]Change{}; + const report = Report{ + .changes = changes[0..], + .account_totals = account_totals, + .cash_attributed_by_account = cash_attr, + }; + try printReport(&w, &report, "portfolio.srf", false); + const out = w.buffered(); + try testing.expect(std.mem.indexOf(u8, out, "No changes detected.") != null); +} + +test "printReport: full report renders every section and sub-printer" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + // One change of (nearly) every kind, exercising every section and + // each line-printer. Values are arbitrary but internally consistent + // for the CD-interest recompute (cash_delta - face = implied interest). + var changes = [_]Change{ + .{ .kind = .new_stock, .symbol = "AAPL", .account = "Roth", .security_type = .stock, .delta_shares = 10, .unit_value = 150 }, + .{ .kind = .new_cash, .symbol = "CASH", .account = "Roth", .security_type = .cash, .delta_shares = 2000, .unit_value = 1 }, + .{ .kind = .new_drip_lot, .symbol = "VTI", .account = "Roth", .security_type = .stock, .delta_shares = 2, .unit_value = 200 }, + .{ .kind = .drip_confirmed, .symbol = "SCHD", .account = "Roth", .security_type = .stock, .delta_shares = 1, .unit_value = 75 }, + .{ .kind = .rollup_delta, .symbol = "VOO", .account = "Brokerage", .security_type = .stock, .delta_shares = 0.5, .unit_value = 400 }, + .{ .kind = .cd_matured, .symbol = "CD1", .account = "CD Acct", .security_type = .cd, .face_value = 10000, .maturity_date = Date.fromYmd(2026, 5, 1) }, + .{ .kind = .cash_delta, .symbol = "CASH", .account = "CD Acct", .security_type = .cash, .delta_shares = 10500, .unit_value = 1 }, + .{ .kind = .cash_contribution, .symbol = "CASH", .account = "HSA", .security_type = .cash, .delta_shares = 300, .unit_value = 1 }, + .{ .kind = .transfer_in, .symbol = "VTI", .account = "Acct B", .security_type = .stock, .delta_shares = 5, .unit_value = 100, .transfer_attributed = 500, .transfer_from = "Acct A", .transfer_date = Date.fromYmd(2026, 5, 2), .transfer_note = "rollover" }, + .{ .kind = .transfer_out, .symbol = "", .account = "Acct A", .security_type = .cash, .transfer_attributed = 500, .transfer_date = Date.fromYmd(2026, 5, 2) }, + .{ .kind = .partial_transfer_in, .symbol = "SPY", .account = "Acct B", .security_type = .stock, .delta_shares = 10, .unit_value = 100, .transfer_attributed = 600, .transfer_from = "Acct A", .transfer_date = Date.fromYmd(2026, 5, 2) }, + .{ .kind = .price_only, .symbol = "VTI", .account = "Roth", .security_type = .stock, .old_price = 100, .new_price = 110 }, + .{ .kind = .lot_edited, .symbol = "BND", .account = "Trust", .security_type = .stock }, + .{ .kind = .flagged, .symbol = "XYZ", .account = "Roth", .security_type = .stock, .detail = "manual edit" }, + .{ .kind = .lot_removed, .symbol = "OLD", .account = "Brokerage", .security_type = .stock, .face_value = 2500 }, + .{ .kind = .drip_negative, .symbol = "ABC", .account = "Roth", .security_type = .stock, .delta_shares = -1, .unit_value = 50 }, + .{ .kind = .unmatched_transfer, .symbol = "", .account = "Acct C", .security_type = .cash, .transfer_attributed = 750, .transfer_from = "Acct D", .transfer_date = Date.fromYmd(2026, 5, 3), .transfer_note = "unmatched wire" }, + }; + + var account_totals = std.StringHashMap(Report.AccountTotal).init(arena); + try account_totals.put("Roth", .{ .new_money = 3800, .drip_confirmed = 475, .rollup = 0, .cash_delta = 0 }); + try account_totals.put("CD Acct", .{ .cash_delta = 10500 }); + try account_totals.put("", .{}); // exercises "(no account)" label + all-zero summary cells + const cash_attr = std.StringHashMap(f64).init(arena); + + const report = Report{ + .changes = changes[0..], + .account_totals = account_totals, + .cash_attributed_by_account = cash_attr, + }; + + var buf: [16384]u8 = undefined; + var w: std.Io.Writer = .fixed(&buf); + try printReport(&w, &report, "portfolio.srf (+1 more)", false); + const out = w.buffered(); + + // Section headers. + try testing.expect(std.mem.indexOf(u8, out, "Portfolio contributions report") != null); + try testing.expect(std.mem.indexOf(u8, out, "== New contributions / purchases ==") != null); + try testing.expect(std.mem.indexOf(u8, out, "== DRIP (confirmed") != null); + try testing.expect(std.mem.indexOf(u8, out, "== Rollup share deltas") != null); + try testing.expect(std.mem.indexOf(u8, out, "== CD events ==") != null); + try testing.expect(std.mem.indexOf(u8, out, "== Cash deltas") != null); + try testing.expect(std.mem.indexOf(u8, out, "== Transfers (matched") != null); + try testing.expect(std.mem.indexOf(u8, out, "== Price-only updates") != null); + try testing.expect(std.mem.indexOf(u8, out, "== Lot edits") != null); + try testing.expect(std.mem.indexOf(u8, out, "== Flagged for review ==") != null); + try testing.expect(std.mem.indexOf(u8, out, "== Summary by account ==") != null); + try testing.expect(std.mem.indexOf(u8, out, "Grand total:") != null); + + // Sub-printer content. + try testing.expect(std.mem.indexOf(u8, out, "AAPL") != null); // printChangeLine + try testing.expect(std.mem.indexOf(u8, out, "matured") != null); // printCdLine + try testing.expect(std.mem.indexOf(u8, out, "implied interest") != null); // CD interest line + try testing.expect(std.mem.indexOf(u8, out, "Implied interest captured") != null); + try testing.expect(std.mem.indexOf(u8, out, "Acct A -> Acct B") != null); // printTransferLine + try testing.expect(std.mem.indexOf(u8, out, "rollover") != null); // transfer note + try testing.expect(std.mem.indexOf(u8, out, "rest from transfer") != null); // printPartialTransferLine + try testing.expect(std.mem.indexOf(u8, out, "price ") != null); // printPriceOnlyLine + try testing.expect(std.mem.indexOf(u8, out, "manual edit") != null); // printFlaggedLine flagged + try testing.expect(std.mem.indexOf(u8, out, "lot removed") != null); // printFlaggedLine lot_removed + try testing.expect(std.mem.indexOf(u8, out, "Transfer 2026-05-03") != null); // printUnmatchedTransferLine + try testing.expect(std.mem.indexOf(u8, out, "unmatched wire") != null); + try testing.expect(std.mem.indexOf(u8, out, "(no account)") != null); // summary no-account label + try testing.expect(std.mem.indexOf(u8, out, "may include CD maturity") != null); // printCashDeltaLine hint + + // Color path: a second render with color=true emits ANSI escapes. + var cbuf: [16384]u8 = undefined; + var cw: std.Io.Writer = .fixed(&cbuf); + try printReport(&cw, &report, "portfolio.srf", true); + try testing.expect(std.mem.indexOf(u8, cw.buffered(), "\x1b[") != null); +} diff --git a/src/commands/enrich.zig b/src/commands/enrich.zig index 26e0271..1c97f12 100644 --- a/src/commands/enrich.zig +++ b/src/commands/enrich.zig @@ -1097,6 +1097,14 @@ test "reportFetchError: long symbol still classifies correctly (bufPrint fallbac // ── formatProvenanceMessage ──────────────────────────────────── +test "kindFromSource: maps provenance strings to FallbackKind" { + try std.testing.expectEqual(FallbackKind.wikidata, kindFromSource("wikidata")); + try std.testing.expectEqual(FallbackKind.edgar_fallback, kindFromSource("edgar_fallback")); + try std.testing.expectEqual(FallbackKind.none, kindFromSource("")); // empty -> none + try std.testing.expectEqual(FallbackKind.none, kindFromSource("polygon")); // unknown -> none + try std.testing.expectEqual(FallbackKind.none, kindFromSource("Wikidata")); // case-sensitive +} + test "formatProvenanceMessage: wikidata -> 'classified via Wikidata' line" { var buf: [256]u8 = undefined; const msg = formatProvenanceMessage(&buf, "AAPL", .wikidata, null) orelse return error.Format; diff --git a/src/commands/exposure.zig b/src/commands/exposure.zig index 13dd275..f54bf51 100644 --- a/src/commands/exposure.zig +++ b/src/commands/exposure.zig @@ -352,3 +352,40 @@ test "display: high concentration emits color when enabled" { const o = w.buffered(); try std.testing.expect(std.mem.indexOf(u8, o, "\x1b[") != null); } + +test "display: warning band (5-8%) renders the total line" { + var buf: [4096]u8 = undefined; + var w: std.Io.Writer = .fixed(&buf); + // 6% total -> in [warn_threshold, flag_threshold) -> WARNING color. + const result: exposure.ExposureResult = .{ + .symbol = "AAPL", + .total_value = 100_000, + .direct_value = 6_000, + .lookthrough_value = 0, + .contributions = &.{}, + .unresolved_holdings = 0, + }; + try display(result, "portfolio.srf", true, &w); + const o = w.buffered(); + try std.testing.expect(std.mem.indexOf(u8, o, "Total exposure") != null); + try std.testing.expect(std.mem.indexOf(u8, o, "$6,000") != null); + try std.testing.expect(std.mem.indexOf(u8, o, "\x1b[") != null); +} + +test "display: accent band (<5%) renders the total line" { + var buf: [4096]u8 = undefined; + var w: std.Io.Writer = .fixed(&buf); + // 3% total -> below warn_threshold -> ACCENT color. + const result: exposure.ExposureResult = .{ + .symbol = "AAPL", + .total_value = 100_000, + .direct_value = 3_000, + .lookthrough_value = 0, + .contributions = &.{}, + .unresolved_holdings = 0, + }; + try display(result, "portfolio.srf", false, &w); + const o = w.buffered(); + try std.testing.expect(std.mem.indexOf(u8, o, "Total exposure") != null); + try std.testing.expect(std.mem.indexOf(u8, o, "$3,000") != null); +} diff --git a/src/commands/milestones.zig b/src/commands/milestones.zig index fce8a1b..85c7946 100644 --- a/src/commands/milestones.zig +++ b/src/commands/milestones.zig @@ -454,3 +454,86 @@ test "loadMergedSeries: imported values only" { try std.testing.expectEqual(@as(f64, 1_280_000), s.points[0].value); try std.testing.expectEqual(Date.fromYmd(2020, 6, 1), s.points[2].date); } + +// ── Rendering tests ────────────────────────────────────────── + +test "buildCpiView maps Shiller annual data to YearCpi" { + const view = try buildCpiView(std.testing.allocator); + defer std.testing.allocator.free(view); + try std.testing.expectEqual(shiller.annual_returns.len, view.len); + try std.testing.expect(view.len > 0); + try std.testing.expectEqual(shiller.annual_returns[0].year, view[0].year); + try std.testing.expectEqual(shiller.annual_returns[0].cpi_inflation, view[0].cpi); +} + +test "renderHeader: absolute nominal step" { + var buf: [256]u8 = undefined; + var w: std.Io.Writer = .fixed(&buf); + try renderHeader(&w, false, .{ .absolute = 1_000_000 }, false, 0, &.{}); + const out = w.buffered(); + try std.testing.expect(std.mem.indexOf(u8, out, "$1,000,000") != null); + try std.testing.expect(std.mem.indexOf(u8, out, "(nominal)") != null); +} + +test "renderHeader: absolute real step shows reference year" { + var buf: [256]u8 = undefined; + var w: std.Io.Writer = .fixed(&buf); + try renderHeader(&w, false, .{ .absolute = 500_000 }, true, 2020, &.{}); + const out = w.buffered(); + try std.testing.expect(std.mem.indexOf(u8, out, "(real, reference year: 2020)") != null); +} + +test "renderHeader: relative step reads the starting point" { + var buf: [256]u8 = undefined; + var w: std.Io.Writer = .fixed(&buf); + const series = [_]milestones.Point{.{ .date = Date.fromYmd(2014, 7, 3), .value = 1_280_000 }}; + try renderHeader(&w, false, .{ .relative = 2.0 }, false, 0, &series); + const out = w.buffered(); + try std.testing.expect(std.mem.indexOf(u8, out, "x from") != null); + try std.testing.expect(std.mem.indexOf(u8, out, "$1,280,000") != null); + try std.testing.expect(std.mem.indexOf(u8, out, "2014-07-03") != null); +} + +test "renderNoCrossings: reports series max and start" { + var buf: [256]u8 = undefined; + var w: std.Io.Writer = .fixed(&buf); + const series = [_]milestones.Point{ + .{ .date = Date.fromYmd(2020, 1, 1), .value = 500_000 }, + .{ .date = Date.fromYmd(2021, 1, 1), .value = 750_000 }, + }; + try renderNoCrossings(&w, false, &series); + const out = w.buffered(); + try std.testing.expect(std.mem.indexOf(u8, out, "No milestones reached") != null); + try std.testing.expect(std.mem.indexOf(u8, out, "$750,000") != null); // max + try std.testing.expect(std.mem.indexOf(u8, out, "$500,000") != null); // start +} + +test "renderTable: absolute step with starting-row footnote" { + var buf: [2048]u8 = undefined; + var w: std.Io.Writer = .fixed(&buf); + const crossings = [_]milestones.Crossing{ + .{ .index = 1, .threshold = 1_000_000, .date = Date.fromYmd(2020, 1, 1), .days_since_prev = null, .days_since_first = 0, .is_start = true }, + .{ .index = 2, .threshold = 2_000_000, .date = Date.fromYmd(2022, 6, 15), .days_since_prev = 896, .days_since_first = 896, .is_start = false }, + }; + try renderTable(&w, false, .{ .absolute = 1_000_000 }, &crossings); + const out = w.buffered(); + try std.testing.expect(std.mem.indexOf(u8, out, "Milestone") != null); + try std.testing.expect(std.mem.indexOf(u8, out, "Date Crossed") != null); + try std.testing.expect(std.mem.indexOf(u8, out, "$2,000,000") != null); + try std.testing.expect(std.mem.indexOf(u8, out, "896 days") != null); + try std.testing.expect(std.mem.indexOf(u8, out, "starting value") != null); // footnote +} + +test "renderTable: relative step renders the Multiple column" { + var buf: [2048]u8 = undefined; + var w: std.Io.Writer = .fixed(&buf); + const crossings = [_]milestones.Crossing{ + .{ .index = 1, .threshold = 1_000_000, .date = Date.fromYmd(2020, 1, 1), .days_since_prev = null, .days_since_first = 0, .is_start = true }, + .{ .index = 2, .threshold = 2_000_000, .date = Date.fromYmd(2022, 6, 15), .days_since_prev = 896, .days_since_first = 896, .is_start = false }, + }; + try renderTable(&w, false, .{ .relative = 2.0 }, &crossings); + const out = w.buffered(); + try std.testing.expect(std.mem.indexOf(u8, out, "Multiple") != null); + try std.testing.expect(std.mem.indexOf(u8, out, "Threshold") != null); + try std.testing.expect(std.mem.indexOf(u8, out, "896 days") != null); +} diff --git a/src/commands/review.zig b/src/commands/review.zig index 4518949..ef9541e 100644 --- a/src/commands/review.zig +++ b/src/commands/review.zig @@ -1016,3 +1016,52 @@ test "render: emits reweight footnote when any flag set" { const out = w.buffered(); try testing.expect(std.mem.indexOf(u8, out, "Reweighted") != null); } + +test "renderStatusGrid: renders labels across severity variants" { + const dummy = struct { + fn run(ctx: observations.CheckCtx) observations.CheckResult { + _ = ctx; + return .pass; + } + }; + const checks = [_]observations.Check{ + .{ .name = "c1", .label = "Concentration", .run = dummy.run }, + .{ .name = "c2", .label = "Sector drift", .run = dummy.run }, + .{ .name = "c3", .label = "Cash drag", .run = dummy.run }, + .{ .name = "c4", .label = "Bond ladder", .run = dummy.run }, + .{ .name = "c5", .label = "Tax location", .run = dummy.run }, + }; + // pass / warn / flag / skipped / err across two rows (3 cells per row), + // exercising every rank, glyph, and worst-severity branch. + var pending = [_]observations.PendingCheck{ + .{ .check = &checks[0], .state = .{ .complete = .pass } }, + .{ .check = &checks[1], .state = .{ .complete = .{ .warn = &.{} } } }, + .{ .check = &checks[2], .state = .{ .complete = .{ .flag = &.{} } } }, + .{ .check = &checks[3], .state = .{ .complete = .skipped } }, + .{ .check = &checks[4], .state = .{ .complete = .{ .err = "boom" } } }, + }; + const panel = observations.CheckPanel{ + .allocator = testing.allocator, + .io = std.testing.io, + .pending = &pending, + }; + var buf: [4096]u8 = undefined; + var w: std.Io.Writer = .fixed(&buf); + try renderStatusGrid(&w, false, panel); + const out = w.buffered(); + try testing.expect(std.mem.indexOf(u8, out, "Concentration") != null); + try testing.expect(std.mem.indexOf(u8, out, "Bond ladder") != null); + try testing.expect(std.mem.indexOf(u8, out, "Tax location") != null); +} + +test "renderStatusGrid: empty panel writes nothing" { + const panel = observations.CheckPanel{ + .allocator = testing.allocator, + .io = std.testing.io, + .pending = &.{}, + }; + var buf: [64]u8 = undefined; + var w: std.Io.Writer = .fixed(&buf); + try renderStatusGrid(&w, false, panel); + try testing.expectEqual(@as(usize, 0), w.buffered().len); +} diff --git a/src/net/http.zig b/src/net/http.zig index b9b0367..4e49fed 100644 --- a/src/net/http.zig +++ b/src/net/http.zig @@ -470,6 +470,63 @@ test "buildUrl" { try std.testing.expectEqualStrings("https://api.example.com/v1/data?symbol=AAPL&apikey=test123", url); } +test "buildUrl percent-encodes reserved characters in values" { + const allocator = std.testing.allocator; + // Value contains a space, '&', '=' (all must be encoded) and '/' + // (allowed in query values, so left as-is). + const url = try buildUrl(allocator, "https://api.example.com/q", &.{ + .{ "name", "a b&c=d/e" }, + }); + defer allocator.free(url); + try std.testing.expect(std.mem.indexOf(u8, url, "%20") != null); // space + try std.testing.expect(std.mem.indexOf(u8, url, "%26") != null); // & + try std.testing.expect(std.mem.indexOf(u8, url, "%3D") != null or std.mem.indexOf(u8, url, "%3d") != null); // = + try std.testing.expect(std.mem.indexOf(u8, url, "d/e") != null); // '/' preserved +} + +test "classifyResponse maps each HTTP status to its HttpError" { + const allocator = std.testing.allocator; + const Case = struct { status: std.http.Status, expected: HttpError }; + const cases = [_]Case{ + .{ .status = .too_many_requests, .expected = HttpError.RateLimited }, + .{ .status = .unauthorized, .expected = HttpError.Unauthorized }, + .{ .status = .forbidden, .expected = HttpError.Unauthorized }, + .{ .status = .payment_required, .expected = HttpError.PaymentRequired }, + .{ .status = .not_found, .expected = HttpError.NotFound }, + .{ .status = .internal_server_error, .expected = HttpError.ServerError }, + .{ .status = .bad_gateway, .expected = HttpError.ServerError }, + .{ .status = .service_unavailable, .expected = HttpError.ServerError }, + .{ .status = .gateway_timeout, .expected = HttpError.ServerError }, + .{ .status = .bad_request, .expected = HttpError.InvalidResponse }, + }; + for (cases) |c| { + // On the non-ok path classifyResponse frees body + etag itself, + // so the test must not free them again. + const resp = Response{ + .status = c.status, + .body = try allocator.dupe(u8, "rejection body"), + .etag = try allocator.dupe(u8, "\"etag\""), + .allocator = allocator, + }; + try std.testing.expectError(c.expected, Client.classifyResponse(resp)); + } +} + +test "classifyResponse passes 200 OK through unchanged" { + const allocator = std.testing.allocator; + const resp = Response{ + .status = .ok, + .body = try allocator.dupe(u8, "payload"), + .etag = null, + .allocator = allocator, + }; + // On the ok path the body is not freed - the caller still owns it. + var out = try Client.classifyResponse(resp); + defer out.deinit(); + try std.testing.expectEqual(std.http.Status.ok, out.status); + try std.testing.expectEqualStrings("payload", out.body); +} + test "parseSha256Etag: quoted form" { const hex = parseSha256Etag("\"sha256:0402d084abcbd4e40993ebe1e55e0beb400ad77c8c5354a46b047c821e36d3b9\"") orelse unreachable; try std.testing.expectEqualStrings("0402d084abcbd4e40993ebe1e55e0beb400ad77c8c5354a46b047c821e36d3b9", hex);