da104e26
feat: prediction overlay — speculation as a layer, never as state
a73x 2026-08-08 15:01
Commit message
build.zig
| Old | New | ||
|---|---|---|---|
| @@ -90,6 +90,17 @@ pub fn build(b: *std.Build) void { | |||
| 90 | .link_libc = true, | 90 | .link_libc = true, |
| 91 | }); | 91 | }); |
| 92 | 92 | ||
| 93 | // Speculative local echo: the overlay and its policy, and deliberately | ||
| 94 | // nothing else. No engine import, which is what lets the whole state | ||
| 95 | // machine be exercised without a terminal — or a daemon — anywhere in | ||
| 96 | // the picture; reconcile takes its grid duck-typed instead. | ||
| 97 | const predict_mod = b.createModule(.{ | ||
| 98 | .root_source_file = b.path("src/predict.zig"), | ||
| 99 | .target = target, | ||
| 100 | .optimize = optimize, | ||
| 101 | }); | ||
| 102 | predict_mod.addImport("protocol", protocol_mod); | ||
| 103 | |||
| 93 | // Test-only: short temp paths for the tests that bind unix sockets. | 104 | // Test-only: short temp paths for the tests that bind unix sockets. |
| 94 | // Imported by every module that has such a test, which is why it is a | 105 | // Imported by every module that has such a test, which is why it is a |
| 95 | // module rather than three copies. | 106 | // module rather than three copies. |
| @@ -190,7 +201,7 @@ pub fn build(b: *std.Build) void { | |||
| 190 | // absence here was a live hazard recorded in decisions.md — muxd's | 201 | // absence here was a live hazard recorded in decisions.md — muxd's |
| 191 | // entrypoint could grow tests that silently never ran, exactly as | 202 | // entrypoint could grow tests that silently never ran, exactly as |
| 192 | // mux_main.zig's five did before it was added. | 203 | // mux_main.zig's five did before it was added. |
| 193 | for ([_]*std.Build.Module{ protocol_mod, engine_mod, pty_mod, server_mod, client_mod, proxy_mod, mux_mod, quic_mod, exe_mod, testtmp_mod, quic_client_mod }) |mod| { | 204 | for ([_]*std.Build.Module{ protocol_mod, engine_mod, pty_mod, server_mod, client_mod, proxy_mod, mux_mod, quic_mod, exe_mod, testtmp_mod, quic_client_mod, predict_mod }) |mod| { |
| 194 | const t = b.addTest(.{ .root_module = mod }); | 205 | const t = b.addTest(.{ .root_module = mod }); |
| 195 | t.use_llvm = true; | 206 | t.use_llvm = true; |
| 196 | t.use_lld = true; | 207 | t.use_lld = true; |
src/predict.zig
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,823 @@ | |||
| 1 | //! Speculative local echo, as an OVERLAY. | ||
| 2 | //! | ||
| 3 | //! The client's replica keeps tracking exactly what the daemon said, and | ||
| 4 | //! nothing in here is ever fed into it. Predictions live in a small queue | ||
| 5 | //! beside it, painted on top and reconciled cell by cell as authoritative | ||
| 6 | //! frames land. That separation is the whole design: a wrong prediction | ||
| 7 | //! costs a repaint, never a desync, and `muxd dump` and the client grid | ||
| 8 | //! stay comparable byte for byte at every moment. | ||
| 9 | //! | ||
| 10 | //! Engine-free on purpose. Nothing here imports a terminal, so the entire | ||
| 11 | //! policy — which contexts predict, what earns the right to be seen, what | ||
| 12 | //! takes it away — is exercised by tests with no engine, no pty and no | ||
| 13 | //! daemon in the picture. `reconcile` takes its grid duck-typed: anything | ||
| 14 | //! with `cellChar(row, col) ?u8` will do, and `PlainGrid` below is the | ||
| 15 | //! adapter over the plain dump a client already has. | ||
| 16 | //! | ||
| 17 | //! Memory: the overlay owns everything it holds. Predictions are copies of | ||
| 18 | //! bytes, never slices into frame payloads or engine rows, and the queue is | ||
| 19 | //! read back by index rather than handed out as a slice — a slice would go | ||
| 20 | //! stale on the next append, which is the shape the M8 egress records call | ||
| 21 | //! the UAF-that-never-crashes. | ||
| 22 | const std = @import("std"); | ||
| 23 | const proto = @import("protocol"); | ||
| 24 | |||
| 25 | /// One predicted character at one place on the grid. Printable ASCII only | ||
| 26 | /// in M9: `ch` is a byte by value, so there is nothing here that can outlive | ||
| 27 | /// what it was copied from. | ||
| 28 | pub const Cell = struct { row: u16, col: u16, ch: u8 }; | ||
| 29 | |||
| 30 | pub const Pred = struct { | ||
| 31 | cell: Cell, | ||
| 32 | /// The authoritative seq the client held when this was predicted. A | ||
| 33 | /// frame carrying a HIGHER seq is the first one that could possibly | ||
| 34 | /// have been built after the keystroke reached the daemon, and so the | ||
| 35 | /// first one entitled to have an opinion about it. | ||
| 36 | made_seq: u64, | ||
| 37 | }; | ||
| 38 | |||
| 39 | pub const Counters = struct { | ||
| 40 | made: u64 = 0, | ||
| 41 | displayed: u64 = 0, | ||
| 42 | confirmed: u64 = 0, | ||
| 43 | contradicted: u64 = 0, | ||
| 44 | suppressed: u64 = 0, | ||
| 45 | }; | ||
| 46 | |||
| 47 | /// What the pty's mode bits say about predicting here. | ||
| 48 | /// .always — icanon && echo: the line discipline is going to print the | ||
| 49 | /// character itself, so predicting it is deduction. | ||
| 50 | /// .never — icanon && !echo: a password prompt. Nothing is predicted, | ||
| 51 | /// so there is nothing to leak, hide, or get wrong. | ||
| 52 | /// .adaptive — !icanon: raw mode. The application decides what a | ||
| 53 | /// keystroke looks like and we have to earn the right to | ||
| 54 | /// guess by being repeatedly right. | ||
| 55 | pub const Context = enum { always, never, adaptive }; | ||
| 56 | |||
| 57 | /// Consecutive confirmations that earn display in `.adaptive`. | ||
| 58 | pub const promote_after: u8 = 2; | ||
| 59 | |||
| 60 | /// Engine-free mirror of the engine's cursor position. | ||
| 61 | pub const CursorPos = struct { x: u16 = 0, y: u16 = 0 }; | ||
| 62 | |||
| 63 | pub const Outcome = union(enum) { | ||
| 64 | /// Refused. Nothing queued, nothing painted; the keystroke still goes | ||
| 65 | /// to the daemon exactly as it would have. | ||
| 66 | suppressed, | ||
| 67 | /// Queued, and to be painted at this cell now. | ||
| 68 | display: Cell, | ||
| 69 | /// Queued but deliberately invisible — adaptive mode gathering the | ||
| 70 | /// evidence that would let the next one be seen. The paint decision | ||
| 71 | /// arrives with the cell rather than being a separate question the | ||
| 72 | /// caller has to remember to ask, because "displayed" is what leg 3 of | ||
| 73 | /// the criterion counts and a forgotten check is how it gets violated. | ||
| 74 | hidden: Cell, | ||
| 75 | }; | ||
| 76 | |||
| 77 | pub const Verdict = enum { | ||
| 78 | /// Nothing pending was old enough to judge. | ||
| 79 | none, | ||
| 80 | /// At least one prediction was confirmed and retired; none were wrong. | ||
| 81 | confirmed, | ||
| 82 | /// One was wrong, so the queue is empty and the caller should repaint. | ||
| 83 | contradicted, | ||
| 84 | }; | ||
| 85 | |||
| 86 | /// Reads cells out of a plain grid dump: rows joined by '\n', trailing | ||
| 87 | /// blanks absent, which is the shape `Engine.dumpPlain` produces. Holds a | ||
| 88 | /// borrowed slice and is meant to be built, used and dropped inside one | ||
| 89 | /// reconcile call — never stored. | ||
| 90 | pub const PlainGrid = struct { | ||
| 91 | text: []const u8, | ||
| 92 | cols: u16, | ||
| 93 | |||
| 94 | /// null means "outside the grid", which is a different answer from "a | ||
| 95 | /// blank cell": a dump carries no trailing blanks, so a column past the | ||
| 96 | /// end of a row, or a row past the end of the dump, is blank rather | ||
| 97 | /// than missing. A cell holding a multi-byte character answers with its | ||
| 98 | /// lead byte, which cannot equal a predicted printable ASCII byte — | ||
| 99 | /// so such a cell contradicts, which is the safe direction. | ||
| 100 | pub fn cellChar(self: PlainGrid, row: u16, col: u16) ?u8 { | ||
| 101 | if (col >= self.cols) return null; | ||
| 102 | var y: u16 = 0; | ||
| 103 | var it = std.mem.splitScalar(u8, self.text, '\n'); | ||
| 104 | while (it.next()) |line| : (y += 1) { | ||
| 105 | if (y != row) continue; | ||
| 106 | if (col >= line.len) return ' '; | ||
| 107 | return line[col]; | ||
| 108 | } | ||
| 109 | return ' '; | ||
| 110 | } | ||
| 111 | }; | ||
| 112 | |||
| 113 | pub const Overlay = struct { | ||
| 114 | alloc: std.mem.Allocator, | ||
| 115 | pending: std.ArrayList(Pred) = .empty, | ||
| 116 | /// Cells removed by the most recent reconcile — the caller's repaint | ||
| 117 | /// list, since a retired prediction is still on the screen underlined | ||
| 118 | /// until someone paints that cell from the replica. Never accumulates | ||
| 119 | /// across calls. Its capacity is kept at or above `pending.len` by | ||
| 120 | /// predictAt, which is what lets reconcile fill it without being able | ||
| 121 | /// to fail partway through a judgement. | ||
| 122 | retired: std.ArrayList(Cell) = .empty, | ||
| 123 | ctx: Context = .never, | ||
| 124 | /// The single gate on painting: true in `.always`, false in `.never`, | ||
| 125 | /// and earned in `.adaptive`. | ||
| 126 | confident: bool = false, | ||
| 127 | streak: u8 = 0, | ||
| 128 | counters: Counters = .{}, | ||
| 129 | cols: u16, | ||
| 130 | rows: u16, | ||
| 131 | scroll_mode: bool = false, | ||
| 132 | resize_pending: bool = false, | ||
| 133 | /// The last authoritative seq the client applied; stamped onto each new | ||
| 134 | /// prediction as `made_seq`. | ||
| 135 | last_seq: u64 = 0, | ||
| 136 | |||
| 137 | pub fn init(alloc: std.mem.Allocator, cols: u16, rows: u16) Overlay { | ||
| 138 | return .{ .alloc = alloc, .cols = cols, .rows = rows }; | ||
| 139 | } | ||
| 140 | |||
| 141 | pub fn deinit(self: *Overlay) void { | ||
| 142 | self.pending.deinit(self.alloc); | ||
| 143 | self.retired.deinit(self.alloc); | ||
| 144 | } | ||
| 145 | |||
| 146 | /// Adopt what the daemon says the pty is doing. No frame ever having | ||
| 147 | /// arrived leaves the overlay at `.never`, which is the safe default an | ||
| 148 | /// old daemon gets for free. | ||
| 149 | pub fn setMode(self: *Overlay, flags: proto.PtyModeFlags) void { | ||
| 150 | const next: Context = blk: { | ||
| 151 | // A bit we do not understand means the byte describes a | ||
| 152 | // terminal we cannot reason about. Predict nothing rather than | ||
| 153 | // mask it off and carry on as though we had understood. | ||
| 154 | if (flags._pad != 0) break :blk .never; | ||
| 155 | if (!flags.icanon) break :blk .adaptive; | ||
| 156 | break :blk if (flags.echo) .always else .never; | ||
| 157 | }; | ||
| 158 | if (next == self.ctx) return; | ||
| 159 | self.ctx = next; | ||
| 160 | // Outstanding predictions were made under the old policy, and the | ||
| 161 | // new one may be that they should never have been visible. | ||
| 162 | self.flush(); | ||
| 163 | self.streak = 0; | ||
| 164 | self.confident = (next == .always); | ||
| 165 | } | ||
| 166 | |||
| 167 | /// A resize invalidates every prediction on the old grid, and moves the | ||
| 168 | /// edge that the last-column refusal is measured against. | ||
| 169 | pub fn setGrid(self: *Overlay, cols: u16, rows: u16) void { | ||
| 170 | if (cols == self.cols and rows == self.rows) return; | ||
| 171 | self.cols = cols; | ||
| 172 | self.rows = rows; | ||
| 173 | self.flush(); | ||
| 174 | } | ||
| 175 | |||
| 176 | pub fn setScrollMode(self: *Overlay, on: bool) void { | ||
| 177 | if (on == self.scroll_mode) return; | ||
| 178 | self.scroll_mode = on; | ||
| 179 | // Entering scroll mode the cursor stops being where the user is | ||
| 180 | // looking; leaving it, the whole viewport is repainted. Either way | ||
| 181 | // what is queued no longer describes the screen. | ||
| 182 | self.flush(); | ||
| 183 | } | ||
| 184 | |||
| 185 | pub fn setResizePending(self: *Overlay, pending: bool) void { | ||
| 186 | if (pending == self.resize_pending) return; | ||
| 187 | self.resize_pending = pending; | ||
| 188 | if (pending) self.flush(); | ||
| 189 | } | ||
| 190 | |||
| 191 | /// Record the authoritative seq the client now holds. reconcile does | ||
| 192 | /// this itself; the client calls it directly on the paths that apply a | ||
| 193 | /// frame without judging anything, a snapshot being the one that | ||
| 194 | /// matters. | ||
| 195 | pub fn noteSeq(self: *Overlay, seq: u64) void { | ||
| 196 | self.last_seq = seq; | ||
| 197 | } | ||
| 198 | |||
| 199 | /// Speculate one printable byte at the cursor, or refuse to. | ||
| 200 | /// | ||
| 201 | /// Every refusal is a place where being wrong would cost more than | ||
| 202 | /// being slow: a control byte we cannot render, a multi-byte sequence | ||
| 203 | /// whose width we do not know, the last column (wrap is the | ||
| 204 | /// application's policy, not ours), a viewport that is scrolled away | ||
| 205 | /// from the cursor, a grid that is about to be resized out from under | ||
| 206 | /// the paint, and a `.never` context where the answer is the whole | ||
| 207 | /// point. Infallible by construction: an allocation failure suppresses | ||
| 208 | /// rather than propagating, because no keystroke is worth failing over | ||
| 209 | /// a speculation. | ||
| 210 | pub fn predictAt(self: *Overlay, cursor: CursorPos, ch: u8) Outcome { | ||
| 211 | if (self.ctx == .never) return self.suppress(); | ||
| 212 | if (self.scroll_mode or self.resize_pending) return self.suppress(); | ||
| 213 | if (ch < 0x20 or ch >= 0x7f) return self.suppress(); | ||
| 214 | if (self.cols == 0 or self.rows == 0) return self.suppress(); | ||
| 215 | if (cursor.y >= self.rows) return self.suppress(); | ||
| 216 | if (cursor.x >= self.cols -| 1) return self.suppress(); | ||
| 217 | |||
| 218 | const cell: Cell = .{ .row = cursor.y, .col = cursor.x, .ch = ch }; | ||
| 219 | // Reserved before the queue grows, so reconcile can retire every | ||
| 220 | // pending cell into `retired` without a fallible call in the middle | ||
| 221 | // of a judgement it has already half-made. | ||
| 222 | self.retired.ensureTotalCapacity(self.alloc, self.pending.items.len + 1) catch | ||
| 223 | return self.suppress(); | ||
| 224 | self.pending.append(self.alloc, .{ .cell = cell, .made_seq = self.last_seq }) catch | ||
| 225 | return self.suppress(); | ||
| 226 | |||
| 227 | self.counters.made += 1; | ||
| 228 | if (self.confident) { | ||
| 229 | self.counters.displayed += 1; | ||
| 230 | return .{ .display = cell }; | ||
| 231 | } | ||
| 232 | return .{ .hidden = cell }; | ||
| 233 | } | ||
| 234 | |||
| 235 | fn suppress(self: *Overlay) Outcome { | ||
| 236 | self.counters.suppressed += 1; | ||
| 237 | return .suppressed; | ||
| 238 | } | ||
| 239 | |||
| 240 | /// Judge everything the newly applied frame is entitled to judge. | ||
| 241 | /// | ||
| 242 | /// A confirmed prediction retires and lengthens the streak. A | ||
| 243 | /// contradicted one takes the WHOLE queue with it — mosh's epoch bump, | ||
| 244 | /// and the reason is not economy: every prediction made after a wrong | ||
| 245 | /// one was made against a screen that never existed, so retiring only | ||
| 246 | /// the wrong cell would leave the rest to be "confirmed" against a | ||
| 247 | /// reality they were never predicting. | ||
| 248 | pub fn reconcile(self: *Overlay, reader: anytype, applied_seq: u64) Verdict { | ||
| 249 | self.retired.clearRetainingCapacity(); | ||
| 250 | var verdict: Verdict = .none; | ||
| 251 | var i: usize = 0; | ||
| 252 | while (i < self.pending.items.len) { | ||
| 253 | const p = self.pending.items[i]; | ||
| 254 | if (p.made_seq >= applied_seq) { | ||
| 255 | i += 1; // too new to be evidence about | ||
| 256 | continue; | ||
| 257 | } | ||
| 258 | const shown = reader.cellChar(p.cell.row, p.cell.col); | ||
| 259 | if (shown != null and shown.? == p.cell.ch) { | ||
| 260 | _ = self.pending.orderedRemove(i); | ||
| 261 | self.retired.appendAssumeCapacity(p.cell); | ||
| 262 | self.counters.confirmed += 1; | ||
| 263 | self.streak +|= 1; | ||
| 264 | if (self.ctx == .adaptive and self.streak >= promote_after) { | ||
| 265 | self.confident = true; | ||
| 266 | } | ||
| 267 | verdict = .confirmed; | ||
| 268 | continue; // index i now holds the next prediction | ||
| 269 | } | ||
| 270 | |||
| 271 | self.counters.contradicted += 1; | ||
| 272 | self.streak = 0; | ||
| 273 | // Demotion is an adaptive-only idea. In canonical echo the pty | ||
| 274 | // is going to print the character whatever we believe, so a | ||
| 275 | // disagreement means we put it in the wrong place, not that we | ||
| 276 | // should stop predicting. | ||
| 277 | if (self.ctx == .adaptive) self.confident = false; | ||
| 278 | for (self.pending.items) |q| self.retired.appendAssumeCapacity(q.cell); | ||
| 279 | self.pending.clearRetainingCapacity(); | ||
| 280 | self.noteSeq(applied_seq); | ||
| 281 | return .contradicted; | ||
| 282 | } | ||
| 283 | self.noteSeq(applied_seq); | ||
| 284 | return verdict; | ||
| 285 | } | ||
| 286 | |||
| 287 | /// Drop every prediction without calling any of them wrong. For the | ||
| 288 | /// events after which we can no longer find out — a snapshot, a resize, | ||
| 289 | /// scroll mode, a reconnect. Counting these as contradictions would fire | ||
| 290 | /// the demotion machinery on a window resize. | ||
| 291 | pub fn flush(self: *Overlay) void { | ||
| 292 | self.pending.clearRetainingCapacity(); | ||
| 293 | self.retired.clearRetainingCapacity(); | ||
| 294 | } | ||
| 295 | |||
| 296 | /// Where the cursor appears to be, given what is queued: the | ||
| 297 | /// authoritative position advanced past the last pending prediction. | ||
| 298 | /// With nothing pending it is the daemon's own answer, untouched. | ||
| 299 | pub fn predictedCursor(self: *const Overlay, base: CursorPos) CursorPos { | ||
| 300 | const last = self.pending.getLastOrNull() orelse return base; | ||
| 301 | return .{ .x = last.cell.col + 1, .y = last.cell.row }; | ||
| 302 | } | ||
| 303 | |||
| 304 | pub fn pendingCount(self: *const Overlay) usize { | ||
| 305 | return self.pending.items.len; | ||
| 306 | } | ||
| 307 | |||
| 308 | /// By value: the queue reallocates as it grows, so handing out a slice | ||
| 309 | /// into it would hand out something that goes stale on the next | ||
| 310 | /// keystroke. | ||
| 311 | pub fn pendingAt(self: *const Overlay, i: usize) Pred { | ||
| 312 | return self.pending.items[i]; | ||
| 313 | } | ||
| 314 | |||
| 315 | pub fn retiredCount(self: *const Overlay) usize { | ||
| 316 | return self.retired.items.len; | ||
| 317 | } | ||
| 318 | |||
| 319 | pub fn retiredAt(self: *const Overlay, i: usize) Cell { | ||
| 320 | return self.retired.items[i]; | ||
| 321 | } | ||
| 322 | }; | ||
| 323 | |||
| 324 | // ---- tests ------------------------------------------------------------ | ||
| 325 | |||
| 326 | /// A grid row set built the way a plain dump arrives: rows joined by '\n', | ||
| 327 | /// trailing blanks absent. Caller frees. | ||
| 328 | fn plainOf(alloc: std.mem.Allocator, rows: []const []const u8) ![]u8 { | ||
| 329 | return std.mem.join(alloc, "\n", rows); | ||
| 330 | } | ||
| 331 | |||
| 332 | test "setMode maps the pty's two bits onto the three policies" { | ||
| 333 | const alloc = std.testing.allocator; | ||
| 334 | const cases = [_]struct { icanon: bool, echo: bool, want: Context }{ | ||
| 335 | // Canonical and echoing: the tty itself will put the character on | ||
| 336 | // the screen, so predicting it is not a guess at all. | ||
| 337 | .{ .icanon = true, .echo = true, .want = .always }, | ||
| 338 | // Canonical and silent: a password prompt. Nothing may be shown. | ||
| 339 | .{ .icanon = true, .echo = false, .want = .never }, | ||
| 340 | // Raw: the application decides what a keystroke looks like, and the | ||
| 341 | // only way to find out is to be right about it repeatedly. | ||
| 342 | .{ .icanon = false, .echo = true, .want = .adaptive }, | ||
| 343 | .{ .icanon = false, .echo = false, .want = .adaptive }, | ||
| 344 | }; | ||
| 345 | for (cases) |c| { | ||
| 346 | var ov = Overlay.init(alloc, 80, 24); | ||
| 347 | defer ov.deinit(); | ||
| 348 | ov.setMode(.{ .icanon = c.icanon, .echo = c.echo }); | ||
| 349 | try std.testing.expectEqual(c.want, ov.ctx); | ||
| 350 | } | ||
| 351 | } | ||
| 352 | |||
| 353 | test "a mode byte carrying a bit we do not understand predicts nothing" { | ||
| 354 | const alloc = std.testing.allocator; | ||
| 355 | var ov = Overlay.init(alloc, 80, 24); | ||
| 356 | defer ov.deinit(); | ||
| 357 | ov.setMode(.{ .icanon = true, .echo = true }); | ||
| 358 | try std.testing.expectEqual(Context.always, ov.ctx); | ||
| 359 | |||
| 360 | // A future daemon defines a third bit. Read as canonical-and-echoing | ||
| 361 | // with the extra bit masked away, this would keep predicting against a | ||
| 362 | // terminal whose description we have only partly understood. | ||
| 363 | ov.setMode(.{ .icanon = true, .echo = true, ._pad = 1 }); | ||
| 364 | try std.testing.expectEqual(Context.never, ov.ctx); | ||
| 365 | try std.testing.expect(ov.predictAt(.{ .x = 0, .y = 0 }, 'a') == .suppressed); | ||
| 366 | } | ||
| 367 | |||
| 368 | test "an overlay predicts nothing until it has been told what the pty is" { | ||
| 369 | const alloc = std.testing.allocator; | ||
| 370 | var ov = Overlay.init(alloc, 80, 24); | ||
| 371 | defer ov.deinit(); | ||
| 372 | // No pty_mode frame has arrived (or the daemon is too old to send one). | ||
| 373 | // No frame, no prediction: the safe direction is the default one. | ||
| 374 | try std.testing.expectEqual(Context.never, ov.ctx); | ||
| 375 | try std.testing.expect(ov.predictAt(.{ .x = 0, .y = 0 }, 'a') == .suppressed); | ||
| 376 | try std.testing.expectEqual(@as(u64, 0), ov.counters.made); | ||
| 377 | } | ||
| 378 | |||
| 379 | test "always: the first keystroke paints, with no evidence required" { | ||
| 380 | const alloc = std.testing.allocator; | ||
| 381 | var ov = Overlay.init(alloc, 80, 24); | ||
| 382 | defer ov.deinit(); | ||
| 383 | ov.setMode(.{ .icanon = true, .echo = true }); | ||
| 384 | |||
| 385 | const out = ov.predictAt(.{ .x = 3, .y = 2 }, 'k'); | ||
| 386 | try std.testing.expect(out == .display); | ||
| 387 | try std.testing.expectEqual(@as(u16, 3), out.display.col); | ||
| 388 | try std.testing.expectEqual(@as(u16, 2), out.display.row); | ||
| 389 | try std.testing.expectEqual(@as(u8, 'k'), out.display.ch); | ||
| 390 | try std.testing.expectEqual(@as(u64, 1), ov.counters.made); | ||
| 391 | try std.testing.expectEqual(@as(u64, 1), ov.counters.displayed); | ||
| 392 | try std.testing.expectEqual(@as(usize, 1), ov.pendingCount()); | ||
| 393 | } | ||
| 394 | |||
| 395 | test "never: nothing is made, so nothing can leak" { | ||
| 396 | const alloc = std.testing.allocator; | ||
| 397 | var ov = Overlay.init(alloc, 80, 24); | ||
| 398 | defer ov.deinit(); | ||
| 399 | ov.setMode(.{ .icanon = true, .echo = false }); | ||
| 400 | |||
| 401 | for ("hunter2") |ch| { | ||
| 402 | try std.testing.expect(ov.predictAt(.{ .x = 0, .y = 0 }, ch) == .suppressed); | ||
| 403 | } | ||
| 404 | // Both counters, deliberately: leg 3 of the criterion asserts made as | ||
| 405 | // well as displayed, because "made but hidden" in a password context | ||
| 406 | // would still put the password in a buffer the overlay paints from. | ||
| 407 | try std.testing.expectEqual(@as(u64, 0), ov.counters.made); | ||
| 408 | try std.testing.expectEqual(@as(u64, 0), ov.counters.displayed); | ||
| 409 | try std.testing.expectEqual(@as(u64, 7), ov.counters.suppressed); | ||
| 410 | try std.testing.expectEqual(@as(usize, 0), ov.pendingCount()); | ||
| 411 | } | ||
| 412 | |||
| 413 | test "adaptive earns the right to display, one confirm at a time" { | ||
| 414 | const alloc = std.testing.allocator; | ||
| 415 | var ov = Overlay.init(alloc, 80, 24); | ||
| 416 | defer ov.deinit(); | ||
| 417 | ov.setMode(.{ .icanon = false, .echo = false }); | ||
| 418 | |||
| 419 | // Written out with literal counts rather than a loop over promote_after: | ||
| 420 | // a loop parameterised by the constant moves its own goalposts when the | ||
| 421 | // constant is mutated, and would have passed at 1 and at 3 alike. | ||
| 422 | try std.testing.expect(ov.predictAt(.{ .x = 0, .y = 0 }, 'a') == .hidden); | ||
| 423 | { | ||
| 424 | const text = try plainOf(alloc, &.{"a"}); | ||
| 425 | defer alloc.free(text); | ||
| 426 | try std.testing.expectEqual( | ||
| 427 | Verdict.confirmed, | ||
| 428 | ov.reconcile(PlainGrid{ .text = text, .cols = 80 }, 1), | ||
| 429 | ); | ||
| 430 | } | ||
| 431 | |||
| 432 | // Still hidden: one confirm is not two. | ||
| 433 | try std.testing.expect(ov.predictAt(.{ .x = 1, .y = 0 }, 'b') == .hidden); | ||
| 434 | { | ||
| 435 | const text = try plainOf(alloc, &.{"ab"}); | ||
| 436 | defer alloc.free(text); | ||
| 437 | try std.testing.expectEqual( | ||
| 438 | Verdict.confirmed, | ||
| 439 | ov.reconcile(PlainGrid{ .text = text, .cols = 80 }, 2), | ||
| 440 | ); | ||
| 441 | } | ||
| 442 | |||
| 443 | try std.testing.expectEqual(@as(u64, 2), ov.counters.confirmed); | ||
| 444 | try std.testing.expectEqual(@as(u64, 0), ov.counters.displayed); | ||
| 445 | |||
| 446 | // Two consecutive confirms, and the third keystroke paints. | ||
| 447 | try std.testing.expect(ov.predictAt(.{ .x = 2, .y = 0 }, 'c') == .display); | ||
| 448 | try std.testing.expectEqual(@as(u64, 3), ov.counters.made); | ||
| 449 | try std.testing.expectEqual(@as(u64, 1), ov.counters.displayed); | ||
| 450 | } | ||
| 451 | |||
| 452 | test "a contradiction flushes the whole queue, not merely the cell that was wrong" { | ||
| 453 | const alloc = std.testing.allocator; | ||
| 454 | var ov = Overlay.init(alloc, 80, 24); | ||
| 455 | defer ov.deinit(); | ||
| 456 | ov.setMode(.{ .icanon = true, .echo = true }); | ||
| 457 | |||
| 458 | _ = ov.predictAt(.{ .x = 0, .y = 0 }, 'a'); | ||
| 459 | _ = ov.predictAt(.{ .x = 1, .y = 0 }, 'b'); | ||
| 460 | _ = ov.predictAt(.{ .x = 2, .y = 0 }, 'c'); | ||
| 461 | try std.testing.expectEqual(@as(usize, 3), ov.pendingCount()); | ||
| 462 | |||
| 463 | // The replica disagrees about the FIRST cell. The two behind it would | ||
| 464 | // each have matched — which is the point: everything typed after a | ||
| 465 | // wrong prediction was typed into a screen that never existed. | ||
| 466 | const text = try plainOf(alloc, &.{"xbc"}); | ||
| 467 | defer alloc.free(text); | ||
| 468 | try std.testing.expectEqual( | ||
| 469 | Verdict.contradicted, | ||
| 470 | ov.reconcile(PlainGrid{ .text = text, .cols = 80 }, 1), | ||
| 471 | ); | ||
| 472 | |||
| 473 | try std.testing.expectEqual(@as(usize, 0), ov.pendingCount()); | ||
| 474 | try std.testing.expectEqual(@as(u64, 1), ov.counters.contradicted); | ||
| 475 | // Zero, not two. An implementation that retired only the wrong cell and | ||
| 476 | // carried on judging would count the other two as confirmed, and would | ||
| 477 | // be claiming agreement about a screen it had already been told it was | ||
| 478 | // wrong about. | ||
| 479 | try std.testing.expectEqual(@as(u64, 0), ov.counters.confirmed); | ||
| 480 | } | ||
| 481 | |||
| 482 | test "adaptive is demoted by one contradiction and must earn display again" { | ||
| 483 | const alloc = std.testing.allocator; | ||
| 484 | var ov = Overlay.init(alloc, 80, 24); | ||
| 485 | defer ov.deinit(); | ||
| 486 | ov.setMode(.{ .icanon = false, .echo = false }); | ||
| 487 | |||
| 488 | // Earn it. | ||
| 489 | try std.testing.expect(ov.predictAt(.{ .x = 0, .y = 0 }, 'a') == .hidden); | ||
| 490 | { | ||
| 491 | const text = try plainOf(alloc, &.{"a"}); | ||
| 492 | defer alloc.free(text); | ||
| 493 | _ = ov.reconcile(PlainGrid{ .text = text, .cols = 80 }, 1); | ||
| 494 | } | ||
| 495 | try std.testing.expect(ov.predictAt(.{ .x = 1, .y = 0 }, 'b') == .hidden); | ||
| 496 | { | ||
| 497 | const text = try plainOf(alloc, &.{"ab"}); | ||
| 498 | defer alloc.free(text); | ||
| 499 | _ = ov.reconcile(PlainGrid{ .text = text, .cols = 80 }, 2); | ||
| 500 | } | ||
| 501 | try std.testing.expect(ov.predictAt(.{ .x = 2, .y = 0 }, 'c') == .display); | ||
| 502 | |||
| 503 | // Lose it, on the first keystroke the application handles its own way — | ||
| 504 | // a normal-mode key in an editor, say, which prints nothing at all. | ||
| 505 | { | ||
| 506 | const text = try plainOf(alloc, &.{"ab"}); | ||
| 507 | defer alloc.free(text); | ||
| 508 | try std.testing.expectEqual( | ||
| 509 | Verdict.contradicted, | ||
| 510 | ov.reconcile(PlainGrid{ .text = text, .cols = 80 }, 3), | ||
| 511 | ); | ||
| 512 | } | ||
| 513 | try std.testing.expect(!ov.confident); | ||
| 514 | |||
| 515 | // ...and the very next keystroke is invisible again. One contradicted | ||
| 516 | // prediction, one demotion: that is leg 3 of the criterion. | ||
| 517 | try std.testing.expect(ov.predictAt(.{ .x = 2, .y = 0 }, 'c') == .hidden); | ||
| 518 | } | ||
| 519 | |||
| 520 | test "always is never demoted: a contradiction costs the queue, not the policy" { | ||
| 521 | const alloc = std.testing.allocator; | ||
| 522 | var ov = Overlay.init(alloc, 80, 24); | ||
| 523 | defer ov.deinit(); | ||
| 524 | ov.setMode(.{ .icanon = true, .echo = true }); | ||
| 525 | |||
| 526 | _ = ov.predictAt(.{ .x = 0, .y = 0 }, 'a'); | ||
| 527 | const text = try plainOf(alloc, &.{"z"}); | ||
| 528 | defer alloc.free(text); | ||
| 529 | try std.testing.expectEqual( | ||
| 530 | Verdict.contradicted, | ||
| 531 | ov.reconcile(PlainGrid{ .text = text, .cols = 80 }, 1), | ||
| 532 | ); | ||
| 533 | // In canonical echo the pty is going to print the character whatever we | ||
| 534 | // do, so a disagreement means we mis-placed it, not that we should stop | ||
| 535 | // predicting. Confidence here is not earned and cannot be lost. | ||
| 536 | try std.testing.expect(ov.predictAt(.{ .x = 0, .y = 0 }, 'a') == .display); | ||
| 537 | } | ||
| 538 | |||
| 539 | test "predictAt refuses everything the plan says it must" { | ||
| 540 | const alloc = std.testing.allocator; | ||
| 541 | |||
| 542 | // Non-printables: control bytes carry meaning we cannot render, and the | ||
| 543 | // high half is a multi-byte sequence whose width we do not know. | ||
| 544 | for ([_]u8{ 0x00, 0x08, 0x09, 0x0a, 0x0d, 0x1b, 0x7f, 0x80, 0xc3, 0xff }) |ch| { | ||
| 545 | var ov = Overlay.init(alloc, 80, 24); | ||
| 546 | defer ov.deinit(); | ||
| 547 | ov.setMode(.{ .icanon = true, .echo = true }); | ||
| 548 | try std.testing.expect(ov.predictAt(.{ .x = 0, .y = 0 }, ch) == .suppressed); | ||
| 549 | try std.testing.expectEqual(@as(u64, 1), ov.counters.suppressed); | ||
| 550 | } | ||
| 551 | |||
| 552 | // The last column: what happens there is the application's policy | ||
| 553 | // (wrap, scroll, truncate, refuse) and we do not get to guess it. | ||
| 554 | { | ||
| 555 | var ov = Overlay.init(alloc, 80, 24); | ||
| 556 | defer ov.deinit(); | ||
| 557 | ov.setMode(.{ .icanon = true, .echo = true }); | ||
| 558 | try std.testing.expect(ov.predictAt(.{ .x = 78, .y = 0 }, 'a') == .display); | ||
| 559 | try std.testing.expect(ov.predictAt(.{ .x = 79, .y = 0 }, 'a') == .suppressed); | ||
| 560 | } | ||
| 561 | |||
| 562 | // Off the grid entirely. | ||
| 563 | { | ||
| 564 | var ov = Overlay.init(alloc, 80, 24); | ||
| 565 | defer ov.deinit(); | ||
| 566 | ov.setMode(.{ .icanon = true, .echo = true }); | ||
| 567 | try std.testing.expect(ov.predictAt(.{ .x = 0, .y = 24 }, 'a') == .suppressed); | ||
| 568 | } | ||
| 569 | |||
| 570 | // Scrolled back: the cursor is not where the user is looking, so a | ||
| 571 | // prediction painted at it would land in the middle of history. | ||
| 572 | { | ||
| 573 | var ov = Overlay.init(alloc, 80, 24); | ||
| 574 | defer ov.deinit(); | ||
| 575 | ov.setMode(.{ .icanon = true, .echo = true }); | ||
| 576 | ov.setScrollMode(true); | ||
| 577 | try std.testing.expect(ov.predictAt(.{ .x = 0, .y = 0 }, 'a') == .suppressed); | ||
| 578 | ov.setScrollMode(false); | ||
| 579 | try std.testing.expect(ov.predictAt(.{ .x = 0, .y = 0 }, 'a') == .display); | ||
| 580 | } | ||
| 581 | |||
| 582 | // A resize we have asked for but not yet been answered about: the grid | ||
| 583 | // the prediction would be painted on is about to stop existing. | ||
| 584 | { | ||
| 585 | var ov = Overlay.init(alloc, 80, 24); | ||
| 586 | defer ov.deinit(); | ||
| 587 | ov.setMode(.{ .icanon = true, .echo = true }); | ||
| 588 | ov.setResizePending(true); | ||
| 589 | try std.testing.expect(ov.predictAt(.{ .x = 0, .y = 0 }, 'a') == .suppressed); | ||
| 590 | ov.setResizePending(false); | ||
| 591 | try std.testing.expect(ov.predictAt(.{ .x = 0, .y = 0 }, 'a') == .display); | ||
| 592 | } | ||
| 593 | } | ||
| 594 | |||
| 595 | test "a frame that cannot have seen the keystroke does not get to judge it" { | ||
| 596 | const alloc = std.testing.allocator; | ||
| 597 | var ov = Overlay.init(alloc, 80, 24); | ||
| 598 | defer ov.deinit(); | ||
| 599 | ov.setMode(.{ .icanon = true, .echo = true }); | ||
| 600 | |||
| 601 | // The client is holding seq 7 when the key is pressed. | ||
| 602 | ov.noteSeq(7); | ||
| 603 | _ = ov.predictAt(.{ .x = 0, .y = 0 }, 'a'); | ||
| 604 | |||
| 605 | // A frame numbered 7 is the one we already had. It is not evidence | ||
| 606 | // about a keystroke that was made after it. | ||
| 607 | const text = try plainOf(alloc, &.{" "}); | ||
| 608 | defer alloc.free(text); | ||
| 609 | try std.testing.expectEqual( | ||
| 610 | Verdict.none, | ||
| 611 | ov.reconcile(PlainGrid{ .text = text, .cols = 80 }, 7), | ||
| 612 | ); | ||
| 613 | try std.testing.expectEqual(@as(usize, 1), ov.pendingCount()); | ||
| 614 | try std.testing.expectEqual(@as(u64, 0), ov.counters.contradicted); | ||
| 615 | try std.testing.expectEqual(@as(u64, 0), ov.counters.confirmed); | ||
| 616 | |||
| 617 | // Seq 8 is the first frame the daemon could have built after seeing it. | ||
| 618 | try std.testing.expectEqual( | ||
| 619 | Verdict.contradicted, | ||
| 620 | ov.reconcile(PlainGrid{ .text = text, .cols = 80 }, 8), | ||
| 621 | ); | ||
| 622 | } | ||
| 623 | |||
| 624 | test "flush drops predictions without calling any of them wrong" { | ||
| 625 | const alloc = std.testing.allocator; | ||
| 626 | var ov = Overlay.init(alloc, 80, 24); | ||
| 627 | defer ov.deinit(); | ||
| 628 | ov.setMode(.{ .icanon = true, .echo = true }); | ||
| 629 | |||
| 630 | _ = ov.predictAt(.{ .x = 0, .y = 0 }, 'a'); | ||
| 631 | _ = ov.predictAt(.{ .x = 1, .y = 0 }, 'b'); | ||
| 632 | ov.flush(); | ||
| 633 | |||
| 634 | try std.testing.expectEqual(@as(usize, 0), ov.pendingCount()); | ||
| 635 | // A snapshot, a resize or a reconnect is not evidence that a prediction | ||
| 636 | // was mistaken — it is evidence that we can no longer find out. Counting | ||
| 637 | // it as a contradiction would make the demotion machinery fire on a | ||
| 638 | // window resize. | ||
| 639 | try std.testing.expectEqual(@as(u64, 0), ov.counters.contradicted); | ||
| 640 | try std.testing.expectEqual(@as(u64, 0), ov.counters.confirmed); | ||
| 641 | } | ||
| 642 | |||
| 643 | test "a context change flushes; staying in the same context does not" { | ||
| 644 | const alloc = std.testing.allocator; | ||
| 645 | var ov = Overlay.init(alloc, 80, 24); | ||
| 646 | defer ov.deinit(); | ||
| 647 | ov.setMode(.{ .icanon = true, .echo = true }); | ||
| 648 | _ = ov.predictAt(.{ .x = 0, .y = 0 }, 'a'); | ||
| 649 | _ = ov.predictAt(.{ .x = 1, .y = 0 }, 'b'); | ||
| 650 | |||
| 651 | // The same bits again: a daemon re-sending state on reattach must not | ||
| 652 | // throw away predictions that are still perfectly good. | ||
| 653 | ov.setMode(.{ .icanon = true, .echo = true }); | ||
| 654 | try std.testing.expectEqual(@as(usize, 2), ov.pendingCount()); | ||
| 655 | |||
| 656 | // Into a password prompt, with predictions outstanding. They go. | ||
| 657 | ov.setMode(.{ .icanon = true, .echo = false }); | ||
| 658 | try std.testing.expectEqual(@as(usize, 0), ov.pendingCount()); | ||
| 659 | } | ||
| 660 | |||
| 661 | test "entering raw mode starts unconfident however confident we just were" { | ||
| 662 | const alloc = std.testing.allocator; | ||
| 663 | var ov = Overlay.init(alloc, 80, 24); | ||
| 664 | defer ov.deinit(); | ||
| 665 | ov.setMode(.{ .icanon = true, .echo = true }); | ||
| 666 | try std.testing.expect(ov.confident); | ||
| 667 | |||
| 668 | ov.setMode(.{ .icanon = false, .echo = false }); | ||
| 669 | try std.testing.expect(!ov.confident); | ||
| 670 | try std.testing.expect(ov.predictAt(.{ .x = 0, .y = 0 }, 'a') == .hidden); | ||
| 671 | } | ||
| 672 | |||
| 673 | test "a resize flushes and moves the edge the last column is measured from" { | ||
| 674 | const alloc = std.testing.allocator; | ||
| 675 | var ov = Overlay.init(alloc, 80, 24); | ||
| 676 | defer ov.deinit(); | ||
| 677 | ov.setMode(.{ .icanon = true, .echo = true }); | ||
| 678 | _ = ov.predictAt(.{ .x = 0, .y = 0 }, 'a'); | ||
| 679 | |||
| 680 | ov.setGrid(40, 12); | ||
| 681 | try std.testing.expectEqual(@as(usize, 0), ov.pendingCount()); | ||
| 682 | try std.testing.expect(ov.predictAt(.{ .x = 39, .y = 0 }, 'a') == .suppressed); | ||
| 683 | try std.testing.expect(ov.predictAt(.{ .x = 38, .y = 0 }, 'a') == .display); | ||
| 684 | |||
| 685 | // The same size again is not a resize and costs nothing. | ||
| 686 | _ = ov.predictAt(.{ .x = 0, .y = 1 }, 'b'); | ||
| 687 | const before = ov.pendingCount(); | ||
| 688 | ov.setGrid(40, 12); | ||
| 689 | try std.testing.expectEqual(before, ov.pendingCount()); | ||
| 690 | } | ||
| 691 | |||
| 692 | test "predictedCursor advances the base past everything pending" { | ||
| 693 | const alloc = std.testing.allocator; | ||
| 694 | var ov = Overlay.init(alloc, 80, 24); | ||
| 695 | defer ov.deinit(); | ||
| 696 | ov.setMode(.{ .icanon = true, .echo = true }); | ||
| 697 | |||
| 698 | // With nothing pending the cursor is the daemon's, untouched. | ||
| 699 | try std.testing.expectEqual( | ||
| 700 | CursorPos{ .x = 5, .y = 1 }, | ||
| 701 | ov.predictedCursor(.{ .x = 5, .y = 1 }), | ||
| 702 | ); | ||
| 703 | |||
| 704 | var cur = CursorPos{ .x = 5, .y = 1 }; | ||
| 705 | for ("abc") |ch| { | ||
| 706 | _ = ov.predictAt(cur, ch); | ||
| 707 | cur = ov.predictedCursor(.{ .x = 5, .y = 1 }); | ||
| 708 | } | ||
| 709 | try std.testing.expectEqual(CursorPos{ .x = 8, .y = 1 }, cur); | ||
| 710 | |||
| 711 | // And it comes back to the authoritative cursor when the queue empties. | ||
| 712 | ov.flush(); | ||
| 713 | try std.testing.expectEqual( | ||
| 714 | CursorPos{ .x = 5, .y = 1 }, | ||
| 715 | ov.predictedCursor(.{ .x = 5, .y = 1 }), | ||
| 716 | ); | ||
| 717 | } | ||
| 718 | |||
| 719 | test "a queued prediction owns its byte; the caller's buffer may be reused" { | ||
| 720 | const alloc = std.testing.allocator; | ||
| 721 | var ov = Overlay.init(alloc, 80, 24); | ||
| 722 | defer ov.deinit(); | ||
| 723 | ov.setMode(.{ .icanon = true, .echo = true }); | ||
| 724 | |||
| 725 | // Exactly the shape of the client's stdin path: bytes are read into a | ||
| 726 | // buffer that is about to be read into again. Anything the overlay keeps | ||
| 727 | // pointing at that buffer is the UAF-that-never-crashes. | ||
| 728 | const src = try alloc.alloc(u8, 3); | ||
| 729 | defer alloc.free(src); | ||
| 730 | @memcpy(src, "abc"); | ||
| 731 | for (src, 0..) |ch, i| { | ||
| 732 | _ = ov.predictAt(.{ .x = @intCast(i), .y = 0 }, ch); | ||
| 733 | } | ||
| 734 | @memset(src, 0xFF); | ||
| 735 | |||
| 736 | try std.testing.expectEqual(@as(u8, 'a'), ov.pendingAt(0).cell.ch); | ||
| 737 | try std.testing.expectEqual(@as(u8, 'b'), ov.pendingAt(1).cell.ch); | ||
| 738 | try std.testing.expectEqual(@as(u8, 'c'), ov.pendingAt(2).cell.ch); | ||
| 739 | |||
| 740 | // And the judgement is made against what was typed, not against | ||
| 741 | // whatever the buffer holds by the time the frame comes back. | ||
| 742 | const text = try plainOf(alloc, &.{"abc"}); | ||
| 743 | defer alloc.free(text); | ||
| 744 | try std.testing.expectEqual( | ||
| 745 | Verdict.confirmed, | ||
| 746 | ov.reconcile(PlainGrid{ .text = text, .cols = 80 }, 1), | ||
| 747 | ); | ||
| 748 | try std.testing.expectEqual(@as(u64, 3), ov.counters.confirmed); | ||
| 749 | } | ||
| 750 | |||
| 751 | test "the queue survives its own growth" { | ||
| 752 | const alloc = std.testing.allocator; | ||
| 753 | var ov = Overlay.init(alloc, 80, 24); | ||
| 754 | defer ov.deinit(); | ||
| 755 | ov.setMode(.{ .icanon = true, .echo = true }); | ||
| 756 | |||
| 757 | // Well past any initial capacity, so the backing array is reallocated | ||
| 758 | // several times underneath the predictions already in it. | ||
| 759 | var i: u16 = 0; | ||
| 760 | while (i < 70) : (i += 1) { | ||
| 761 | const ch: u8 = 'a' + @as(u8, @intCast(i % 26)); | ||
| 762 | try std.testing.expect(ov.predictAt(.{ .x = i, .y = 0 }, ch) == .display); | ||
| 763 | } | ||
| 764 | try std.testing.expectEqual(@as(usize, 70), ov.pendingCount()); | ||
| 765 | |||
| 766 | i = 0; | ||
| 767 | while (i < 70) : (i += 1) { | ||
| 768 | const want: u8 = 'a' + @as(u8, @intCast(i % 26)); | ||
| 769 | try std.testing.expectEqual(want, ov.pendingAt(i).cell.ch); | ||
| 770 | try std.testing.expectEqual(i, ov.pendingAt(i).cell.col); | ||
| 771 | } | ||
| 772 | } | ||
| 773 | |||
| 774 | test "reconcile hands back the cells it retired, so the caller can repaint them" { | ||
| 775 | const alloc = std.testing.allocator; | ||
| 776 | var ov = Overlay.init(alloc, 80, 24); | ||
| 777 | defer ov.deinit(); | ||
| 778 | ov.setMode(.{ .icanon = true, .echo = true }); | ||
| 779 | |||
| 780 | _ = ov.predictAt(.{ .x = 0, .y = 0 }, 'a'); | ||
| 781 | _ = ov.predictAt(.{ .x = 1, .y = 0 }, 'b'); | ||
| 782 | const text = try plainOf(alloc, &.{"ab"}); | ||
| 783 | defer alloc.free(text); | ||
| 784 | try std.testing.expectEqual( | ||
| 785 | Verdict.confirmed, | ||
| 786 | ov.reconcile(PlainGrid{ .text = text, .cols = 80 }, 1), | ||
| 787 | ); | ||
| 788 | |||
| 789 | // A confirmed prediction is still painted underlined until someone | ||
| 790 | // repaints that cell from the replica, so the cells that left the queue | ||
| 791 | // are exactly the repaint list. | ||
| 792 | try std.testing.expectEqual(@as(usize, 2), ov.retiredCount()); | ||
| 793 | try std.testing.expectEqual(@as(u16, 0), ov.retiredAt(0).col); | ||
| 794 | try std.testing.expectEqual(@as(u16, 1), ov.retiredAt(1).col); | ||
| 795 | |||
| 796 | // ...and it describes the LAST reconcile only, never accumulates. | ||
| 797 | _ = ov.predictAt(.{ .x = 2, .y = 0 }, 'c'); | ||
| 798 | const text2 = try plainOf(alloc, &.{"abc"}); | ||
| 799 | defer alloc.free(text2); | ||
| 800 | _ = ov.reconcile(PlainGrid{ .text = text2, .cols = 80 }, 2); | ||
| 801 | try std.testing.expectEqual(@as(usize, 1), ov.retiredCount()); | ||
| 802 | try std.testing.expectEqual(@as(u16, 2), ov.retiredAt(0).col); | ||
| 803 | } | ||
| 804 | |||
| 805 | test "PlainGrid reads a cell out of a dump, and blanks where the dump stops" { | ||
| 806 | const alloc = std.testing.allocator; | ||
| 807 | const text = try plainOf(alloc, &.{ "ab", "cd" }); | ||
| 808 | defer alloc.free(text); | ||
| 809 | const g = PlainGrid{ .text = text, .cols = 80 }; | ||
| 810 | |||
| 811 | try std.testing.expectEqual(@as(?u8, 'a'), g.cellChar(0, 0)); | ||
| 812 | try std.testing.expectEqual(@as(?u8, 'b'), g.cellChar(0, 1)); | ||
| 813 | try std.testing.expectEqual(@as(?u8, 'c'), g.cellChar(1, 0)); | ||
| 814 | try std.testing.expectEqual(@as(?u8, 'd'), g.cellChar(1, 1)); | ||
| 815 | |||
| 816 | // A dump carries no trailing blanks, so a cell past the end of a row — | ||
| 817 | // or past the last row — is a blank cell, not a missing one. | ||
| 818 | try std.testing.expectEqual(@as(?u8, ' '), g.cellChar(0, 2)); | ||
| 819 | try std.testing.expectEqual(@as(?u8, ' '), g.cellChar(9, 0)); | ||
| 820 | |||
| 821 | // Outside the grid is a different answer: nothing to compare against. | ||
| 822 | try std.testing.expectEqual(@as(?u8, null), g.cellChar(0, 80)); | ||
| 823 | } | ||