delint/get basic async checks working
This commit is contained in:
parent
b3645a7bd1
commit
8860efb371
14 changed files with 388 additions and 74 deletions
|
|
@ -2,4 +2,4 @@
|
||||||
prek = "0.4.1"
|
prek = "0.4.1"
|
||||||
zig = "0.16.0"
|
zig = "0.16.0"
|
||||||
zls = "0.16.0"
|
zls = "0.16.0"
|
||||||
"ubi:DonIsaac/zlint" = "0.7.9"
|
"ubi:DonIsaac/zlint" = "0.8.1"
|
||||||
|
|
|
||||||
|
|
@ -23,14 +23,6 @@ repos:
|
||||||
entry: bash -c 'printf "%s\n" "$@" | zlint --deny-warnings --fix -S' --
|
entry: bash -c 'printf "%s\n" "$@" | zlint --deny-warnings --fix -S' --
|
||||||
language: system
|
language: system
|
||||||
types: [zig]
|
types: [zig]
|
||||||
# zlint 0.7.9 misparses Zig 0.16's `async`/`await` method
|
|
||||||
# names (Group.async, Future.await) as the legacy keywords,
|
|
||||||
# firing false-positive "syntax error" diagnostics. Until
|
|
||||||
# zlint catches up, exclude files that legitimately use the
|
|
||||||
# std.Io.Group async/await methods. `zig fmt` rewrites the
|
|
||||||
# `@"async"`/`@"await"` workaround back to the bare form,
|
|
||||||
# so we can't dodge it locally either.
|
|
||||||
exclude: ^src/(tui|PortfolioData|service)\.zig$
|
|
||||||
- repo: https://github.com/batmac/pre-commit-zig
|
- repo: https://github.com/batmac/pre-commit-zig
|
||||||
rev: v0.3.0
|
rev: v0.3.0
|
||||||
hooks:
|
hooks:
|
||||||
|
|
|
||||||
|
|
@ -68,6 +68,7 @@
|
||||||
//! `deinit()` cancels first, then frees.
|
//! `deinit()` cancels first, then frees.
|
||||||
|
|
||||||
const std = @import("std");
|
const std = @import("std");
|
||||||
|
const log = std.log.scoped(.portfolio_data);
|
||||||
|
|
||||||
const PortfolioData = @This();
|
const PortfolioData = @This();
|
||||||
|
|
||||||
|
|
@ -517,21 +518,21 @@ pub fn load(
|
||||||
defer watchlist_set.deinit();
|
defer watchlist_set.deinit();
|
||||||
var portfolio_set = std.StringHashMap(void).init(gpa);
|
var portfolio_set = std.StringHashMap(void).init(gpa);
|
||||||
defer portfolio_set.deinit();
|
defer portfolio_set.deinit();
|
||||||
for (syms) |s| portfolio_set.put(s, {}) catch {};
|
for (syms) |s| portfolio_set.put(s, {}) catch return error.OutOfMemory;
|
||||||
for (opts.watchlist_syms) |sym| {
|
for (opts.watchlist_syms) |sym| {
|
||||||
if (!portfolio_set.contains(sym)) watchlist_set.put(sym, {}) catch {};
|
if (!portfolio_set.contains(sym)) watchlist_set.put(sym, {}) catch return error.OutOfMemory;
|
||||||
}
|
}
|
||||||
for (pf.lots) |lot| {
|
for (pf.lots) |lot| {
|
||||||
if (lot.security_type == .watch) {
|
if (lot.security_type == .watch) {
|
||||||
const sym = lot.priceSymbol();
|
const sym = lot.priceSymbol();
|
||||||
if (!portfolio_set.contains(sym)) watchlist_set.put(sym, {}) catch {};
|
if (!portfolio_set.contains(sym)) watchlist_set.put(sym, {}) catch return error.OutOfMemory;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
var watch_syms_list: std.ArrayList([]const u8) = .empty;
|
var watch_syms_list: std.ArrayList([]const u8) = .empty;
|
||||||
defer watch_syms_list.deinit(gpa);
|
defer watch_syms_list.deinit(gpa);
|
||||||
{
|
{
|
||||||
var it = watchlist_set.keyIterator();
|
var it = watchlist_set.keyIterator();
|
||||||
while (it.next()) |k| watch_syms_list.append(gpa, k.*) catch {};
|
while (it.next()) |k| watch_syms_list.append(gpa, k.*) catch return error.OutOfMemory;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Fetch prices ──────────────────────────────────────────
|
// ── Fetch prices ──────────────────────────────────────────
|
||||||
|
|
@ -579,10 +580,16 @@ pub fn load(
|
||||||
const k = entry.key_ptr.*;
|
const k = entry.key_ptr.*;
|
||||||
const v = entry.value_ptr.*;
|
const v = entry.value_ptr.*;
|
||||||
if (portfolio_set.contains(k)) {
|
if (portfolio_set.contains(k)) {
|
||||||
prices.put(k, v) catch {};
|
prices.put(k, v) catch return error.OutOfMemory;
|
||||||
} else if (watchlist_set.contains(k)) {
|
} else if (watchlist_set.contains(k)) {
|
||||||
const owned = arena_alloc.dupe(u8, k) catch continue;
|
const owned = arena_alloc.dupe(u8, k) catch continue;
|
||||||
wp.put(owned, v) catch {};
|
wp.put(owned, v) catch |err| {
|
||||||
|
// Free the just-duped key... except it's
|
||||||
|
// arena-allocated, so the arena reaps it.
|
||||||
|
// Skip this watchlist price; a missing
|
||||||
|
// watchlist entry degrades display only.
|
||||||
|
log.warn("watchlist price put({s}): {t}", .{ k, err });
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
self.latest_quote_date = load_all.latest_date;
|
self.latest_quote_date = load_all.latest_date;
|
||||||
|
|
|
||||||
|
|
@ -104,26 +104,52 @@ pub const Check = struct {
|
||||||
run: *const fn (ctx: CheckCtx) CheckResult,
|
run: *const fn (ctx: CheckCtx) CheckResult,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Per-check execution state, lives on `CheckPanel`. Today every
|
/// Per-check execution state, lives on `CheckPanel`. Sync checks
|
||||||
/// check is sync so `state` is always `.complete` immediately after
|
/// are `.complete` immediately after `runChecks` returns;
|
||||||
/// `runChecks` returns. The async path is in place for future use.
|
/// `is_long_running` checks start `.pending` and transition to
|
||||||
|
/// `.complete` via `poll` or `awaitResult`.
|
||||||
pub const PendingCheck = struct {
|
pub const PendingCheck = struct {
|
||||||
check: *const Check,
|
check: *const Check,
|
||||||
|
/// Completion flag set by the async wrapper as its final
|
||||||
|
/// action. `poll` reads this to detect completion without
|
||||||
|
/// blocking — `std.Io.Future` itself only offers blocking
|
||||||
|
/// `await`/`cancel`, so the flag is what makes a
|
||||||
|
/// non-blocking probe possible. The tiny window between
|
||||||
|
/// flag-set and the future's internal result write is
|
||||||
|
/// covered by the (then nearly instant) `await` in `poll`.
|
||||||
|
done: std.atomic.Value(bool) = .init(false),
|
||||||
state: union(enum) {
|
state: union(enum) {
|
||||||
complete: CheckResult,
|
complete: CheckResult,
|
||||||
// Future: pending: std.Io.Future(CheckResult), populated
|
pending: std.Io.Future(CheckResult),
|
||||||
// by io.async() and resolved via poll/await. Adding a
|
|
||||||
// variant here is a non-breaking API change for current
|
|
||||||
// consumers.
|
|
||||||
},
|
},
|
||||||
|
|
||||||
/// Returns the resolved `CheckResult`, awaiting if necessary.
|
/// Returns the resolved `CheckResult`, awaiting (blocking) if
|
||||||
/// Idempotent.
|
/// necessary. Idempotent.
|
||||||
pub fn awaitResult(self: *PendingCheck, io: std.Io) CheckResult {
|
pub fn awaitResult(self: *PendingCheck, io: std.Io) CheckResult {
|
||||||
_ = io;
|
switch (self.state) {
|
||||||
return switch (self.state) {
|
.complete => |r| return r,
|
||||||
.complete => |r| r,
|
.pending => |*f| {
|
||||||
};
|
const r = f.await(io);
|
||||||
|
self.state = .{ .complete = r };
|
||||||
|
return r;
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Non-blocking completion probe. Returns true when the
|
||||||
|
/// result is available in `.complete` (transitioning it
|
||||||
|
/// there if the async task just finished). Returns false
|
||||||
|
/// while the task is still running.
|
||||||
|
pub fn poll(self: *PendingCheck, io: std.Io) bool {
|
||||||
|
switch (self.state) {
|
||||||
|
.complete => return true,
|
||||||
|
.pending => |*f| {
|
||||||
|
if (!self.done.load(.acquire)) return false;
|
||||||
|
const r = f.await(io);
|
||||||
|
self.state = .{ .complete = r };
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -132,23 +158,35 @@ pub const PendingCheck = struct {
|
||||||
/// per-finding strings inside each `CheckResult`).
|
/// per-finding strings inside each `CheckResult`).
|
||||||
pub const CheckPanel = struct {
|
pub const CheckPanel = struct {
|
||||||
allocator: std.mem.Allocator,
|
allocator: std.mem.Allocator,
|
||||||
|
io: std.Io,
|
||||||
pending: []PendingCheck,
|
pending: []PendingCheck,
|
||||||
|
|
||||||
pub fn deinit(self: *CheckPanel) void {
|
pub fn deinit(self: *CheckPanel) void {
|
||||||
for (self.pending) |pc| freeResult(self.allocator, pc.state.complete);
|
for (self.pending) |*pc| {
|
||||||
|
// Resolve any still-pending future before freeing.
|
||||||
|
// `cancel` requests cancelation and blocks until the
|
||||||
|
// task returns — the task may still produce a full
|
||||||
|
// result (checks don't hit cancelation points today),
|
||||||
|
// which we then free like any complete result.
|
||||||
|
const result = switch (pc.state) {
|
||||||
|
.complete => |r| r,
|
||||||
|
.pending => |*f| f.cancel(self.io),
|
||||||
|
};
|
||||||
|
freeResult(self.allocator, result);
|
||||||
|
}
|
||||||
self.allocator.free(self.pending);
|
self.allocator.free(self.pending);
|
||||||
self.* = undefined;
|
self.* = undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// True iff every check has a resolved result. Today always
|
/// True iff every check has a resolved result. Non-blocking:
|
||||||
/// returns `true` immediately after `runChecks` (sync only).
|
/// polls each pending check, transitioning newly-finished
|
||||||
pub fn isComplete(self: *const CheckPanel) bool {
|
/// ones to `.complete` as a side effect.
|
||||||
for (self.pending) |pc| {
|
pub fn isComplete(self: *CheckPanel) bool {
|
||||||
switch (pc.state) {
|
var all = true;
|
||||||
.complete => {},
|
for (self.pending) |*pc| {
|
||||||
}
|
if (!pc.poll(self.io)) all = false;
|
||||||
}
|
}
|
||||||
return true;
|
return all;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -175,25 +213,66 @@ fn freeObservations(a: std.mem.Allocator, obs: []const Observation) void {
|
||||||
|
|
||||||
// ── Runner ────────────────────────────────────────────────────
|
// ── Runner ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Async wrapper for long-running checks. Runs the check, then
|
||||||
|
/// sets the completion flag as the final action so `poll` can
|
||||||
|
/// detect the result without blocking.
|
||||||
|
///
|
||||||
|
/// IMPORTANT: `done` points into the panel's `pending` array,
|
||||||
|
/// and `ctx`'s borrowed slices (`rows`, `totals`) must outlive
|
||||||
|
/// the task. Both invariants are owned by `runChecks`'s caller
|
||||||
|
/// contract: the panel and the review view that owns ctx's rows
|
||||||
|
/// live until `CheckPanel.deinit`, which resolves all futures
|
||||||
|
/// before anything is freed.
|
||||||
|
fn runCheckTask(check: *const Check, ctx: CheckCtx, done: *std.atomic.Value(bool)) CheckResult {
|
||||||
|
const result = check.run(ctx);
|
||||||
|
done.store(true, .release);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
/// Run the registered checks against the given context. Returns a
|
/// Run the registered checks against the given context. Returns a
|
||||||
/// `CheckPanel` that renderers consume. Sync today; the contract
|
/// `CheckPanel` that renderers consume.
|
||||||
/// allows a future async dispatch path for `is_long_running` checks.
|
///
|
||||||
|
/// Sync checks (`is_long_running == false`) complete inline; the
|
||||||
|
/// panel entry is `.complete` on return. Long-running checks are
|
||||||
|
/// dispatched via `io.async` and start `.pending`; callers
|
||||||
|
/// resolve them with `PendingCheck.poll` (non-blocking, for
|
||||||
|
/// progressive TUI rendering) or `awaitResult` (blocking, for
|
||||||
|
/// CLI output).
|
||||||
|
///
|
||||||
|
/// Lifetime contract: `ctx.rows` / `ctx.totals` are borrowed by
|
||||||
|
/// in-flight async checks. The caller must keep them alive until
|
||||||
|
/// every pending check resolves — in practice both the panel and
|
||||||
|
/// the rows live on the same `ReviewView` and are torn down
|
||||||
|
/// together by `ReviewView.deinit` (panel first, which resolves
|
||||||
|
/// stragglers via cancel).
|
||||||
pub fn runChecks(
|
pub fn runChecks(
|
||||||
allocator: std.mem.Allocator,
|
allocator: std.mem.Allocator,
|
||||||
io: std.Io,
|
io: std.Io,
|
||||||
ctx: CheckCtx,
|
ctx: CheckCtx,
|
||||||
checks: []const Check,
|
checks: []const Check,
|
||||||
) !CheckPanel {
|
) !CheckPanel {
|
||||||
_ = io;
|
|
||||||
var pending = try allocator.alloc(PendingCheck, checks.len);
|
var pending = try allocator.alloc(PendingCheck, checks.len);
|
||||||
errdefer allocator.free(pending);
|
errdefer allocator.free(pending);
|
||||||
|
|
||||||
|
// Two passes: initialize every slot first so the `done`
|
||||||
|
// flags have stable addresses, THEN spawn the async tasks
|
||||||
|
// that point at them. (Spawning during the init loop would
|
||||||
|
// hand out pointers into an array we're still writing.)
|
||||||
for (checks, 0..) |*check, i| {
|
for (checks, 0..) |*check, i| {
|
||||||
const result = check.run(ctx);
|
pending[i] = .{ .check = check, .state = .{ .complete = .skipped } };
|
||||||
pending[i] = .{ .check = check, .state = .{ .complete = result } };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return .{ .allocator = allocator, .pending = pending };
|
for (checks, 0..) |*check, i| {
|
||||||
|
if (check.is_long_running) {
|
||||||
|
pending[i].state = .{
|
||||||
|
.pending = io.async(runCheckTask, .{ check, ctx, &pending[i].done }),
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
pending[i].state = .{ .complete = check.run(ctx) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return .{ .allocator = allocator, .io = io, .pending = pending };
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Threshold constants ───────────────────────────────────────
|
// ── Threshold constants ───────────────────────────────────────
|
||||||
|
|
@ -1001,6 +1080,9 @@ test "runChecks: produces a panel with one entry per check" {
|
||||||
var panel = try runChecks(testing.allocator, std.testing.io, ctx, &default_checks);
|
var panel = try runChecks(testing.allocator, std.testing.io, ctx, &default_checks);
|
||||||
defer panel.deinit();
|
defer panel.deinit();
|
||||||
try testing.expectEqual(default_checks.len, panel.pending.len);
|
try testing.expectEqual(default_checks.len, panel.pending.len);
|
||||||
|
// Await everything (drift is is_long_running and runs async);
|
||||||
|
// after awaiting, the panel must report complete.
|
||||||
|
for (panel.pending) |*pc| _ = pc.awaitResult(std.testing.io);
|
||||||
try testing.expect(panel.isComplete());
|
try testing.expect(panel.isComplete());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1012,12 +1094,82 @@ test "runChecks: empty portfolio every check passes or skips" {
|
||||||
};
|
};
|
||||||
var panel = try runChecks(testing.allocator, std.testing.io, ctx, &default_checks);
|
var panel = try runChecks(testing.allocator, std.testing.io, ctx, &default_checks);
|
||||||
defer panel.deinit();
|
defer panel.deinit();
|
||||||
for (panel.pending) |pc| {
|
for (panel.pending) |*pc| {
|
||||||
const tag = std.meta.activeTag(pc.state.complete);
|
const tag = std.meta.activeTag(pc.awaitResult(std.testing.io));
|
||||||
try testing.expect(tag == .pass or tag == .skipped);
|
try testing.expect(tag == .pass or tag == .skipped);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test "runChecks: async check resolves via poll without blocking forever" {
|
||||||
|
// Pin the async-dispatch contract: an is_long_running check
|
||||||
|
// starts pending and becomes complete via poll once its task
|
||||||
|
// finishes. We can't observe the intermediate pending state
|
||||||
|
// deterministically (the task may finish before we look),
|
||||||
|
// but we CAN assert the poll loop terminates and yields the
|
||||||
|
// right result.
|
||||||
|
const slow_check = [_]Check{.{
|
||||||
|
.name = "test_async",
|
||||||
|
.label = "Test async",
|
||||||
|
.is_long_running = true,
|
||||||
|
.run = struct {
|
||||||
|
fn run(c: CheckCtx) CheckResult {
|
||||||
|
_ = c;
|
||||||
|
return .pass;
|
||||||
|
}
|
||||||
|
}.run,
|
||||||
|
}};
|
||||||
|
const ctx: CheckCtx = .{
|
||||||
|
.allocator = testing.allocator,
|
||||||
|
.rows = &.{},
|
||||||
|
.totals = emptyTotals(),
|
||||||
|
};
|
||||||
|
var panel = try runChecks(testing.allocator, std.testing.io, ctx, &slow_check);
|
||||||
|
defer panel.deinit();
|
||||||
|
|
||||||
|
// Poll until complete (bounded loop; the task is trivially
|
||||||
|
// fast, so thousands of iterations would indicate a real
|
||||||
|
// hang — fail rather than spin forever).
|
||||||
|
var iterations: usize = 0;
|
||||||
|
while (!panel.isComplete()) : (iterations += 1) {
|
||||||
|
try testing.expect(iterations < 1_000_000);
|
||||||
|
}
|
||||||
|
try testing.expectEqual(CheckResult.pass, panel.pending[0].awaitResult(std.testing.io));
|
||||||
|
}
|
||||||
|
|
||||||
|
test "runChecks: deinit with unresolved async check does not leak or crash" {
|
||||||
|
// The panel may be torn down while a check is still pending
|
||||||
|
// (user quits the TUI mid-poll). deinit must cancel/resolve
|
||||||
|
// the future and free whatever result it produced.
|
||||||
|
const slow_check = [_]Check{.{
|
||||||
|
.name = "test_async_abandon",
|
||||||
|
.label = "Test abandon",
|
||||||
|
.is_long_running = true,
|
||||||
|
.run = struct {
|
||||||
|
fn run(c: CheckCtx) CheckResult {
|
||||||
|
// Allocate a real finding so deinit has something
|
||||||
|
// to free — exercises the result-ownership path.
|
||||||
|
const obs = c.allocator.alloc(Observation, 1) catch return .pass;
|
||||||
|
obs[0] = .{
|
||||||
|
.severity = .warn,
|
||||||
|
.kind = c.allocator.dupe(u8, "test_async_abandon") catch return .pass,
|
||||||
|
.target = c.allocator.dupe(u8, "TEST") catch return .pass,
|
||||||
|
.text = c.allocator.dupe(u8, "test finding") catch return .pass,
|
||||||
|
};
|
||||||
|
return .{ .warn = obs };
|
||||||
|
}
|
||||||
|
}.run,
|
||||||
|
}};
|
||||||
|
const ctx: CheckCtx = .{
|
||||||
|
.allocator = testing.allocator,
|
||||||
|
.rows = &.{},
|
||||||
|
.totals = emptyTotals(),
|
||||||
|
};
|
||||||
|
var panel = try runChecks(testing.allocator, std.testing.io, ctx, &slow_check);
|
||||||
|
// Deinit immediately — no poll, no await. testing.allocator
|
||||||
|
// catches any leak of the result allocations.
|
||||||
|
panel.deinit();
|
||||||
|
}
|
||||||
|
|
||||||
test "default_checks: every check name is unique" {
|
test "default_checks: every check name is unique" {
|
||||||
for (default_checks, 0..) |a, i| {
|
for (default_checks, 0..) |a, i| {
|
||||||
for (default_checks[i + 1 ..]) |b| {
|
for (default_checks[i + 1 ..]) |b| {
|
||||||
|
|
|
||||||
|
|
@ -46,7 +46,12 @@ pub fn writeFileAtomic(
|
||||||
});
|
});
|
||||||
errdefer {
|
errdefer {
|
||||||
tmp_file.close(io);
|
tmp_file.close(io);
|
||||||
std.Io.Dir.cwd().deleteFile(io, tmp_path) catch {};
|
// Best-effort cleanup of the temp file while unwinding
|
||||||
|
// the primary error; a failed delete leaves a stray
|
||||||
|
// .tmp that the next write overwrites.
|
||||||
|
std.Io.Dir.cwd().deleteFile(io, tmp_path) catch |err| {
|
||||||
|
std.log.debug("atomic write cleanup deleteFile({s}): {t}", .{ tmp_path, err });
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
try tmp_file.writeStreamingAll(io, bytes);
|
try tmp_file.writeStreamingAll(io, bytes);
|
||||||
|
|
@ -58,7 +63,11 @@ pub fn writeFileAtomic(
|
||||||
}
|
}
|
||||||
|
|
||||||
std.Io.Dir.cwd().rename(tmp_path, std.Io.Dir.cwd(), path, io) catch |err| {
|
std.Io.Dir.cwd().rename(tmp_path, std.Io.Dir.cwd(), path, io) catch |err| {
|
||||||
std.Io.Dir.cwd().deleteFile(io, tmp_path) catch {};
|
// Same best-effort cleanup as above; the rename failure is
|
||||||
|
// the error the caller needs to see.
|
||||||
|
std.Io.Dir.cwd().deleteFile(io, tmp_path) catch |del_err| {
|
||||||
|
std.log.debug("atomic write cleanup deleteFile({s}): {t}", .{ tmp_path, del_err });
|
||||||
|
};
|
||||||
return err;
|
return err;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -222,6 +222,14 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
|
||||||
);
|
);
|
||||||
defer view.deinit(allocator);
|
defer view.deinit(allocator);
|
||||||
|
|
||||||
|
// CLI is one-shot output — block until every async check
|
||||||
|
// resolves so the rendered grid + findings are complete.
|
||||||
|
// (The TUI renders progressively instead; see review_tab's
|
||||||
|
// tick hook.)
|
||||||
|
if (view.observations) |*panel| {
|
||||||
|
for (panel.pending) |*pc| _ = pc.awaitResult(io);
|
||||||
|
}
|
||||||
|
|
||||||
// Sort: explicit --sort overrides the default grouping.
|
// Sort: explicit --sort overrides the default grouping.
|
||||||
if (parsed.sort) |field| {
|
if (parsed.sort) |field| {
|
||||||
review_view.sortRows(view.rows, field, parsed.sort_dir);
|
review_view.sortRows(view.rows, field, parsed.sort_dir);
|
||||||
|
|
@ -359,7 +367,13 @@ fn renderStatusGrid(
|
||||||
var worst: u8 = 0; // 0=pass/skipped, 1=warn, 2=flag/err
|
var worst: u8 = 0; // 0=pass/skipped, 1=warn, 2=flag/err
|
||||||
var worst_color: [3]u8 = cli.CLR_MUTED;
|
var worst_color: [3]u8 = cli.CLR_MUTED;
|
||||||
for (panel.pending[i..end]) |pc| {
|
for (panel.pending[i..end]) |pc| {
|
||||||
const result = pc.state.complete;
|
// The CLI awaits every check before rendering (see
|
||||||
|
// run()), so .pending here would be a logic bug —
|
||||||
|
// skip defensively rather than crash.
|
||||||
|
const result = switch (pc.state) {
|
||||||
|
.complete => |r| r,
|
||||||
|
.pending => continue,
|
||||||
|
};
|
||||||
const rank: u8 = switch (result) {
|
const rank: u8 = switch (result) {
|
||||||
.pass, .skipped => 0,
|
.pass, .skipped => 0,
|
||||||
.warn => 1,
|
.warn => 1,
|
||||||
|
|
@ -390,12 +404,15 @@ fn renderStatusGrid(
|
||||||
}
|
}
|
||||||
try out.print("{s} ", .{label});
|
try out.print("{s} ", .{label});
|
||||||
|
|
||||||
const glyph: []const u8 = switch (pc.state.complete) {
|
const glyph: []const u8 = switch (pc.state) {
|
||||||
.pass => "✅\u{FE0F}",
|
.complete => |r| switch (r) {
|
||||||
.warn => "⚠️",
|
.pass => "✅\u{FE0F}",
|
||||||
.flag => "❌\u{FE0F}",
|
.warn => "⚠️",
|
||||||
.skipped => "➖\u{FE0F}",
|
.flag => "❌\u{FE0F}",
|
||||||
.err => "🛑\u{FE0F}",
|
.skipped => "➖\u{FE0F}",
|
||||||
|
.err => "🛑\u{FE0F}",
|
||||||
|
},
|
||||||
|
.pending => "⏳\u{FE0F}", // see defensive note above
|
||||||
};
|
};
|
||||||
try out.print("{s}", .{glyph});
|
try out.print("{s}", .{glyph});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -64,9 +64,14 @@ pub fn tryAcquire(self: *RateLimiter) bool {
|
||||||
/// Acquire a token, blocking (sleeping) until one is available.
|
/// Acquire a token, blocking (sleeping) until one is available.
|
||||||
pub fn acquire(self: *RateLimiter) void {
|
pub fn acquire(self: *RateLimiter) void {
|
||||||
while (!self.tryAcquire()) {
|
while (!self.tryAcquire()) {
|
||||||
// Sleep for the time needed to generate 1 token
|
// Sleep for the time needed to generate 1 token. An
|
||||||
|
// interrupted sleep (cancelation propagating through the
|
||||||
|
// Io) just loops back to tryAcquire — the next refill
|
||||||
|
// covers whatever fraction of the wait elapsed.
|
||||||
const wait_ns: u64 = @intFromFloat(1.0 / self.refill_rate_per_ns);
|
const wait_ns: u64 = @intFromFloat(1.0 / self.refill_rate_per_ns);
|
||||||
std.Io.sleep(self.io, .{ .nanoseconds = @intCast(wait_ns) }, .awake) catch {};
|
std.Io.sleep(self.io, .{ .nanoseconds = @intCast(wait_ns) }, .awake) catch |err| {
|
||||||
|
std.log.scoped(.rate_limiter).debug("acquire sleep interrupted: {t}", .{err});
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -74,7 +79,11 @@ pub fn acquire(self: *RateLimiter) void {
|
||||||
/// Use after receiving a server-side 429 to wait before retrying.
|
/// Use after receiving a server-side 429 to wait before retrying.
|
||||||
pub fn backoff(self: *RateLimiter) void {
|
pub fn backoff(self: *RateLimiter) void {
|
||||||
const wait_ns: u64 = @max(self.estimateWaitNs(), 2 * std.time.ns_per_s);
|
const wait_ns: u64 = @max(self.estimateWaitNs(), 2 * std.time.ns_per_s);
|
||||||
std.Io.sleep(self.io, .{ .nanoseconds = @intCast(wait_ns) }, .awake) catch {};
|
// Interrupted backoff sleep degrades to a shorter wait; the
|
||||||
|
// caller's retry may hit 429 again and re-backoff.
|
||||||
|
std.Io.sleep(self.io, .{ .nanoseconds = @intCast(wait_ns) }, .awake) catch |err| {
|
||||||
|
std.log.scoped(.rate_limiter).debug("backoff sleep interrupted: {t}", .{err});
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns estimated wait time in nanoseconds until a token is available.
|
/// Returns estimated wait time in nanoseconds until a token is available.
|
||||||
|
|
|
||||||
|
|
@ -28,9 +28,11 @@ pub fn Padded(comptime T: type) type {
|
||||||
// we can measure its length and pad. 64 bytes covers
|
// we can measure its length and pad. 64 bytes covers
|
||||||
// every realistic format-method output in this codebase
|
// every realistic format-method output in this codebase
|
||||||
// (Money's worst case is ~14 chars; Date is exactly 10).
|
// (Money's worst case is ~14 chars; Date is exactly 10).
|
||||||
|
// An inner value exceeding the buffer surfaces as
|
||||||
|
// WriteFailed to the caller rather than UB.
|
||||||
var tmp: [64]u8 = undefined;
|
var tmp: [64]u8 = undefined;
|
||||||
var fixed = std.Io.Writer.fixed(&tmp);
|
var fixed = std.Io.Writer.fixed(&tmp);
|
||||||
self.inner.format(&fixed) catch unreachable;
|
try self.inner.format(&fixed);
|
||||||
const text = fixed.buffered();
|
const text = fixed.buffered();
|
||||||
|
|
||||||
const pad = if (text.len >= self.width) 0 else self.width - text.len;
|
const pad = if (text.len >= self.width) 0 else self.width - text.len;
|
||||||
|
|
|
||||||
|
|
@ -742,7 +742,7 @@ fn gitInTestRepo(allocator: std.mem.Allocator, cwd: []const u8, argv: []const []
|
||||||
defer allocator.free(result.stderr);
|
defer allocator.free(result.stderr);
|
||||||
switch (result.term) {
|
switch (result.term) {
|
||||||
.exited => |code| if (code != 0) {
|
.exited => |code| if (code != 0) {
|
||||||
std.debug.print("git command failed (code {d}): {s}\nstderr: {s}\n", .{ code, std.mem.join(allocator, " ", argv) catch "?", result.stderr });
|
std.log.err("git command failed (code {d}): {s}\nstderr: {s}", .{ code, std.mem.join(allocator, " ", argv) catch "?", result.stderr });
|
||||||
return error.GitFailed;
|
return error.GitFailed;
|
||||||
},
|
},
|
||||||
else => return error.GitFailed,
|
else => return error.GitFailed,
|
||||||
|
|
|
||||||
|
|
@ -96,8 +96,11 @@ pub const Tiingo = struct {
|
||||||
) !CandleAndCorporateActions {
|
) !CandleAndCorporateActions {
|
||||||
var from_buf: [10]u8 = undefined;
|
var from_buf: [10]u8 = undefined;
|
||||||
var to_buf: [10]u8 = undefined;
|
var to_buf: [10]u8 = undefined;
|
||||||
const from_str = std.fmt.bufPrint(&from_buf, "{f}", .{from}) catch unreachable;
|
// Date's `{f}` output is exactly 10 bytes (YYYY-MM-DD), so
|
||||||
const to_str = std.fmt.bufPrint(&to_buf, "{f}", .{to}) catch unreachable;
|
// these cannot fail in practice; `try` instead of
|
||||||
|
// `catch unreachable` to keep the error path honest.
|
||||||
|
const from_str = try std.fmt.bufPrint(&from_buf, "{f}", .{from});
|
||||||
|
const to_str = try std.fmt.bufPrint(&to_buf, "{f}", .{to});
|
||||||
|
|
||||||
const symbol_url = try std.fmt.allocPrint(allocator, base_url ++ "/{s}/prices", .{symbol});
|
const symbol_url = try std.fmt.allocPrint(allocator, base_url ++ "/{s}/prices", .{symbol});
|
||||||
defer allocator.free(symbol_url);
|
defer allocator.free(symbol_url);
|
||||||
|
|
|
||||||
|
|
@ -52,8 +52,11 @@ pub const TwelveData = struct {
|
||||||
|
|
||||||
var from_buf: [10]u8 = undefined;
|
var from_buf: [10]u8 = undefined;
|
||||||
var to_buf: [10]u8 = undefined;
|
var to_buf: [10]u8 = undefined;
|
||||||
const from_str = std.fmt.bufPrint(&from_buf, "{f}", .{from}) catch unreachable;
|
// Date's `{f}` output is exactly 10 bytes (YYYY-MM-DD), so
|
||||||
const to_str = std.fmt.bufPrint(&to_buf, "{f}", .{to}) catch unreachable;
|
// these cannot fail in practice; `try` instead of
|
||||||
|
// `catch unreachable` to keep the error path honest.
|
||||||
|
const from_str = try std.fmt.bufPrint(&from_buf, "{f}", .{from});
|
||||||
|
const to_str = try std.fmt.bufPrint(&to_buf, "{f}", .{to});
|
||||||
|
|
||||||
// TwelveData's max outputsize is 5000 data points per request.
|
// TwelveData's max outputsize is 5000 data points per request.
|
||||||
// For daily candles this covers ~20 years of trading days (~252/year),
|
// For daily candles this covers ~20 years of trading days (~252/year),
|
||||||
|
|
|
||||||
44
src/tui.zig
44
src/tui.zig
|
|
@ -468,6 +468,19 @@ pub const App = struct {
|
||||||
/// observation engine async dispatch, etc.) has a wall-clock-
|
/// observation engine async dispatch, etc.) has a wall-clock-
|
||||||
/// independent tick to react to.
|
/// independent tick to react to.
|
||||||
frame: u64 = 0,
|
frame: u64 = 0,
|
||||||
|
/// True when the active tab has async work in flight that needs
|
||||||
|
/// poll-driven redraws (e.g. review's observation panel with
|
||||||
|
/// pending async checks). Set by the tab (loadData / activate);
|
||||||
|
/// cleared by the tab when the work completes or the tab
|
||||||
|
/// deactivates. While set, the event handler keeps a one-shot
|
||||||
|
/// vxfw Tick timer armed so the draw loop wakes up to poll —
|
||||||
|
/// without it, vxfw sleeps until the next user event and async
|
||||||
|
/// results would sit invisible until a keypress.
|
||||||
|
wants_poll_tick: bool = false,
|
||||||
|
/// True while a vxfw Tick command is scheduled and not yet
|
||||||
|
/// delivered. Prevents stacking duplicate timers (each event
|
||||||
|
/// would otherwise arm another).
|
||||||
|
poll_tick_armed: bool = false,
|
||||||
|
|
||||||
has_explicit_symbol: bool = false, // true if -s was used
|
has_explicit_symbol: bool = false, // true if -s was used
|
||||||
|
|
||||||
|
|
@ -519,6 +532,27 @@ pub const App = struct {
|
||||||
|
|
||||||
fn typeErasedEventHandler(ptr: *anyopaque, ctx: *vaxis.vxfw.EventContext, event: vaxis.vxfw.Event) anyerror!void {
|
fn typeErasedEventHandler(ptr: *anyopaque, ctx: *vaxis.vxfw.EventContext, event: vaxis.vxfw.Event) anyerror!void {
|
||||||
const self: *App = @ptrCast(@alignCast(ptr));
|
const self: *App = @ptrCast(@alignCast(ptr));
|
||||||
|
// Poll-driven redraw support: while a tab has async work
|
||||||
|
// in flight (`wants_poll_tick`), keep a one-shot vxfw
|
||||||
|
// Tick timer armed. The timer fires a `.tick` event,
|
||||||
|
// which requests a redraw; the draw pass runs the active
|
||||||
|
// tab's `tick` hook, which polls the async work and
|
||||||
|
// clears `wants_poll_tick` when done. Re-armed here on
|
||||||
|
// every event (including the .tick itself) until the tab
|
||||||
|
// clears the flag. 100ms cadence — fast enough to feel
|
||||||
|
// responsive, slow enough to be invisible in CPU terms.
|
||||||
|
defer if (self.wants_poll_tick and !self.poll_tick_armed) {
|
||||||
|
if (ctx.tick(100, self.widget())) {
|
||||||
|
self.poll_tick_armed = true;
|
||||||
|
} else |err| {
|
||||||
|
// OOM appending the Tick command. Leave
|
||||||
|
// poll_tick_armed false so the next event retries
|
||||||
|
// the arm; worst case the poll degrades to
|
||||||
|
// event-driven (pre-timer behavior), not a
|
||||||
|
// permanently stuck spinner.
|
||||||
|
std.log.scoped(.tui).warn("poll tick arm failed: {t}", .{err});
|
||||||
|
}
|
||||||
|
};
|
||||||
switch (event) {
|
switch (event) {
|
||||||
.key_press => |key| {
|
.key_press => |key| {
|
||||||
// Tab-level pre-empt. The active tab gets first
|
// Tab-level pre-empt. The active tab gets first
|
||||||
|
|
@ -553,6 +587,16 @@ pub const App = struct {
|
||||||
.init => {
|
.init => {
|
||||||
self.loadTabData();
|
self.loadTabData();
|
||||||
},
|
},
|
||||||
|
.tick => {
|
||||||
|
// Our scheduled poll timer fired. Mark it
|
||||||
|
// un-armed (the defer above re-arms if the tab
|
||||||
|
// still wants polling) and request a redraw so
|
||||||
|
// the draw pass runs the tab's tick hook.
|
||||||
|
self.poll_tick_armed = false;
|
||||||
|
if (self.wants_poll_tick) {
|
||||||
|
ctx.redraw = true;
|
||||||
|
}
|
||||||
|
},
|
||||||
else => {},
|
else => {},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -142,6 +142,13 @@ pub const State = struct {
|
||||||
/// Whether already-acknowledged findings are rendered. Toggled
|
/// Whether already-acknowledged findings are rendered. Toggled
|
||||||
/// by `toggle_show_acked` (`v` key by default).
|
/// by `toggle_show_acked` (`v` key by default).
|
||||||
show_acked: bool = false,
|
show_acked: bool = false,
|
||||||
|
/// True while the observation panel has unresolved async
|
||||||
|
/// checks. Set when `loadData` builds a view whose panel
|
||||||
|
/// isn't complete; cleared by `tick` once every check
|
||||||
|
/// resolves (at which point findings_view is rebuilt to
|
||||||
|
/// include the late results). Keeps the per-frame tick poll
|
||||||
|
/// cost at zero for the common all-sync case.
|
||||||
|
observations_pending: bool = false,
|
||||||
|
|
||||||
// ── Single-cursor (single-sheet) navigation ──────────────
|
// ── Single-cursor (single-sheet) navigation ──────────────
|
||||||
|
|
||||||
|
|
@ -253,6 +260,10 @@ pub const tab = struct {
|
||||||
|
|
||||||
pub fn activate(state: *State, app: *App) !void {
|
pub fn activate(state: *State, app: *App) !void {
|
||||||
if (tab.isDisabled(app)) return;
|
if (tab.isDisabled(app)) return;
|
||||||
|
// Re-arm the poll timer if we still have async checks in
|
||||||
|
// flight from a previous activation (user switched away
|
||||||
|
// and back while a check was running).
|
||||||
|
if (state.observations_pending) app.wants_poll_tick = true;
|
||||||
if (state.loaded) return;
|
if (state.loaded) return;
|
||||||
// Load the journal before building findings; `loadData`
|
// Load the journal before building findings; `loadData`
|
||||||
// joins both into `findings_view`.
|
// joins both into `findings_view`.
|
||||||
|
|
@ -260,7 +271,16 @@ pub const tab = struct {
|
||||||
loadData(state, app);
|
loadData(state, app);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub const deactivate = framework.noopDeactivate(State);
|
/// Pause poll-driven redraws while this tab is inactive. The
|
||||||
|
/// tick hook only dispatches to the ACTIVE tab, so leaving
|
||||||
|
/// `wants_poll_tick` set after switching away would spin the
|
||||||
|
/// 100ms timer with nobody polling. `activate` re-arms when
|
||||||
|
/// the user returns; the async check keeps running in the
|
||||||
|
/// background either way.
|
||||||
|
pub fn deactivate(state: *State, app: *App) void {
|
||||||
|
_ = state;
|
||||||
|
app.wants_poll_tick = false;
|
||||||
|
}
|
||||||
|
|
||||||
/// Manual refresh: drops the cached view and re-builds. Also
|
/// Manual refresh: drops the cached view and re-builds. Also
|
||||||
/// invalidates the shared `account_map` and `classification_map`
|
/// invalidates the shared `account_map` and `classification_map`
|
||||||
|
|
@ -280,7 +300,29 @@ pub const tab = struct {
|
||||||
loadData(state, app);
|
loadData(state, app);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub const tick = framework.noopTick(State);
|
/// Poll in-flight async observation checks. No-op (one bool
|
||||||
|
/// test) unless `loadData` left the panel incomplete. When
|
||||||
|
/// the last pending check resolves, rebuild the findings
|
||||||
|
/// view so late findings appear, and clear both the local
|
||||||
|
/// flag and the App-level poll-tick request so subsequent
|
||||||
|
/// frames cost nothing.
|
||||||
|
///
|
||||||
|
/// Redraw cadence: while `app.wants_poll_tick` is set, the
|
||||||
|
/// App keeps a 100ms vxfw Tick timer armed (see
|
||||||
|
/// `typeErasedEventHandler`), so this hook gets called
|
||||||
|
/// even when the user isn't generating events. Without
|
||||||
|
/// that timer, async results would sit invisible until
|
||||||
|
/// the next keypress.
|
||||||
|
pub fn tick(state: *State, app: *App, frame: u64) void {
|
||||||
|
_ = frame;
|
||||||
|
if (!state.observations_pending) return;
|
||||||
|
const view = if (state.view) |*v| v else return;
|
||||||
|
const panel = if (view.observations) |*p| p else return;
|
||||||
|
if (!panel.isComplete()) return;
|
||||||
|
state.observations_pending = false;
|
||||||
|
app.wants_poll_tick = false;
|
||||||
|
rebuildFindingsView(state, app);
|
||||||
|
}
|
||||||
|
|
||||||
pub fn handleAction(state: *State, app: *App, action: Action) void {
|
pub fn handleAction(state: *State, app: *App, action: Action) void {
|
||||||
switch (action) {
|
switch (action) {
|
||||||
|
|
@ -954,6 +996,20 @@ fn loadData(state: *State, app: *App) void {
|
||||||
|
|
||||||
applySort(state);
|
applySort(state);
|
||||||
rebuildFindingsView(state, app);
|
rebuildFindingsView(state, app);
|
||||||
|
|
||||||
|
// If any check is still running (async dispatch), arm the
|
||||||
|
// tick-poll so the findings view refreshes when results
|
||||||
|
// land. `isComplete` also transitions newly-finished checks,
|
||||||
|
// so a fast async check that finished during buildReview is
|
||||||
|
// caught right here without waiting for the next frame.
|
||||||
|
// `wants_poll_tick` tells the App to keep a vxfw Tick timer
|
||||||
|
// armed so the poll runs even with no user input.
|
||||||
|
if (state.view) |*v| {
|
||||||
|
if (v.observations) |*panel| {
|
||||||
|
state.observations_pending = !panel.isComplete();
|
||||||
|
if (state.observations_pending) app.wants_poll_tick = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolve the absolute path to `acknowledgments.srf` next to the
|
/// Resolve the absolute path to `acknowledgments.srf` next to the
|
||||||
|
|
@ -1462,11 +1518,15 @@ fn appendStatusGrid(
|
||||||
// Promote the row's worst-severity color so multi-cell rows
|
// Promote the row's worst-severity color so multi-cell rows
|
||||||
// with mixed states still draw the user's eye to the bad
|
// with mixed states still draw the user's eye to the bad
|
||||||
// ones. Order from best to worst: pass < skipped < warn <
|
// ones. Order from best to worst: pass < skipped < warn <
|
||||||
// flag/err.
|
// flag/err. Pending checks rank with pass (no news isn't
|
||||||
|
// bad news yet).
|
||||||
var row_style = th.mutedStyle();
|
var row_style = th.mutedStyle();
|
||||||
var worst: u8 = 0; // 0=pass/skipped, 1=warn, 2=flag/err
|
var worst: u8 = 0; // 0=pass/skipped/pending, 1=warn, 2=flag/err
|
||||||
for (panel.pending[i..end]) |pc| {
|
for (panel.pending[i..end]) |pc| {
|
||||||
const result = pc.state.complete;
|
const result = switch (pc.state) {
|
||||||
|
.complete => |r| r,
|
||||||
|
.pending => continue,
|
||||||
|
};
|
||||||
const rank: u8 = switch (result) {
|
const rank: u8 = switch (result) {
|
||||||
.pass, .skipped => 0,
|
.pass, .skipped => 0,
|
||||||
.warn => 1,
|
.warn => 1,
|
||||||
|
|
@ -1482,7 +1542,11 @@ fn appendStatusGrid(
|
||||||
try text.appendSlice(arena, " ");
|
try text.appendSlice(arena, " ");
|
||||||
for (panel.pending[i..end], 0..) |pc, col| {
|
for (panel.pending[i..end], 0..) |pc, col| {
|
||||||
if (col > 0) try text.appendSlice(arena, " ");
|
if (col > 0) try text.appendSlice(arena, " ");
|
||||||
try appendStatusCell(arena, &text, pc.check.label, pc.state.complete);
|
const result: ?observations.CheckResult = switch (pc.state) {
|
||||||
|
.complete => |r| r,
|
||||||
|
.pending => null,
|
||||||
|
};
|
||||||
|
try appendStatusCell(arena, &text, pc.check.label, result);
|
||||||
}
|
}
|
||||||
try lines.append(arena, .{ .text = try text.toOwnedSlice(arena), .style = row_style });
|
try lines.append(arena, .{ .text = try text.toOwnedSlice(arena), .style = row_style });
|
||||||
|
|
||||||
|
|
@ -1492,12 +1556,14 @@ fn appendStatusGrid(
|
||||||
|
|
||||||
/// Append one cell's bytes to `text`: right-padded label + space
|
/// Append one cell's bytes to `text`: right-padded label + space
|
||||||
/// + glyph. With ASCII glyphs (always 1 col), the cell ends at
|
/// + glyph. With ASCII glyphs (always 1 col), the cell ends at
|
||||||
/// exactly `status_cell_cols` display columns.
|
/// exactly `status_cell_cols` display columns. A `null` result
|
||||||
|
/// means the check is still running (async dispatch in flight);
|
||||||
|
/// rendered as an hourglass.
|
||||||
fn appendStatusCell(
|
fn appendStatusCell(
|
||||||
arena: std.mem.Allocator,
|
arena: std.mem.Allocator,
|
||||||
text: *std.ArrayList(u8),
|
text: *std.ArrayList(u8),
|
||||||
label: []const u8,
|
label: []const u8,
|
||||||
result: observations.CheckResult,
|
result: ?observations.CheckResult,
|
||||||
) !void {
|
) !void {
|
||||||
const lbl_cols = label.len; // ASCII labels: byte count == display cols
|
const lbl_cols = label.len; // ASCII labels: byte count == display cols
|
||||||
if (lbl_cols < status_label_cols) {
|
if (lbl_cols < status_label_cols) {
|
||||||
|
|
@ -1505,7 +1571,11 @@ fn appendStatusCell(
|
||||||
}
|
}
|
||||||
try text.appendSlice(arena, label);
|
try text.appendSlice(arena, label);
|
||||||
try text.append(arena, ' ');
|
try text.append(arena, ' ');
|
||||||
try text.appendSlice(arena, checkStatusGlyph(result));
|
if (result) |r| {
|
||||||
|
try text.appendSlice(arena, checkStatusGlyph(r));
|
||||||
|
} else {
|
||||||
|
try text.appendSlice(arena, "⏳\u{FE0F}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Append the findings section to `lines`. Layout:
|
/// Append the findings section to `lines`. Layout:
|
||||||
|
|
@ -3102,6 +3172,7 @@ test "appendStatusGrid: one row per status_cells_per_row checks" {
|
||||||
};
|
};
|
||||||
const panel: observations.CheckPanel = .{
|
const panel: observations.CheckPanel = .{
|
||||||
.allocator = testing.allocator,
|
.allocator = testing.allocator,
|
||||||
|
.io = std.testing.io,
|
||||||
.pending = &pending,
|
.pending = &pending,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -3128,6 +3199,7 @@ test "appendStatusGrid: empty panel produces no lines" {
|
||||||
|
|
||||||
const panel: observations.CheckPanel = .{
|
const panel: observations.CheckPanel = .{
|
||||||
.allocator = testing.allocator,
|
.allocator = testing.allocator,
|
||||||
|
.io = std.testing.io,
|
||||||
.pending = &.{},
|
.pending = &.{},
|
||||||
};
|
};
|
||||||
var lines: std.ArrayList(StyledLine) = .empty;
|
var lines: std.ArrayList(StyledLine) = .empty;
|
||||||
|
|
|
||||||
|
|
@ -102,6 +102,10 @@ pub fn build(
|
||||||
.flag => |o| o,
|
.flag => |o| o,
|
||||||
.pass, .skipped, .err => continue,
|
.pass, .skipped, .err => continue,
|
||||||
},
|
},
|
||||||
|
// Still running — no findings to show yet. The caller
|
||||||
|
// rebuilds the view when the panel completes (TUI
|
||||||
|
// polls via tick; CLI awaits before building).
|
||||||
|
.pending => continue,
|
||||||
};
|
};
|
||||||
|
|
||||||
for (obs_slice) |obs| {
|
for (obs_slice) |obs| {
|
||||||
|
|
@ -208,7 +212,7 @@ fn makePanel(allocator: std.mem.Allocator, obs_slice: []const Observation) !Chec
|
||||||
.check = &check_singleton.c,
|
.check = &check_singleton.c,
|
||||||
.state = .{ .complete = .{ .warn = owned_obs } },
|
.state = .{ .complete = .{ .warn = owned_obs } },
|
||||||
};
|
};
|
||||||
return .{ .allocator = allocator, .pending = pending };
|
return .{ .allocator = allocator, .io = std.testing.io, .pending = pending };
|
||||||
}
|
}
|
||||||
|
|
||||||
fn makeJournalWithAck(
|
fn makeJournalWithAck(
|
||||||
|
|
@ -374,7 +378,7 @@ test "build: pass/skipped/err checks contribute no rows" {
|
||||||
.check = &check_singleton.c,
|
.check = &check_singleton.c,
|
||||||
.state = .{ .complete = .pass },
|
.state = .{ .complete = .pass },
|
||||||
};
|
};
|
||||||
var panel = CheckPanel{ .allocator = testing.allocator, .pending = pending };
|
var panel = CheckPanel{ .allocator = testing.allocator, .io = std.testing.io, .pending = pending };
|
||||||
defer panel.deinit();
|
defer panel.deinit();
|
||||||
|
|
||||||
var journal = try makeEmptyJournal(testing.allocator);
|
var journal = try makeEmptyJournal(testing.allocator);
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue