8c4695ae
test: the delta fixture replays through the one core, and a zero-row delta is pinned
a73x 2026-09-04 18:04
Commit message
src/client/wasm_core.zig
| Old | New | ||
|---|---|---|---|
| @@ -567,7 +567,8 @@ fn paintRowFrom(c: *Core, r: *const grid_mod.Row, y: u16) void { | |||
| 567 | } | 567 | } |
| 568 | 568 | ||
| 569 | // --------------------------------------------------------------------- | 569 | // --------------------------------------------------------------------- |
| 570 | // Scrollback view (a scratch terminal; the live replica is never touched) | 570 | // Scrollback view (the last fetched chunk, decoded; the live replica is |
| 571 | // never touched) | ||
| 571 | // --------------------------------------------------------------------- | 572 | // --------------------------------------------------------------------- |
| 572 | 573 | ||
| 573 | /// Decode `len` staged bytes: a WHOLE scrollback_chunk payload, echoed | 574 | /// Decode `len` staged bytes: a WHOLE scrollback_chunk payload, echoed |
src/engine/delta.zig
| Old | New | ||
|---|---|---|---|
| @@ -231,12 +231,19 @@ pub fn buildSnapshot(alloc: std.mem.Allocator, eng: *Engine, prefix: proto.Snaps | |||
| 231 | // way a client does — through grid.decodeRow — rather than searching bytes. | 231 | // way a client does — through grid.decodeRow — rather than searching bytes. |
| 232 | 232 | ||
| 233 | const grid = @import("term").grid; | 233 | const grid = @import("term").grid; |
| 234 | 234 | const Replica = @import("term").replica.Replica; | |
| 235 | /// Apply every row of a delta payload into `g` and return the plain text, so | 235 | |
| 236 | /// a test can say what the far side would be SHOWING after the frame. | 236 | /// Apply a delta payload into `g` and return the plain text, so a test can |
| 237 | /// say what the far side would be SHOWING after the frame. | ||
| 238 | /// | ||
| 239 | /// Through `Replica.apply` rather than a loop over the rows: that is the one | ||
| 240 | /// replay core, and a fixture with its own applier grades payloads by rules | ||
| 241 | /// the product does not use. A payload the product would resync on is an | ||
| 242 | /// error here, since every caller builds one it expects to land. | ||
| 237 | fn deltaText(alloc: std.mem.Allocator, g: *grid.Grid, payload: []const u8) ![]const u8 { | 243 | fn deltaText(alloc: std.mem.Allocator, g: *grid.Grid, payload: []const u8) ![]const u8 { |
| 238 | var it = proto.deltaRowIterator(payload); | 244 | var r = Replica.init(alloc, g); |
| 239 | while (try it.next()) |row| try g.applyRow(row.row, row.bytes); | 245 | const applied = try r.apply(.delta, payload); |
| 246 | if (applied != .painted) return error.DeltaRefused; | ||
| 240 | return g.dumpPlain(alloc); | 247 | return g.dumpPlain(alloc); |
| 241 | } | 248 | } |
| 242 | 249 | ||
src/engine/replica.zig
| Old | New | ||
|---|---|---|---|
| @@ -409,6 +409,47 @@ test "delta replay: rows land, last_seq advances, history and cursor follow" { | |||
| 409 | try std.testing.expectEqualStrings("hi\n\nyo\nok", dump); | 409 | try std.testing.expectEqualStrings("hi\n\nyo\nok", dump); |
| 410 | } | 410 | } |
| 411 | 411 | ||
| 412 | test "delta replay: a delta carrying no rows moves the cursor and nothing else" { | ||
| 413 | const alloc = std.testing.allocator; | ||
| 414 | const g = try Grid.init(alloc, 80, 24); | ||
| 415 | defer g.deinit(); | ||
| 416 | var r = Replica.init(alloc, g); | ||
| 417 | |||
| 418 | var rows: [24][]const u8 = undefined; | ||
| 419 | for (&rows) |*row| row.* = ""; | ||
| 420 | rows[1] = "keep me"; | ||
| 421 | const snap = try testSnapshot(alloc, .{ | ||
| 422 | .seq = 4, | ||
| 423 | .history_rows = 0, | ||
| 424 | .cols = 80, | ||
| 425 | .rows = 24, | ||
| 426 | .epoch = 1, | ||
| 427 | }, .{ .x = 0, .y = 0 }, &rows); | ||
| 428 | defer alloc.free(snap); | ||
| 429 | _ = try r.apply(.snapshot, snap); | ||
| 430 | |||
| 431 | // The tracker mints exactly this frame when only the cursor moved, so a | ||
| 432 | // replica that refused it would resync a client on every arrow key. | ||
| 433 | var delta: std.ArrayList(u8) = .empty; | ||
| 434 | defer delta.deinit(alloc); | ||
| 435 | try proto.appendDeltaHeader(&delta, alloc, .{ | ||
| 436 | .seq = 5, | ||
| 437 | .history_rows = 0, | ||
| 438 | .cursor_x = 9, | ||
| 439 | .cursor_y = 4, | ||
| 440 | .row_count = 0, | ||
| 441 | }); | ||
| 442 | |||
| 443 | try std.testing.expectEqual(Replica.Applied.painted, try r.apply(.delta, delta.items)); | ||
| 444 | try std.testing.expectEqual(@as(u64, 5), r.last_seq); | ||
| 445 | try std.testing.expectEqual(@as(u16, 9), g.cursor.x); | ||
| 446 | try std.testing.expectEqual(@as(u16, 4), g.cursor.y); | ||
| 447 | |||
| 448 | const dump = try g.dumpPlain(alloc); | ||
| 449 | defer alloc.free(dump); | ||
| 450 | try std.testing.expectEqualStrings("\nkeep me", dump); | ||
| 451 | } | ||
| 452 | |||
| 412 | test "delta decode failure reports .resync — and its arrival still proves admission" { | 453 | test "delta decode failure reports .resync — and its arrival still proves admission" { |
| 413 | const alloc = std.testing.allocator; | 454 | const alloc = std.testing.allocator; |
| 414 | const g = try Grid.init(alloc, 80, 24); | 455 | const g = try Grid.init(alloc, 80, 24); |
src/tui/interact.zig
| Old | New | ||
|---|---|---|---|
| @@ -1933,9 +1933,9 @@ pub const Core = struct { | |||
| 1933 | /// trivial, which is why selection lives in the Core: a terminal cell is a | 1933 | /// trivial, which is why selection lives in the Core: a terminal cell is a |
| 1934 | /// grid cell less the tile's origin, plus the history under it. | 1934 | /// grid cell less the tile's origin, plus the history under it. |
| 1935 | fn hitTest(self: *Core, ev: MouseFilter.Event) ?select.Hit { | 1935 | fn hitTest(self: *Core, ev: MouseFilter.Event) ?select.Hit { |
| 1936 | // Scrolled back, nothing here is addressable: `renderScrollback` blits | 1936 | // Scrolled back, nothing here is addressable: `renderScrollback` |
| 1937 | // VT bytes with no engine behind them. Refused rather than | 1937 | // paints decoded history rows this Core does not hold. Refused rather |
| 1938 | // half-answered, or the coordinates name the live view instead. | 1938 | // than half-answered, or the coordinates name the live view instead. |
| 1939 | if (self.scroll_rows > 0) return null; | 1939 | if (self.scroll_rows > 0) return null; |
| 1940 | // A tile paints at `row_off`/`col_off`, so a terminal cell is a grid | 1940 | // A tile paints at `row_off`/`col_off`, so a terminal cell is a grid |
| 1941 | // cell only after the origin comes off — on BOTH axes. Above or left | 1941 | // cell only after the origin comes off — on BOTH axes. Above or left |
| @@ -2987,7 +2987,7 @@ const full_vp: paint_mod.Viewport = .{ .top = 0, .left = 0, .rows = 24, .cols = | |||
| 2987 | 2987 | ||
| 2988 | /// Only the tests reach for an engine now: one authors the screens a client | 2988 | /// Only the tests reach for an engine now: one authors the screens a client |
| 2989 | /// would be sent, and one plays the terminal the client paints onto. | 2989 | /// would be sent, and one plays the terminal the client paints onto. |
| 2990 | const Engine = @import("term").engine.Engine; | 2990 | const Engine = @import("engine").Engine; |
| 2991 | 2991 | ||
| 2992 | /// An engine and the grid it mirrors into. A test describes a screen in VT, | 2992 | /// An engine and the grid it mirrors into. A test describes a screen in VT, |
| 2993 | /// and what the code under test reads is the cells — through the daemon's | 2993 | /// and what the code under test reads is the cells — through the daemon's |
| @@ -4060,10 +4060,10 @@ test "interact: a drag while scrolled back selects nothing" { | |||
| 4060 | var buf: [8192]u8 = undefined; | 4060 | var buf: [8192]u8 = undefined; |
| 4061 | _ = drainPipe(p[0], &buf); | 4061 | _ = drainPipe(p[0], &buf); |
| 4062 | 4062 | ||
| 4063 | // Viewing history: `renderScrollback` blits daemon VT bytes with no | 4063 | // Viewing history: `renderScrollback` paints decoded history rows this |
| 4064 | // engine behind them, so nothing on this screen is a row this Core can | 4064 | // Core does not hold, so nothing on this screen is a row it can name. |
| 4065 | // name. Refused outright rather than answered against the live view | 4065 | // Refused outright rather than answered against the live view the user |
| 4066 | // the user is not looking at. | 4066 | // is not looking at. |
| 4067 | core.scroll_rows = 4; | 4067 | core.scroll_rows = 4; |
| 4068 | try mouse(&core, &tr, 0, 3, 2, 'M'); | 4068 | try mouse(&core, &tr, 0, 3, 2, 'M'); |
| 4069 | try mouse(&core, &tr, 32, 7, 2, 'M'); | 4069 | try mouse(&core, &tr, 32, 7, 2, 'M'); |