src/tui/predict.zig
Ref: Size: 59.5 KiB History
//! Speculative local echo, as an OVERLAY: predictions live in a queue beside
//! the replica and never enter it, so a wrong guess costs a repaint and never a
//! desync. Engine-free — `reconcile` takes its grid duck-typed.
//!
//! Judgment is about EVIDENCE, not arrival order: a frame showing the predicted
//! cell unchanged was probably built before the keystroke arrived, so the
//! prediction stays pending. Only a cell that moved to something that is
//! neither our guess nor what was there refutes.
//!
//! The tiers describe ECHO bits: readline echoes itself, so a shell prompt is
//! `.adaptive` and never `.always`. Predictions COPY bytes and the queue is
//! read by index — a slice goes stale on the next append.
const std = @import("std");
const proto = @import("term").protocol;
/// One predicted character at one place on the grid. Printable ASCII only,
/// deliberately: `ch` is a byte by value, so there is nothing here that can
/// outlive what it was copied from.
pub const Cell = struct { row: u16, col: u16, ch: u8 };
const Pred = struct {
cell: Cell,
/// What the cell showed when the prediction was made. The load-bearing
/// field of reconcile v2: a cell that STILL shows this is a cell the
/// authoritative stream has said nothing about yet, which is a
/// different thing from one that disagrees with us.
prev_ch: u8,
/// The authoritative seq the client held when this was predicted. A
/// frame carrying a HIGHER seq is the first one that could possibly
/// have been built after the keystroke reached the daemon, and so the
/// first one entitled to have an opinion about it.
made_seq: u64,
/// Wall clock at prediction time, supplied by the caller — the module
/// never reads a clock, so every deadline in here is testable.
made_ms: i64,
/// Judging frames that have looked at this cell and found it unchanged.
/// Bounded by expire_after_frames, because "no evidence yet" must not
/// be a state a prediction can sit in forever.
frames: u8 = 0,
/// Whether this prediction has ever been drawn. Not the same question as
/// whether the overlay is confident now: one queued while unconfident
/// and painted later, after a promotion, has reached the screen exactly
/// once and must be counted exactly once.
painted: bool = false,
};
/// Units are mixed on purpose and stated per field: `contradicted` counts
/// EVENTS while `made` counts PREDICTIONS, so `confirmed + contradicted` totals
/// nothing. `made == confirmed + abandoned + pending` is the identity that
/// holds, and `abandoned` exists so it can.
pub const Counters = struct {
/// PER PREDICTION: queued, whether or not it was ever shown.
made: u64 = 0,
/// PER PREDICTION: ever reached the screen, counted the first time it is
/// painted — whether that was when it was made or later, when a promotion
/// made an already-queued one visible. Never exceeds `made`.
displayed: u64 = 0,
/// PER PREDICTION: retired because the authoritative grid agreed.
confirmed: u64 = 0,
/// PER EVENT: one refutation or expiry, however many predictions it
/// discarded — a contradiction takes the whole queue, so this counts
/// how often we were wrong, not how much was thrown away.
contradicted: u64 = 0,
/// PER EVENT: the subset of `contradicted` where nothing ever answered
/// rather than something disagreed. Counted in both, so a reader can tell
/// "we guessed wrong" apart from "the application went quiet".
expired: u64 = 0,
/// PER PREDICTION: queued but discarded without a verdict — by a
/// contradiction, an expiry, or a flush. The flush case is counted
/// nowhere else, which is what used to leave predictions unaccounted
/// for: made, never confirmed, and no number saying where they went.
abandoned: u64 = 0,
/// PER INPUT: keystrokes declined for prediction, BY WHOEVER MADE THE CALL.
/// Most are `predictAt`'s own refusals, but the client increments this
/// directly for input it declines to offer at all — a paste, whose lead byte
/// is printable. So this is NOT "times `predictAt` said no", and none of
/// these became predictions.
suppressed: u64 = 0,
/// PER PREDICTION: queued with display earned, and hidden anyway
/// because the path was measured too fast to show it. A subset of
/// `made` and disjoint from `displayed`; the number that says whether
/// the local gate is what is keeping an overlay quiet.
local: u64 = 0,
};
/// What the pty's mode bits say about predicting here.
/// .always — icanon && echo: the line discipline prints the character
/// itself, so predicting it is deduction.
/// .never — icanon && !echo: a password prompt.
/// .adaptive — !icanon: raw mode, where the application decides what a
/// keystroke looks like and the guess must be earned.
const Context = enum { always, never, adaptive };
/// Consecutive confirmations that earn display in `.adaptive`.
const promote_after: u8 = 2;
/// How many judging frames a prediction may go unanswered before it is given up
/// on — the phantom-glyph guard. An application that consumes a keystroke and
/// repaints some OTHER row leaves the predicted cell untouched forever.
const expire_after_frames: u8 = 8;
/// The same guard in wall time, for the case the frame bound cannot catch:
/// the application answers by going quiet. Milliseconds.
const expire_after_ms: i64 = 1000;
/// The round trip below which a prediction is never worth SHOWING. On a local
/// socket a confirm lands inside the frame the keystroke was painted in, so a
/// correct prediction is invisible by construction and the only ones the eye
/// catches are the wrong ones, underlined until they expire.
///
/// Two triggers rather than one, with the smoothed estimate moving between
/// them, so jitter around the line does not flap the overlay per keystroke. The
/// 20/30 pair is mosh's; the smoothing is TCP's 1/8.
const local_below_ms: i64 = 20;
const local_above_ms: i64 = 30;
/// Engine-free mirror of the engine's cursor position.
pub const CursorPos = struct { x: u16 = 0, y: u16 = 0 };
/// A struct, not four positional arguments: `ch` and `prev_ch` are
/// adjacent bytes, so transposing them compiles silently and turns every
/// prediction into a no-op or a wrong guess.
const Keystroke = struct {
cursor: CursorPos,
/// The byte the user typed.
ch: u8,
/// What the client's replica shows at that cell right now. The caller
/// reads it; the overlay stays engine-free.
prev_ch: u8,
now_ms: i64,
};
pub const Outcome = union(enum) {
/// Refused. Nothing queued, nothing painted; the keystroke still goes
/// to the daemon exactly as it would have.
suppressed,
/// Queued, and to be painted at this cell now.
display: Cell,
/// Queued but deliberately invisible — adaptive mode gathering the evidence
/// that lets the next one be seen. The paint decision arrives WITH the cell
/// rather than as a question the caller must remember to ask.
hidden: Cell,
};
pub const Verdict = enum {
/// Nothing pending was old enough to judge.
none,
/// At least one prediction was confirmed and retired; none were wrong.
confirmed,
/// One was wrong, so the queue is empty and the caller should repaint.
contradicted,
};
/// Reads cells out of a plain grid dump: rows joined by '\n', trailing
/// blanks absent, which is the shape `Engine.dumpPlain` produces. Holds a
/// borrowed slice and is meant to be built, used and dropped inside one
/// reconcile call — never stored.
pub const PlainGrid = struct {
text: []const u8,
cols: u16,
/// null means "outside the grid", not "blank": a dump carries no
/// trailing blanks, so past a row's end or the dump's end is blank. A
/// multi-byte cell answers with its lead byte, which can never equal a
/// predicted printable ASCII byte — so it contradicts, the safe way.
pub fn cellChar(self: PlainGrid, row: u16, col: u16) ?u8 {
if (col >= self.cols) return null;
var y: u16 = 0;
var it = std.mem.splitScalar(u8, self.text, '\n');
while (it.next()) |line| : (y += 1) {
if (y != row) continue;
if (col >= line.len) return ' ';
return line[col];
}
return ' ';
}
};
pub const Overlay = struct {
alloc: std.mem.Allocator,
pending: std.ArrayList(Pred) = .empty,
ctx: Context = .never,
/// What the POLICY says about painting: true in `.always`, false in
/// `.never`, and earned in `.adaptive`. Not the gate itself any more —
/// that is `visible`, which also asks whether the path is worth it.
confident: bool = false,
streak: u8 = 0,
/// Smoothed keystroke-to-confirm round trip, or null until the first
/// confirm has measured one. Counted from the keystroke's own clock to
/// the frame that confirmed it, so it is the whole path — tty read,
/// wire, daemon, pty, engine, wire back — and not a transport ping.
srtt_ms: ?i64 = null,
/// The path is too fast to show predictions on. Moves only when
/// `srtt_ms` crosses a trigger, so it carries the hysteresis; an
/// unmeasured path is not local, which keeps a fresh WAN attach on the
/// behaviour the numbers in decisions.md were measured against.
local: bool = false,
counters: Counters = .{},
cols: u16,
rows: u16,
scroll_mode: bool = false,
resize_pending: bool = false,
/// The last mode byte adopted, or null before any pty_mode frame has
/// arrived. The raw byte rather than the derived context, because the
/// churn rule is about the BITS moving: a change inside one tier is
/// still an application taking the terminal somewhere else.
mode_byte: ?u8 = null,
/// The last authoritative seq the client applied; stamped onto each new
/// prediction as `made_seq`.
last_seq: u64 = 0,
pub fn init(alloc: std.mem.Allocator, cols: u16, rows: u16) Overlay {
return .{ .alloc = alloc, .cols = cols, .rows = rows };
}
pub fn deinit(self: *Overlay) void {
self.pending.deinit(self.alloc);
}
/// Adopt what the daemon says the pty is doing. No frame ever arriving
/// leaves the overlay at `.never`, the safe default an old daemon gets free.
///
/// ANY change to the bits flushes the queue and un-earns display, even one
/// within the same tier: predictions in flight across a mode transition are
/// unverifiable, and confidence under one line discipline is not evidence
/// about the next. Re-sending the SAME bits is not a change.
pub fn setMode(self: *Overlay, flags: proto.PtyModeFlags) void {
const byte: u8 = @bitCast(flags);
if (self.mode_byte) |prev| {
if (prev == byte) return;
}
self.mode_byte = byte;
const next: Context = blk: {
// A bit we do not understand means the byte describes a
// terminal we cannot reason about. Predict nothing rather than
// mask it off and carry on as though we had understood.
if (flags._pad != 0) break :blk .never;
if (!flags.icanon) break :blk .adaptive;
break :blk if (flags.echo) .always else .never;
};
self.ctx = next;
// Outstanding predictions were made under the old line discipline,
// and the new one may be that they should never have been visible.
self.flush();
self.streak = 0;
self.confident = (next == .always);
}
/// A resize invalidates every prediction on the old grid, and moves the
/// edge that the last-column refusal is measured against.
pub fn setGrid(self: *Overlay, cols: u16, rows: u16) void {
if (cols == self.cols and rows == self.rows) return;
self.cols = cols;
self.rows = rows;
self.flush();
}
pub fn setScrollMode(self: *Overlay, on: bool) void {
if (on == self.scroll_mode) return;
self.scroll_mode = on;
// Entering scroll mode the cursor stops being where the user is
// looking; leaving it, the whole viewport is repainted. Either way
// what is queued no longer describes the screen.
self.flush();
}
pub fn setResizePending(self: *Overlay, pending: bool) void {
if (pending == self.resize_pending) return;
self.resize_pending = pending;
if (pending) self.flush();
}
/// For a frame applied without judging it — a snapshot.
pub fn noteSeq(self: *Overlay, seq: u64) void {
self.last_seq = seq;
}
/// Speculate one printable byte at the cursor, or refuse to. Every refusal
/// is a place where being wrong costs more than being slow: a control byte,
/// a multi-byte sequence, the last column, a scrolled viewport, a pending
/// resize, a `.never` context. Infallible by construction — an allocation
/// failure suppresses rather than propagating.
pub fn predictAt(self: *Overlay, k: Keystroke) Outcome {
if (self.ctx == .never) return self.suppress();
if (self.scroll_mode or self.resize_pending) return self.suppress();
if (k.ch < 0x20 or k.ch >= 0x7f) return self.suppress();
if (self.cols == 0 or self.rows == 0) return self.suppress();
if (k.cursor.y >= self.rows) return self.suppress();
if (k.cursor.x >= self.cols -| 1) return self.suppress();
// The glyph is already there. Painting it changes nothing on screen,
// and queueing it would put a prediction into the queue that cannot
// be told apart from "no evidence yet" no matter what happens next.
if (k.ch == k.prev_ch) return self.suppress();
const cell: Cell = .{ .row = k.cursor.y, .col = k.cursor.x, .ch = k.ch };
self.pending.append(self.alloc, .{
.cell = cell,
.prev_ch = k.prev_ch,
.made_seq = self.last_seq,
.made_ms = k.now_ms,
}) catch return self.suppress();
self.counters.made += 1;
if (self.visible()) {
self.markPainted(self.pending.items.len - 1);
return .{ .display = cell };
}
// Queued even when local, deliberately: a hidden prediction is still
// judged, so it keeps measuring the path and keeps the streak. That
// is what lets the overlay come back on if the box gets slow, and
// what made the local-gate tests cheap — no second state machine.
if (self.confident) self.counters.local += 1;
return .{ .hidden = cell };
}
/// The gate on painting: earned, AND on a path slow enough to show it.
pub fn visible(self: *const Overlay) bool {
return self.confident and !self.local;
}
/// Fold one measured round trip into the estimate and move `local`
/// if it crossed a trigger. Confirms only — an expiry says nothing
/// about how fast the path is, only that the application went quiet.
fn samplePath(self: *Overlay, rtt_ms: i64) void {
const srtt = if (self.srtt_ms) |s| s + @divTrunc(rtt_ms - s, 8) else rtt_ms;
self.srtt_ms = srtt;
if (srtt <= local_below_ms) self.local = true;
if (srtt >= local_above_ms) self.local = false;
}
fn suppress(self: *Overlay) Outcome {
self.recordSuppressed();
return .suppressed;
}
/// A refusal the CALLER made: a plain-ASCII paste, whose lead byte is
/// printable.
pub fn recordSuppressed(self: *Overlay) void {
self.counters.suppressed += 1;
}
/// A prediction queued while unconfident is invisible; a later
/// promotion makes the next repaint draw it. Count it once, however
/// many repaints redraw the cell.
pub fn markPainted(self: *Overlay, i: usize) void {
if (self.pending.items[i].painted) return;
self.pending.items[i].painted = true;
self.counters.displayed += 1;
}
/// Judge everything the newly applied frame is entitled to judge. A
/// confirmed prediction retires and lengthens the streak; a contradicted one
/// takes the WHOLE queue, because every prediction made after a wrong one was
/// made against a screen that never existed.
pub fn reconcile(self: *Overlay, reader: anytype, applied_seq: u64, now_ms: i64) Verdict {
var verdict: Verdict = .none;
var i: usize = 0;
while (i < self.pending.items.len) {
const p = self.pending.items[i];
if (p.made_seq >= applied_seq) {
i += 1; // too new to be evidence about
continue;
}
const shown = reader.cellChar(p.cell.row, p.cell.col);
if (shown != null and shown.? == p.cell.ch) {
_ = self.pending.orderedRemove(i);
self.counters.confirmed += 1;
self.samplePath(now_ms -| p.made_ms);
self.streak +|= 1;
if (self.ctx == .adaptive and self.streak >= promote_after) {
self.confident = true;
}
verdict = .confirmed;
continue; // index i now holds the next prediction
}
if (shown != null and shown.? == p.prev_ch) {
// Silence, not disagreement. This frame was almost certainly
// built before the keystroke landed — which over any real
// path is the ordinary case for everything typed after the
// first character of a burst.
self.pending.items[i].frames +|= 1;
if (self.pending.items[i].frames >= expire_after_frames or
now_ms -| p.made_ms >= expire_after_ms)
{
self.counters.expired += 1;
self.noteSeq(applied_seq);
return self.abandonAll();
}
i += 1;
continue;
}
// The cell moved to something that is neither our guess nor what
// was there before: somebody else wrote it, and every prediction
// behind this one was made against a screen that never existed.
self.noteSeq(applied_seq);
return self.abandonAll();
}
self.noteSeq(applied_seq);
return verdict;
}
/// An app that swallows a keystroke then goes quiet produces no more
/// frames, so `reconcile` never runs again and the phantom sits there
/// forever. The client calls this from its idle path.
pub fn expire(self: *Overlay, now_ms: i64) Verdict {
for (self.pending.items) |p| {
if (now_ms -| p.made_ms < expire_after_ms) continue;
self.counters.expired += 1;
return self.abandonAll();
}
return .none;
}
/// Drop everything and stop trusting ourselves: the shared tail of a
/// contradiction and an expiry, which differ only in what made them
/// necessary. Callers repaint in full.
fn abandonAll(self: *Overlay) Verdict {
self.counters.contradicted += 1;
self.counters.abandoned += self.pending.items.len;
// Re-earning display costs the full promote_after again. Without
// this the streak survives the demotion, one confirm re-promotes,
// and leg 3 of the criterion — display stops until it is earned back
// — quietly becomes display stops for one keystroke.
self.streak = 0;
// Demotion is an adaptive-only idea. In canonical echo the pty is
// going to print the character whatever we believe, so a
// disagreement means we put it in the wrong place, not that we
// should stop predicting.
if (self.ctx == .adaptive) self.confident = false;
self.pending.clearRetainingCapacity();
return .contradicted;
}
/// Snapshot, resize, scroll mode, reconnect: nothing here is wrong, so
/// confidence and the streak survive.
pub fn flush(self: *Overlay) void {
self.counters.abandoned += self.pending.items.len;
self.pending.clearRetainingCapacity();
}
/// Where the cursor appears to be, given what is queued: the
/// authoritative position advanced past the last pending prediction.
/// With nothing pending it is the daemon's own answer, untouched.
pub fn predictedCursor(self: *const Overlay, base: CursorPos) CursorPos {
const last = self.pending.getLastOrNull() orelse return base;
return .{ .x = last.cell.col + 1, .y = last.cell.row };
}
pub fn pendingCount(self: *const Overlay) usize {
return self.pending.items.len;
}
/// By value: the queue reallocates, so a slice would go stale on the
/// next keystroke.
pub fn pendingAt(self: *const Overlay, i: usize) Pred {
return self.pending.items[i];
}
};
// ---- tests ------------------------------------------------------------
/// A grid row set built the way a plain dump arrives: rows joined by '\n',
/// trailing blanks absent. Caller frees.
fn plainOf(alloc: std.mem.Allocator, rows: []const []const u8) ![]u8 {
return std.mem.join(alloc, "\n", rows);
}
/// Type one character into a cell that is currently blank — the ordinary
/// case, and the one most of these tests are about.
fn typeAt(ov: *Overlay, x: u16, y: u16, ch: u8) Outcome {
return ov.predictAt(.{
.cursor = .{ .x = x, .y = y },
.ch = ch,
.prev_ch = ' ',
.now_ms = 0,
});
}
/// A frame from a remote path. Tests about time or the gate use `seeRowsAt`.
fn seeRows(
alloc: std.mem.Allocator,
ov: *Overlay,
rows: []const []const u8,
seq: u64,
) !Verdict {
// 100ms after the keystroke, not 0: at 0 every confirm is a measured
// 0ms round trip, the local gate closes, and every `.display` the
// policy tests assert goes `.hidden` for a reason they are not about.
return seeRowsAt(alloc, ov, rows, seq, 100);
}
fn seeRowsAt(
alloc: std.mem.Allocator,
ov: *Overlay,
rows: []const []const u8,
seq: u64,
now_ms: i64,
) !Verdict {
const text = try plainOf(alloc, rows);
defer alloc.free(text);
return ov.reconcile(PlainGrid{ .text = text, .cols = 80 }, seq, now_ms);
}
test "setMode maps the pty's two bits onto the three policies" {
const alloc = std.testing.allocator;
const cases = [_]struct { icanon: bool, echo: bool, want: Context }{
// Canonical and echoing: the tty itself will put the character on
// the screen, so predicting it is not a guess at all.
.{ .icanon = true, .echo = true, .want = .always },
// Canonical and silent: a password prompt. Nothing may be shown.
.{ .icanon = true, .echo = false, .want = .never },
// Raw: the application decides what a keystroke looks like, and the
// only way to find out is to be right about it repeatedly.
.{ .icanon = false, .echo = true, .want = .adaptive },
.{ .icanon = false, .echo = false, .want = .adaptive },
};
for (cases) |c| {
var ov = Overlay.init(alloc, 80, 24);
defer ov.deinit();
ov.setMode(.{ .icanon = c.icanon, .echo = c.echo });
try std.testing.expectEqual(c.want, ov.ctx);
}
}
test "a mode byte carrying a bit we do not understand predicts nothing" {
const alloc = std.testing.allocator;
var ov = Overlay.init(alloc, 80, 24);
defer ov.deinit();
ov.setMode(.{ .icanon = true, .echo = true });
try std.testing.expectEqual(Context.always, ov.ctx);
// A future daemon defines a third bit. Read as canonical-and-echoing
// with the extra bit masked away, this would keep predicting against a
// terminal whose description we have only partly understood.
ov.setMode(.{ .icanon = true, .echo = true, ._pad = 1 });
try std.testing.expectEqual(Context.never, ov.ctx);
try std.testing.expect(typeAt(&ov, 0, 0, 'a') == .suppressed);
}
test "an overlay predicts nothing until it has been told what the pty is" {
const alloc = std.testing.allocator;
var ov = Overlay.init(alloc, 80, 24);
defer ov.deinit();
// No pty_mode frame has arrived (or the daemon is too old to send one).
// No frame, no prediction: the safe direction is the default one.
try std.testing.expectEqual(Context.never, ov.ctx);
try std.testing.expect(typeAt(&ov, 0, 0, 'a') == .suppressed);
try std.testing.expectEqual(@as(u64, 0), ov.counters.made);
}
test "always: the first keystroke paints, with no evidence required" {
const alloc = std.testing.allocator;
var ov = Overlay.init(alloc, 80, 24);
defer ov.deinit();
ov.setMode(.{ .icanon = true, .echo = true });
const out = typeAt(&ov, 3, 2, 'k');
try std.testing.expect(out == .display);
try std.testing.expectEqual(@as(u16, 3), out.display.col);
try std.testing.expectEqual(@as(u16, 2), out.display.row);
try std.testing.expectEqual(@as(u8, 'k'), out.display.ch);
try std.testing.expectEqual(@as(u64, 1), ov.counters.made);
try std.testing.expectEqual(@as(u64, 1), ov.counters.displayed);
try std.testing.expectEqual(@as(usize, 1), ov.pendingCount());
}
test "never: nothing is made, so nothing can leak" {
const alloc = std.testing.allocator;
var ov = Overlay.init(alloc, 80, 24);
defer ov.deinit();
ov.setMode(.{ .icanon = true, .echo = false });
for ("hunter2") |ch| {
try std.testing.expect(typeAt(&ov, 0, 0, ch) == .suppressed);
}
// Both counters, deliberately: leg 3 of the criterion asserts made as
// well as displayed, because "made but hidden" in a password context
// would still put the password in a buffer the overlay paints from.
try std.testing.expectEqual(@as(u64, 0), ov.counters.made);
try std.testing.expectEqual(@as(u64, 0), ov.counters.displayed);
try std.testing.expectEqual(@as(u64, 7), ov.counters.suppressed);
try std.testing.expectEqual(@as(usize, 0), ov.pendingCount());
}
test "a burst outruns the round trip without refuting itself" {
const alloc = std.testing.allocator;
var ov = Overlay.init(alloc, 80, 24);
defer ov.deinit();
ov.setMode(.{ .icanon = true, .echo = true });
// Three characters typed faster than the daemon can answer.
try std.testing.expect(typeAt(&ov, 0, 0, 'h') == .display);
try std.testing.expect(typeAt(&ov, 1, 0, 'e') == .display);
try std.testing.expect(typeAt(&ov, 2, 0, 'l') == .display);
// The first frame back was built when the daemon had seen only 'h'. The
// other two cells are still blank, which is what they were when we predicted
// — so this frame said nothing about them, and reading that silence as
// disagreement flushes the queue once per round trip.
try std.testing.expectEqual(Verdict.confirmed, try seeRows(alloc, &ov, &.{"h"}, 1));
try std.testing.expectEqual(@as(usize, 2), ov.pendingCount());
try std.testing.expectEqual(@as(u64, 0), ov.counters.contradicted);
try std.testing.expectEqual(Verdict.confirmed, try seeRows(alloc, &ov, &.{"he"}, 2));
try std.testing.expectEqual(@as(usize, 1), ov.pendingCount());
try std.testing.expectEqual(Verdict.confirmed, try seeRows(alloc, &ov, &.{"hel"}, 3));
try std.testing.expectEqual(@as(usize, 0), ov.pendingCount());
// Every one of them landed, and nothing was ever called wrong.
try std.testing.expectEqual(@as(u64, 3), ov.counters.confirmed);
try std.testing.expectEqual(@as(u64, 0), ov.counters.contradicted);
try std.testing.expectEqual(@as(u64, 0), ov.counters.expired);
}
test "predicting what is already on the screen is a no-op, and refused" {
const alloc = std.testing.allocator;
var ov = Overlay.init(alloc, 80, 24);
defer ov.deinit();
ov.setMode(.{ .icanon = true, .echo = true });
// Painting an 'a' over an 'a' changes nothing, and the prediction could
// never be judged: "still shows 'a'" would be both the confirmation and
// the no-evidence-yet answer, which are the two things reconcile exists
// to tell apart.
const out = ov.predictAt(.{
.cursor = .{ .x = 0, .y = 0 },
.ch = 'a',
.prev_ch = 'a',
.now_ms = 0,
});
try std.testing.expect(out == .suppressed);
try std.testing.expectEqual(@as(u64, 0), ov.counters.made);
try std.testing.expectEqual(@as(u64, 1), ov.counters.suppressed);
}
test "adaptive earns the right to display, one confirm at a time" {
const alloc = std.testing.allocator;
var ov = Overlay.init(alloc, 80, 24);
defer ov.deinit();
ov.setMode(.{ .icanon = false, .echo = false });
// Written out with literal counts rather than a loop over promote_after:
// a loop parameterised by the constant moves its own goalposts when the
// constant is mutated, and would have passed at 1 and at 3 alike.
try std.testing.expect(typeAt(&ov, 0, 0, 'a') == .hidden);
try std.testing.expectEqual(Verdict.confirmed, try seeRows(alloc, &ov, &.{"a"}, 1));
// Still hidden: one confirm is not two.
try std.testing.expect(typeAt(&ov, 1, 0, 'b') == .hidden);
try std.testing.expectEqual(Verdict.confirmed, try seeRows(alloc, &ov, &.{"ab"}, 2));
try std.testing.expectEqual(@as(u64, 2), ov.counters.confirmed);
try std.testing.expectEqual(@as(u64, 0), ov.counters.displayed);
// Two consecutive confirms, and the third keystroke paints.
try std.testing.expect(typeAt(&ov, 2, 0, 'c') == .display);
try std.testing.expectEqual(@as(u64, 3), ov.counters.made);
try std.testing.expectEqual(@as(u64, 1), ov.counters.displayed);
}
test "a path that answers faster than the eye can see earns display and still hides" {
const alloc = std.testing.allocator;
var ov = Overlay.init(alloc, 80, 24);
defer ov.deinit();
ov.setMode(.{ .icanon = false, .echo = false });
// Two confirms, each 1ms after its keystroke: a unix socket to a daemon
// on the same box. Confidence is earned exactly as over a WAN...
try std.testing.expect(typeAt(&ov, 0, 0, 'a') == .hidden);
try std.testing.expectEqual(Verdict.confirmed, try seeRowsAt(alloc, &ov, &.{"a"}, 1, 1));
try std.testing.expect(typeAt(&ov, 1, 0, 'b') == .hidden);
try std.testing.expectEqual(Verdict.confirmed, try seeRowsAt(alloc, &ov, &.{"ab"}, 2, 1));
try std.testing.expect(ov.confident);
// ...and the third keystroke is queued but NOT shown: a prediction the
// real glyph overtakes within a frame can only ever be seen when it is
// wrong. `displayed` stays at zero, which is what the repro counted.
try std.testing.expect(typeAt(&ov, 2, 0, 'c') == .hidden);
try std.testing.expect(!ov.visible());
try std.testing.expectEqual(@as(u64, 3), ov.counters.made);
try std.testing.expectEqual(@as(u64, 0), ov.counters.displayed);
try std.testing.expectEqual(@as(u64, 1), ov.counters.local);
}
test "the local gate has hysteresis, and an unmeasured path is not local" {
const alloc = std.testing.allocator;
var ov = Overlay.init(alloc, 80, 24);
defer ov.deinit();
ov.setMode(.{ .icanon = false, .echo = false });
// Before any confirm there is no measurement, and the WAN behaviour
// stands unchanged: earn display, show.
try std.testing.expect(!ov.local);
try std.testing.expect(typeAt(&ov, 0, 0, 'a') == .hidden);
try std.testing.expectEqual(Verdict.confirmed, try seeRowsAt(alloc, &ov, &.{"a"}, 1, 150));
try std.testing.expect(typeAt(&ov, 1, 0, 'b') == .hidden);
try std.testing.expectEqual(Verdict.confirmed, try seeRowsAt(alloc, &ov, &.{"ab"}, 2, 150));
try std.testing.expect(ov.visible());
try std.testing.expectEqual(@as(i64, 150), ov.srtt_ms.?);
// One fast confirm does not flip it: the estimate is smoothed
// (150 - 150/8 = 132), and nothing above `local_below_ms` turns it off.
try std.testing.expect(typeAt(&ov, 2, 0, 'c') == .display);
try std.testing.expectEqual(Verdict.confirmed, try seeRowsAt(alloc, &ov, &.{"abc"}, 3, 0));
try std.testing.expectEqual(@as(i64, 132), ov.srtt_ms.?);
try std.testing.expect(ov.visible());
// A run of fast confirms does. The keystroke clock stays at 0 (typeAt),
// so each confirm at 0 is a 0ms round trip.
var col: u16 = 3;
var row = [_]u8{ 'a', 'b', 'c' } ++ [_]u8{' '} ** 60;
while (ov.visible()) : (col += 1) {
row[col] = 'x';
_ = typeAt(&ov, col, 0, 'x');
try std.testing.expectEqual(Verdict.confirmed, try seeRowsAt(alloc, &ov, &.{row[0 .. col + 1]}, col + 1, 0));
}
try std.testing.expect(ov.local);
try std.testing.expect(ov.srtt_ms.? <= local_below_ms);
// Back above the upper trigger and it shows again; the band between the
// two is where a jittery path would otherwise flap every keystroke.
while (!ov.visible()) : (col += 1) {
row[col] = 'x';
_ = typeAt(&ov, col, 0, 'x');
try std.testing.expectEqual(Verdict.confirmed, try seeRowsAt(alloc, &ov, &.{row[0 .. col + 1]}, col + 1, 400));
}
try std.testing.expect(ov.srtt_ms.? >= local_above_ms);
}
test "a mode change keeps the path estimate: the wire did not move" {
const alloc = std.testing.allocator;
var ov = Overlay.init(alloc, 80, 24);
defer ov.deinit();
ov.setMode(.{ .icanon = false, .echo = false });
_ = typeAt(&ov, 0, 0, 'a');
try std.testing.expectEqual(Verdict.confirmed, try seeRowsAt(alloc, &ov, &.{"a"}, 1, 1));
try std.testing.expect(ov.local);
// readline hands the tty back and forth around every command; the
// daemon is no further away afterwards.
ov.setMode(.{ .icanon = true, .echo = true });
try std.testing.expect(ov.local);
try std.testing.expect(!ov.visible());
}
test "a contradiction flushes the whole queue, not merely the cell that was wrong" {
const alloc = std.testing.allocator;
var ov = Overlay.init(alloc, 80, 24);
defer ov.deinit();
ov.setMode(.{ .icanon = true, .echo = true });
_ = typeAt(&ov, 0, 0, 'a');
_ = typeAt(&ov, 1, 0, 'b');
_ = typeAt(&ov, 2, 0, 'c');
try std.testing.expectEqual(@as(usize, 3), ov.pendingCount());
// Somebody else wrote the FIRST cell: it holds neither our guess nor the
// blank that was there, so this is refutation and not silence. The two
// behind it would each have matched, which is the point.
try std.testing.expectEqual(
Verdict.contradicted,
try seeRows(alloc, &ov, &.{"xbc"}, 1),
);
try std.testing.expectEqual(@as(usize, 0), ov.pendingCount());
try std.testing.expectEqual(@as(u64, 1), ov.counters.contradicted);
// Zero, not two. An implementation that retired only the wrong cell and
// carried on judging would count the other two as confirmed, and would
// be claiming agreement about a screen it had already been told it was
// wrong about.
try std.testing.expectEqual(@as(u64, 0), ov.counters.confirmed);
// A refutation is not an expiry, and the counters read these apart.
try std.testing.expectEqual(@as(u64, 0), ov.counters.expired);
}
test "a prediction nothing ever answers is given up on, by frame count" {
const alloc = std.testing.allocator;
var ov = Overlay.init(alloc, 80, 24);
defer ov.deinit();
ov.setMode(.{ .icanon = true, .echo = true });
// The nvim case: a keystroke the application consumes silently. The
// cell it was predicted into never changes, so no frame ever confirms
// or refutes it, and the glyph we painted would stay there forever.
_ = typeAt(&ov, 0, 0, 'j');
// Seven frames of silence are patience, not evidence. Literal counts,
// for the same reason as promote_after: a loop over the constant would
// pass at any value of it.
var n: u64 = 1;
while (n <= 7) : (n += 1) {
try std.testing.expectEqual(Verdict.none, try seeRows(alloc, &ov, &.{""}, n));
try std.testing.expectEqual(@as(usize, 1), ov.pendingCount());
}
// The eighth is where patience runs out.
try std.testing.expectEqual(Verdict.contradicted, try seeRows(alloc, &ov, &.{""}, 8));
try std.testing.expectEqual(@as(usize, 0), ov.pendingCount());
try std.testing.expectEqual(@as(u64, 1), ov.counters.expired);
// Counted as a contradiction too: evidentially that is what it is, and
// leg 2 of the criterion asks that no abandoned prediction outlive the
// frame that abandoned it, however it was abandoned.
try std.testing.expectEqual(@as(u64, 1), ov.counters.contradicted);
}
test "a prediction nothing ever answers is given up on, by the clock" {
const alloc = std.testing.allocator;
var ov = Overlay.init(alloc, 80, 24);
defer ov.deinit();
ov.setMode(.{ .icanon = true, .echo = true });
_ = typeAt(&ov, 0, 0, 'j'); // made at now_ms = 0
// A frame short of the deadline leaves it alone...
try std.testing.expectEqual(
Verdict.none,
try seeRowsAt(alloc, &ov, &.{""}, 1, 999),
);
try std.testing.expectEqual(@as(usize, 1), ov.pendingCount());
// ...and one at it does not. The clock comes from the caller, so this
// deadline is exercised without anything here reading a real one.
try std.testing.expectEqual(
Verdict.contradicted,
try seeRowsAt(alloc, &ov, &.{""}, 2, 1000),
);
try std.testing.expectEqual(@as(usize, 0), ov.pendingCount());
try std.testing.expectEqual(@as(u64, 1), ov.counters.expired);
}
test "expire gives up on a silent prediction with no frame to prompt it" {
const alloc = std.testing.allocator;
var ov = Overlay.init(alloc, 80, 24);
defer ov.deinit();
ov.setMode(.{ .icanon = true, .echo = true });
_ = typeAt(&ov, 0, 0, 'j');
// The frame bound cannot reach the case that motivates the guard: an
// application that swallows the keystroke and then says nothing at all
// produces no further frames, so reconcile is never called again. This
// is the client's idle path, and without it the phantom is permanent.
try std.testing.expectEqual(Verdict.none, ov.expire(999));
try std.testing.expectEqual(@as(usize, 1), ov.pendingCount());
try std.testing.expectEqual(Verdict.contradicted, ov.expire(1000));
try std.testing.expectEqual(@as(usize, 0), ov.pendingCount());
try std.testing.expectEqual(@as(u64, 1), ov.counters.expired);
try std.testing.expectEqual(@as(u64, 1), ov.counters.contradicted);
}
test "a promotion mid-queue counts the predictions it makes visible, once each" {
const alloc = std.testing.allocator;
var ov = Overlay.init(alloc, 80, 24);
defer ov.deinit();
ov.setMode(.{ .icanon = false, .echo = false });
// One confirm banked, so the overlay is one short of promotion.
try std.testing.expect(typeAt(&ov, 0, 0, 'a') == .hidden);
try std.testing.expectEqual(Verdict.confirmed, try seeRows(alloc, &ov, &.{"a"}, 1));
// Two more typed while still invisible — and the second confirmation
// arrives while the last of them is still outstanding.
try std.testing.expect(typeAt(&ov, 1, 0, 'b') == .hidden);
try std.testing.expect(typeAt(&ov, 2, 0, 'c') == .hidden);
try std.testing.expectEqual(@as(u64, 0), ov.counters.displayed);
try std.testing.expectEqual(Verdict.confirmed, try seeRows(alloc, &ov, &.{"ab"}, 2));
try std.testing.expect(ov.confident);
try std.testing.expectEqual(@as(usize, 1), ov.pendingCount());
// The next repaint draws 'c', which was queued invisible and has just
// become visible without anybody typing anything. It reached the screen,
// so it counts.
ov.markPainted(0);
try std.testing.expectEqual(@as(u64, 1), ov.counters.displayed);
// Every subsequent repaint redraws the same cell — a delta rewrites the
// row and the overlay is re-laid on top — and none of them is a second
// arrival on screen.
ov.markPainted(0);
ov.markPainted(0);
try std.testing.expectEqual(@as(u64, 1), ov.counters.displayed);
try std.testing.expect(ov.counters.displayed <= ov.counters.made);
}
test "a refusal the caller made on its own authority still counts" {
const alloc = std.testing.allocator;
var ov = Overlay.init(alloc, 80, 24);
defer ov.deinit();
ov.setMode(.{ .icanon = true, .echo = true });
// The client declines chunks the overlay never sees — a paste, whose
// lead byte is printable and would otherwise be predicted as though it
// were a keystroke. The decision is the client's; the count belongs
// with every other refusal, or the numbers disagree with the behaviour.
ov.recordSuppressed();
try std.testing.expectEqual(@as(u64, 1), ov.counters.suppressed);
try std.testing.expectEqual(@as(u64, 0), ov.counters.made);
try std.testing.expectEqual(@as(usize, 0), ov.pendingCount());
// And it is the same counter predictAt's own refusals land in.
try std.testing.expect(typeAt(&ov, 0, 0, 0x1b) == .suppressed);
try std.testing.expectEqual(@as(u64, 2), ov.counters.suppressed);
}
test "stale frames age nothing: only a frame that could have seen it counts" {
const alloc = std.testing.allocator;
var ov = Overlay.init(alloc, 80, 24);
defer ov.deinit();
ov.setMode(.{ .icanon = true, .echo = true });
ov.noteSeq(10);
_ = typeAt(&ov, 0, 0, 'j');
// Eight frames the daemon built before it could have seen the keystroke.
// The expiry bound counts evidence, and these are not evidence: age a
// prediction on frames that predate it and a busy session kills its own
// predictions faster the more output it produces.
var n: usize = 0;
while (n < 8) : (n += 1) {
try std.testing.expectEqual(Verdict.none, try seeRows(alloc, &ov, &.{""}, 10));
try std.testing.expectEqual(@as(usize, 1), ov.pendingCount());
}
try std.testing.expectEqual(@as(u64, 0), ov.counters.contradicted);
// The first frame that could have seen it is the first one that ages it,
// so the prediction is still alive here rather than eight frames stale.
try std.testing.expectEqual(Verdict.none, try seeRows(alloc, &ov, &.{""}, 11));
try std.testing.expectEqual(@as(usize, 1), ov.pendingCount());
try std.testing.expectEqual(@as(u64, 0), ov.counters.expired);
}
test "a flush costs the queue but never the confidence that was earned" {
const alloc = std.testing.allocator;
var ov = Overlay.init(alloc, 80, 24);
defer ov.deinit();
ov.setMode(.{ .icanon = false, .echo = false });
try std.testing.expect(typeAt(&ov, 0, 0, 'a') == .hidden);
try std.testing.expectEqual(Verdict.confirmed, try seeRows(alloc, &ov, &.{"a"}, 1));
try std.testing.expect(typeAt(&ov, 1, 0, 'b') == .hidden);
try std.testing.expectEqual(Verdict.confirmed, try seeRows(alloc, &ov, &.{"ab"}, 2));
try std.testing.expect(ov.confident);
try std.testing.expectEqual(@as(u8, 2), ov.streak);
_ = typeAt(&ov, 2, 0, 'c');
ov.flush();
// The confirmations that earned display were real, and a resize is not
// evidence against them. Demote here and every window resize would cost
// the next two keystrokes their visibility.
try std.testing.expect(ov.confident);
try std.testing.expectEqual(@as(u8, 2), ov.streak);
try std.testing.expect(typeAt(&ov, 3, 0, 'd') == .display);
}
test "every prediction is accounted for: made = confirmed + abandoned + pending" {
const alloc = std.testing.allocator;
var ov = Overlay.init(alloc, 80, 24);
defer ov.deinit();
ov.setMode(.{ .icanon = true, .echo = true });
// One confirmed...
_ = typeAt(&ov, 0, 0, 'a');
try std.testing.expectEqual(Verdict.confirmed, try seeRows(alloc, &ov, &.{"a"}, 1));
// ...two thrown away by a refutation, which is ONE event...
_ = typeAt(&ov, 1, 0, 'b');
_ = typeAt(&ov, 2, 0, 'c');
try std.testing.expectEqual(Verdict.contradicted, try seeRows(alloc, &ov, &.{"aZ"}, 2));
try std.testing.expectEqual(@as(u64, 1), ov.counters.contradicted);
try std.testing.expectEqual(@as(u64, 2), ov.counters.abandoned);
// ...one thrown away by a flush, which is counted nowhere else...
_ = typeAt(&ov, 1, 0, 'd');
ov.flush();
try std.testing.expectEqual(@as(u64, 3), ov.counters.abandoned);
// ...and a flush is still not an accusation.
try std.testing.expectEqual(@as(u64, 1), ov.counters.contradicted);
// ...leaving one outstanding.
_ = typeAt(&ov, 1, 0, 'e');
const c = ov.counters;
try std.testing.expectEqual(@as(u64, 5), c.made);
try std.testing.expectEqual(
c.made,
c.confirmed + c.abandoned + @as(u64, ov.pendingCount()),
);
}
test "an expiry demotes adaptive exactly as a refutation would" {
const alloc = std.testing.allocator;
var ov = Overlay.init(alloc, 80, 24);
defer ov.deinit();
ov.setMode(.{ .icanon = false, .echo = false });
try std.testing.expect(typeAt(&ov, 0, 0, 'a') == .hidden);
try std.testing.expectEqual(Verdict.confirmed, try seeRows(alloc, &ov, &.{"a"}, 1));
try std.testing.expect(typeAt(&ov, 1, 0, 'b') == .hidden);
try std.testing.expectEqual(Verdict.confirmed, try seeRows(alloc, &ov, &.{"ab"}, 2));
try std.testing.expect(ov.confident);
// A keystroke the application swallows: the confidence that earned
// display was evidence about a mode the application has left.
_ = typeAt(&ov, 2, 0, 'c');
try std.testing.expectEqual(Verdict.contradicted, ov.expire(1000));
try std.testing.expect(!ov.confident);
try std.testing.expect(typeAt(&ov, 2, 0, 'c') == .hidden);
}
test "adaptive is demoted by one contradiction and must earn display again" {
const alloc = std.testing.allocator;
var ov = Overlay.init(alloc, 80, 24);
defer ov.deinit();
ov.setMode(.{ .icanon = false, .echo = false });
// Earn it.
try std.testing.expect(typeAt(&ov, 0, 0, 'a') == .hidden);
try std.testing.expectEqual(Verdict.confirmed, try seeRows(alloc, &ov, &.{"a"}, 1));
try std.testing.expect(typeAt(&ov, 1, 0, 'b') == .hidden);
try std.testing.expectEqual(Verdict.confirmed, try seeRows(alloc, &ov, &.{"ab"}, 2));
try std.testing.expect(typeAt(&ov, 2, 0, 'c') == .display);
// Lose it: the application put its own content in the cell we drew in,
// which is a refutation and not silence.
try std.testing.expectEqual(
Verdict.contradicted,
try seeRows(alloc, &ov, &.{"abX"}, 3),
);
try std.testing.expect(!ov.confident);
// ...and the very next keystroke is invisible again. One contradicted
// prediction, one demotion: that is leg 3 of the criterion.
try std.testing.expect(typeAt(&ov, 3, 0, 'd') == .hidden);
// Re-earning costs the FULL `promote_after`, not one confirm: otherwise
// "display stops until it is earned again" means "for one keystroke", and an
// application that contradicts every other one paints half of them wrong.
try std.testing.expectEqual(@as(u8, 0), ov.streak);
try std.testing.expectEqual(Verdict.confirmed, try seeRows(alloc, &ov, &.{"abXd"}, 4));
try std.testing.expect(!ov.confident);
try std.testing.expect(typeAt(&ov, 4, 0, 'e') == .hidden);
// The second confirmation is the one that earns it back.
try std.testing.expectEqual(Verdict.confirmed, try seeRows(alloc, &ov, &.{"abXde"}, 5));
try std.testing.expect(ov.confident);
try std.testing.expect(typeAt(&ov, 5, 0, 'f') == .display);
}
test "always is never demoted: a contradiction costs the queue, not the policy" {
const alloc = std.testing.allocator;
var ov = Overlay.init(alloc, 80, 24);
defer ov.deinit();
ov.setMode(.{ .icanon = true, .echo = true });
_ = typeAt(&ov, 0, 0, 'a');
try std.testing.expectEqual(Verdict.contradicted, try seeRows(alloc, &ov, &.{"z"}, 1));
// In canonical echo the pty is going to print the character whatever we
// do, so a disagreement means we mis-placed it, not that we should stop
// predicting. Confidence here is not earned and cannot be lost.
try std.testing.expect(typeAt(&ov, 0, 0, 'a') == .display);
}
test "predictAt refuses everything the plan says it must" {
const alloc = std.testing.allocator;
// Non-printables: control bytes carry meaning we cannot render, and the
// high half is a multi-byte sequence whose width we do not know.
for ([_]u8{ 0x00, 0x08, 0x09, 0x0a, 0x0d, 0x1b, 0x7f, 0x80, 0xc3, 0xff }) |ch| {
var ov = Overlay.init(alloc, 80, 24);
defer ov.deinit();
ov.setMode(.{ .icanon = true, .echo = true });
try std.testing.expect(typeAt(&ov, 0, 0, ch) == .suppressed);
try std.testing.expectEqual(@as(u64, 1), ov.counters.suppressed);
}
// The last column: what happens there is the application's policy
// (wrap, scroll, truncate, refuse) and we do not get to guess it.
{
var ov = Overlay.init(alloc, 80, 24);
defer ov.deinit();
ov.setMode(.{ .icanon = true, .echo = true });
try std.testing.expect(typeAt(&ov, 78, 0, 'a') == .display);
try std.testing.expect(typeAt(&ov, 79, 0, 'a') == .suppressed);
}
// Off the grid entirely.
{
var ov = Overlay.init(alloc, 80, 24);
defer ov.deinit();
ov.setMode(.{ .icanon = true, .echo = true });
try std.testing.expect(typeAt(&ov, 0, 24, 'a') == .suppressed);
}
// Scrolled back: the cursor is not where the user is looking, so a
// prediction painted at it would land in the middle of history.
{
var ov = Overlay.init(alloc, 80, 24);
defer ov.deinit();
ov.setMode(.{ .icanon = true, .echo = true });
ov.setScrollMode(true);
try std.testing.expect(typeAt(&ov, 0, 0, 'a') == .suppressed);
ov.setScrollMode(false);
try std.testing.expect(typeAt(&ov, 0, 0, 'a') == .display);
}
// A resize we have asked for but not yet been answered about: the grid
// the prediction would be painted on is about to stop existing.
{
var ov = Overlay.init(alloc, 80, 24);
defer ov.deinit();
ov.setMode(.{ .icanon = true, .echo = true });
ov.setResizePending(true);
try std.testing.expect(typeAt(&ov, 0, 0, 'a') == .suppressed);
ov.setResizePending(false);
try std.testing.expect(typeAt(&ov, 0, 0, 'a') == .display);
}
}
test "a frame that cannot have seen the keystroke does not get to judge it" {
const alloc = std.testing.allocator;
var ov = Overlay.init(alloc, 80, 24);
defer ov.deinit();
ov.setMode(.{ .icanon = true, .echo = true });
// The client is holding seq 7 when the key is pressed.
ov.noteSeq(7);
_ = typeAt(&ov, 0, 0, 'a');
// A frame numbered 7 is the one we already had. Even though it carries
// content that WOULD refute the prediction, it is not evidence about a
// keystroke made after it, and must not be read as any.
try std.testing.expectEqual(Verdict.none, try seeRows(alloc, &ov, &.{"Z"}, 7));
try std.testing.expectEqual(@as(usize, 1), ov.pendingCount());
try std.testing.expectEqual(@as(u64, 0), ov.counters.contradicted);
try std.testing.expectEqual(@as(u64, 0), ov.counters.confirmed);
// Seq 8 is the first frame the daemon could have built after seeing it.
try std.testing.expectEqual(
Verdict.contradicted,
try seeRows(alloc, &ov, &.{"Z"}, 8),
);
}
test "flush drops predictions without calling any of them wrong" {
const alloc = std.testing.allocator;
var ov = Overlay.init(alloc, 80, 24);
defer ov.deinit();
ov.setMode(.{ .icanon = true, .echo = true });
_ = typeAt(&ov, 0, 0, 'a');
_ = typeAt(&ov, 1, 0, 'b');
ov.flush();
try std.testing.expectEqual(@as(usize, 0), ov.pendingCount());
// A snapshot, a resize or a reconnect is not evidence that a prediction
// was mistaken — it is evidence that we can no longer find out. Counting
// it as a contradiction would make the demotion machinery fire on a
// window resize.
try std.testing.expectEqual(@as(u64, 0), ov.counters.contradicted);
try std.testing.expectEqual(@as(u64, 0), ov.counters.confirmed);
try std.testing.expectEqual(@as(u64, 0), ov.counters.expired);
}
test "a flush leaves scroll mode exactly where it found it" {
// `flush` empties the queue; it does not decide where the viewport is, and
// must not, because every snapshot flushes and a snapshot is no reason to
// leave history. So leaving scroll mode is the CLIENT's job on every path —
// an overlay left scrolled suppresses every keystroke for the session.
const alloc = std.testing.allocator;
var ov = Overlay.init(alloc, 80, 24);
defer ov.deinit();
ov.setMode(.{ .icanon = true, .echo = true });
ov.setScrollMode(true);
ov.flush();
try std.testing.expect(typeAt(&ov, 0, 0, 'a') == .suppressed);
// And the mirror: a flush does not switch it ON either, so an ordinary
// reconnect on a live screen keeps predicting.
ov.setScrollMode(false);
ov.flush();
try std.testing.expect(typeAt(&ov, 0, 0, 'a') == .display);
}
test "the same mode bits again cost nothing" {
const alloc = std.testing.allocator;
var ov = Overlay.init(alloc, 80, 24);
defer ov.deinit();
ov.setMode(.{ .icanon = true, .echo = true });
_ = typeAt(&ov, 0, 0, 'a');
_ = typeAt(&ov, 1, 0, 'b');
// A reattach re-states the mode, and at an idle prompt the daemon's
// poll re-reads bits that have not moved. Neither is a transition, and
// treating them as one would throw away predictions that are still
// perfectly good — on every reconnect.
ov.setMode(.{ .icanon = true, .echo = true });
try std.testing.expectEqual(@as(usize, 2), ov.pendingCount());
}
test "any move in the bits flushes and un-earns display, tier or no tier" {
const alloc = std.testing.allocator;
var ov = Overlay.init(alloc, 80, 24);
defer ov.deinit();
// Into a password prompt with predictions outstanding: they go.
ov.setMode(.{ .icanon = true, .echo = true });
_ = typeAt(&ov, 0, 0, 'a');
ov.setMode(.{ .icanon = true, .echo = false });
try std.testing.expectEqual(@as(usize, 0), ov.pendingCount());
// The case a tier comparison misses: both are raw mode, so the policy tier
// does not change — but the application has taken the terminal somewhere
// else, and predictions made before are about a screen that has moved on.
ov.setMode(.{ .icanon = false, .echo = false });
try std.testing.expect(typeAt(&ov, 0, 0, 'a') == .hidden);
try std.testing.expectEqual(Verdict.confirmed, try seeRows(alloc, &ov, &.{"a"}, 1));
try std.testing.expect(typeAt(&ov, 1, 0, 'b') == .hidden);
try std.testing.expectEqual(Verdict.confirmed, try seeRows(alloc, &ov, &.{"ab"}, 2));
try std.testing.expect(typeAt(&ov, 2, 0, 'c') == .display);
try std.testing.expect(ov.confident);
// Raw to raw, one bit different.
ov.setMode(.{ .icanon = false, .echo = true });
try std.testing.expectEqual(Context.adaptive, ov.ctx);
try std.testing.expectEqual(@as(usize, 0), ov.pendingCount());
try std.testing.expect(!ov.confident);
try std.testing.expectEqual(@as(u8, 0), ov.streak);
try std.testing.expect(typeAt(&ov, 3, 0, 'd') == .hidden);
// Flushing is not an accusation: nothing here was contradicted.
try std.testing.expectEqual(@as(u64, 0), ov.counters.contradicted);
}
test "entering raw mode starts unconfident however confident we just were" {
const alloc = std.testing.allocator;
var ov = Overlay.init(alloc, 80, 24);
defer ov.deinit();
ov.setMode(.{ .icanon = true, .echo = true });
try std.testing.expect(ov.confident);
ov.setMode(.{ .icanon = false, .echo = false });
try std.testing.expect(!ov.confident);
try std.testing.expect(typeAt(&ov, 0, 0, 'a') == .hidden);
}
test "a resize flushes and moves the edge the last column is measured from" {
const alloc = std.testing.allocator;
var ov = Overlay.init(alloc, 80, 24);
defer ov.deinit();
ov.setMode(.{ .icanon = true, .echo = true });
_ = typeAt(&ov, 0, 0, 'a');
ov.setGrid(40, 12);
try std.testing.expectEqual(@as(usize, 0), ov.pendingCount());
try std.testing.expect(typeAt(&ov, 39, 0, 'a') == .suppressed);
try std.testing.expect(typeAt(&ov, 38, 0, 'a') == .display);
// The same size again is not a resize and costs nothing.
_ = typeAt(&ov, 0, 1, 'b');
const before = ov.pendingCount();
ov.setGrid(40, 12);
try std.testing.expectEqual(before, ov.pendingCount());
}
test "predictedCursor advances the base past everything pending" {
const alloc = std.testing.allocator;
var ov = Overlay.init(alloc, 80, 24);
defer ov.deinit();
ov.setMode(.{ .icanon = true, .echo = true });
// With nothing pending the cursor is the daemon's, untouched.
try std.testing.expectEqual(
CursorPos{ .x = 5, .y = 1 },
ov.predictedCursor(.{ .x = 5, .y = 1 }),
);
var cur = CursorPos{ .x = 5, .y = 1 };
for ("abc") |ch| {
_ = typeAt(&ov, cur.x, cur.y, ch);
cur = ov.predictedCursor(.{ .x = 5, .y = 1 });
}
try std.testing.expectEqual(CursorPos{ .x = 8, .y = 1 }, cur);
// And it comes back to the authoritative cursor when the queue empties.
ov.flush();
try std.testing.expectEqual(
CursorPos{ .x = 5, .y = 1 },
ov.predictedCursor(.{ .x = 5, .y = 1 }),
);
}
test "a queued prediction owns its byte; the caller's buffer may be reused" {
const alloc = std.testing.allocator;
var ov = Overlay.init(alloc, 80, 24);
defer ov.deinit();
ov.setMode(.{ .icanon = true, .echo = true });
// Exactly the shape of the client's stdin path: bytes are read into a
// buffer that is about to be read into again. Anything the overlay keeps
// pointing at that buffer is the UAF-that-never-crashes.
const src = try alloc.alloc(u8, 3);
defer alloc.free(src);
@memcpy(src, "abc");
for (src, 0..) |ch, i| {
_ = typeAt(&ov, @intCast(i), 0, ch);
}
@memset(src, 0xFF);
try std.testing.expectEqual(@as(u8, 'a'), ov.pendingAt(0).cell.ch);
try std.testing.expectEqual(@as(u8, 'b'), ov.pendingAt(1).cell.ch);
try std.testing.expectEqual(@as(u8, 'c'), ov.pendingAt(2).cell.ch);
// And the judgement is made against what was typed, not against
// whatever the buffer holds by the time the frame comes back.
try std.testing.expectEqual(Verdict.confirmed, try seeRows(alloc, &ov, &.{"abc"}, 1));
try std.testing.expectEqual(@as(u64, 3), ov.counters.confirmed);
}
test "the queue survives its own growth" {
const alloc = std.testing.allocator;
var ov = Overlay.init(alloc, 80, 24);
defer ov.deinit();
ov.setMode(.{ .icanon = true, .echo = true });
// Well past any initial capacity, so the backing array is reallocated
// several times underneath the predictions already in it.
var i: u16 = 0;
while (i < 70) : (i += 1) {
const ch: u8 = 'a' + @as(u8, @intCast(i % 26));
try std.testing.expect(typeAt(&ov, i, 0, ch) == .display);
}
try std.testing.expectEqual(@as(usize, 70), ov.pendingCount());
i = 0;
while (i < 70) : (i += 1) {
const want: u8 = 'a' + @as(u8, @intCast(i % 26));
try std.testing.expectEqual(want, ov.pendingAt(i).cell.ch);
try std.testing.expectEqual(i, ov.pendingAt(i).cell.col);
}
}
test "PlainGrid reads a cell out of a dump, and blanks where the dump stops" {
const alloc = std.testing.allocator;
const text = try plainOf(alloc, &.{ "ab", "cd" });
defer alloc.free(text);
const g = PlainGrid{ .text = text, .cols = 80 };
try std.testing.expectEqual(@as(?u8, 'a'), g.cellChar(0, 0));
try std.testing.expectEqual(@as(?u8, 'b'), g.cellChar(0, 1));
try std.testing.expectEqual(@as(?u8, 'c'), g.cellChar(1, 0));
try std.testing.expectEqual(@as(?u8, 'd'), g.cellChar(1, 1));
// A dump carries no trailing blanks, so a cell past the end of a row —
// or past the last row — is a blank cell, not a missing one.
try std.testing.expectEqual(@as(?u8, ' '), g.cellChar(0, 2));
try std.testing.expectEqual(@as(?u8, ' '), g.cellChar(9, 0));
// Outside the grid is a different answer: nothing to compare against.
try std.testing.expectEqual(@as(?u8, null), g.cellChar(0, 80));
}
// Forces semantic analysis of every pub decl under `zig build test`, so an
// unreferenced decl must at least compile (the silent-module-loss hazard,
// decisions.md). Pub decls only: std.meta.declarations sees nothing private.
test {
std.testing.refAllDeclsRecursive(@This());
}