terminal/snapshot: clean up tests

This commit is contained in:
Mitchell Hashimoto
2026-07-30 13:22:15 -07:00
parent 43ec9b373b
commit 92c8dfd508
7 changed files with 326 additions and 659 deletions

View File

@@ -123,9 +123,12 @@ test "checkpoint BLAKE3-256 registry" {
var hasher = Blake3.init(.{});
hasher.update("a");
hasher.update("bc");
var prefix: Digest = undefined;
Blake3.hash("a", &prefix, .{});
hasher.final(&actual);
try std.testing.expectEqual(expected, actual);
try std.testing.expectEqual(prefix, actual);
hasher.update("bc");
hasher.final(&actual);
try std.testing.expectEqual(expected, actual);
}
@@ -142,7 +145,6 @@ test "READY and FINISH checkpoint coverage" {
var ready_digest: Digest = undefined;
Blake3.hash(snapshot.written(), &ready_digest, .{});
try encode(.ready, &snapshot);
const ready_end = snapshot.written().len;
// History follows READY and is included by FINISH.
try snapshot.writer.writeAll("history");
@@ -160,19 +162,6 @@ test "READY and FINISH checkpoint coverage" {
snapshot.written()[finish_offset..],
);
try decode(.finish, finish_digest, &finish_source);
// Including READY changes the FINISH digest even before history is added.
var ready_record_digest: Digest = undefined;
Blake3.hash(
snapshot.written()[0..ready_end],
&ready_record_digest,
.{},
);
try testing.expect(!std.mem.eql(
u8,
&ready_digest,
&ready_record_digest,
));
}
test "checkpoint rejects wrong tags, digests, and FINISH trailing data" {
@@ -219,33 +208,3 @@ test "checkpoint rejects wrong tags, digests, and FINISH trailing data" {
decode(.finish, digest, &trailing_source),
);
}
test "checkpoint rejects corruption and every truncation" {
const testing = std.testing;
var snapshot: std.Io.Writer.Allocating = .init(testing.allocator);
defer snapshot.deinit();
try snapshot.writer.writeAll("prefix");
const checkpoint_offset = snapshot.written().len;
var digest: Digest = undefined;
Blake3.hash(snapshot.written(), &digest, .{});
try encode(.ready, &snapshot);
const fixture = snapshot.written()[checkpoint_offset..];
const corrupt = try testing.allocator.dupe(u8, fixture);
defer testing.allocator.free(corrupt);
corrupt[record.Header.len] ^= 1;
var corrupt_source: std.Io.Reader = .fixed(corrupt);
try testing.expectError(
error.InvalidChecksum,
decode(.ready, digest, &corrupt_source),
);
for (0..fixture.len) |fixture_len| {
var source: std.Io.Reader = .fixed(fixture[0..fixture_len]);
try testing.expectError(
error.EndOfStream,
decode(.ready, digest, &source),
);
}
}

View File

@@ -668,56 +668,60 @@ test "HISTORY rejects manifest mismatches" {
// Encode a manifest without PAGE records and vary only the fixed fields so
// each structural validation boundary is exercised in isolation.
const cases = .{
const Case = struct {
header: Header,
expected: anyerror,
};
const cases = [_]Case{
.{
Header{
.header = .{
.key = .alternate,
.page_count = 0,
.total_rows = 0,
.screen_overlap_rows = 0,
},
error.UnexpectedScreenKey,
.expected = error.UnexpectedScreenKey,
},
.{
Header{
.header = .{
.key = .primary,
.page_count = 0,
.total_rows = 1,
.screen_overlap_rows = 1,
},
error.InvalidScreenOverlap,
.expected = error.InvalidScreenOverlap,
},
.{
Header{
.header = .{
.key = .primary,
.page_count = 0,
.total_rows = 1,
.screen_overlap_rows = 0,
},
error.InvalidHistoryRows,
.expected = error.InvalidHistoryRows,
},
.{
Header{
.header = .{
.key = .primary,
.page_count = 1,
.total_rows = 1,
.screen_overlap_rows = 0,
},
error.EndOfStream,
.expected = error.EndOfStream,
},
};
inline for (cases) |case| {
for (cases) |case| {
var destination: std.Io.Writer.Allocating = .init(
std.testing.allocator,
);
defer destination.deinit();
var record_writer = try record.Writer.init(&destination, .history);
try case[0].encode(record_writer.payloadWriter());
try case.header.encode(record_writer.payloadWriter());
try record_writer.finish();
var source: std.Io.Reader = .fixed(destination.written());
try std.testing.expectError(
case[1],
case.expected,
decode(
&source,
std.testing.allocator,

View File

@@ -357,7 +357,7 @@ test "decodePage ignores empty strings" {
}
test "reject invalid kinds" {
inline for (.{ 0, 3, std.math.maxInt(u8) }) |kind| {
for ([_]u8{ 0, 3, std.math.maxInt(u8) }) |kind| {
var fixture: [1]u8 = .{kind};
var reader: std.Io.Reader = .fixed(&fixture);
try std.testing.expectError(

View File

@@ -94,7 +94,6 @@
const std = @import("std");
const Allocator = std.mem.Allocator;
const envelope = @import("envelope.zig");
const grid = @import("grid.zig");
const hyperlink = @import("hyperlink.zig");
const io = @import("io.zig");
@@ -577,7 +576,7 @@ const test_empty_framed_page_fixture =
"\x00\x00\x00\x00\x00\x00\x00\x00" ++
"\x00";
test "golden encoding" {
test "PAGE header golden encoding and decoding" {
const header: Header = .{
.columns = 0x0102,
.rows = 0x0304,
@@ -588,41 +587,20 @@ test "golden encoding" {
.grapheme_capacity_bytes = 0x0d0e0f10,
.string_capacity_bytes = 0x11121314,
};
var buf: [Header.len]u8 = undefined;
var writer: std.Io.Writer = .fixed(&buf);
try header.encode(&writer);
try std.testing.expectEqualStrings(
"\x02\x01\x04\x03\x06\x05\x08\x07" ++
"\x0a\x09\x0c\x0b\x10\x0f\x0e\x0d" ++
"\x14\x13\x12\x11",
writer.buffered(),
);
}
test "decode with a one-byte reader buffer" {
const fixture =
"\x02\x01\x04\x03\x06\x05\x08\x07" ++
"\x0a\x09\x0c\x0b\x10\x0f\x0e\x0d" ++
"\x14\x13\x12\x11";
var source: std.Io.Reader = .fixed(fixture);
var buf: [1]u8 = undefined;
var limited = source.limited(.unlimited, &buf);
try std.testing.expectEqual(
Header{
.columns = 0x0102,
.rows = 0x0304,
.style_count = 0x0506,
.hyperlink_count = 0x0708,
.style_capacity = 0x090a,
.hyperlink_capacity_bytes = 0x0b0c,
.grapheme_capacity_bytes = 0x0d0e0f10,
.string_capacity_bytes = 0x11121314,
},
try Header.decode(&limited.interface),
);
var buf: [Header.len]u8 = undefined;
var writer: std.Io.Writer = .fixed(&buf);
try header.encode(&writer);
try std.testing.expectEqualStrings(fixture, writer.buffered());
var source: std.Io.Reader = .fixed(fixture);
var read_buf: [1]u8 = undefined;
var limited = source.limited(.unlimited, &read_buf);
try std.testing.expectEqual(header, try Header.decode(&limited.interface));
}
test "reject every truncation" {
@@ -870,41 +848,6 @@ test "framed PAGE encode and decode a sparse native page" {
rewriter.buffered(),
rewriter_again.buffered(),
);
// The public codec appends one complete PAGE record after the envelope.
var snapshot: std.Io.Writer.Allocating = .init(std.testing.allocator);
defer snapshot.deinit();
try envelope.encode(&snapshot.writer);
try encode(&page, &snapshot);
const snapshot_bytes = snapshot.written();
try std.testing.expectEqualStrings(
test_page_fixture,
snapshot_bytes[envelope.encoded_len + record.Header.len ..],
);
var snapshot_reader: std.Io.Reader = .fixed(snapshot_bytes);
try envelope.decode(&snapshot_reader);
var framed_page = try decode(
&snapshot_reader,
std.testing.allocator,
);
defer framed_page.deinit();
try std.testing.expectEqual(header, Header.init(&framed_page));
const framed_first = framed_page.getRowAndCell(0, 0);
try std.testing.expectEqual(@as(u21, 'A'), framed_first.cell.codepoint());
try std.testing.expectEqual(
TerminalCell.SemanticContent.prompt,
framed_first.cell.semantic_content,
);
try std.testing.expectEqualSlices(
u21,
&.{ 0x0301, 0x0302 },
framed_page.lookupGrapheme(
framed_page.getRowAndCell(0, 1).cell,
).?,
);
}
test "framed PAGE golden empty record" {
@@ -936,90 +879,22 @@ test "framed PAGE golden empty record" {
);
}
test "framed PAGE validates tag length checksum and exhaustion" {
{
// A valid non-PAGE tag is rejected before payload decoding.
var wrong_tag = test_empty_framed_page_fixture.*;
std.mem.writeInt(u16, wrong_tag[0..2], @intFromEnum(record.Tag.screen), .little);
var reader: std.Io.Reader = .fixed(&wrong_tag);
try std.testing.expectError(
error.UnexpectedRecordTag,
decode(&reader, std.testing.allocator),
);
}
{
// Corrupt only the stored checksum.
var invalid_checksum = test_empty_framed_page_fixture.*;
invalid_checksum[6] ^= 1;
var reader: std.Io.Reader = .fixed(&invalid_checksum);
try std.testing.expectError(
error.InvalidChecksum,
decode(&reader, std.testing.allocator),
);
}
{
// Mutate a cell without updating the checksum.
var invalid_payload = test_empty_framed_page_fixture.*;
const value_offset = record.Header.len +
Header.len +
1 +
8;
invalid_payload[value_offset] = 'A';
var reader: std.Io.Reader = .fixed(&invalid_payload);
try std.testing.expectError(
error.InvalidChecksum,
decode(&reader, std.testing.allocator),
);
}
{
// Advertise one byte less than the PAGE decoder requires.
var short_payload = test_empty_framed_page_fixture.*;
std.mem.writeInt(u32, short_payload[2..6], 36, .little);
var reader: std.Io.Reader = .fixed(&short_payload);
try std.testing.expectError(
error.EndOfStream,
decode(&reader, std.testing.allocator),
);
}
{
// Add a payload byte and recompute the checksum so exhaustion, rather
// than checksum validation, is what fails.
var trailing: [test_empty_framed_page_fixture.len + 1]u8 = undefined;
@memcpy(
trailing[0..test_empty_framed_page_fixture.len],
test_empty_framed_page_fixture,
);
trailing[trailing.len - 1] = 0;
std.mem.writeInt(u32, trailing[2..6], 38, .little);
var checksum: record.Checksum = .init(.page, 38);
try checksum.writer().writeAll(trailing[record.Header.len..]);
std.mem.writeInt(u32, trailing[6..10], checksum.final(), .little);
var reader: std.Io.Reader = .fixed(&trailing);
try std.testing.expectError(
error.PayloadNotExhausted,
decode(&reader, std.testing.allocator),
);
}
test "framed PAGE rejects a different record tag" {
var wrong_tag = test_empty_framed_page_fixture.*;
std.mem.writeInt(
u16,
wrong_tag[0..2],
@intFromEnum(record.Tag.screen),
.little,
);
var reader: std.Io.Reader = .fixed(&wrong_tag);
try std.testing.expectError(
error.UnexpectedRecordTag,
decode(&reader, std.testing.allocator),
);
}
test "framed PAGE rejects every truncation and preserves following bytes" {
for (0..test_empty_framed_page_fixture.len) |len| {
var reader: std.Io.Reader = .fixed(
test_empty_framed_page_fixture[0..len],
);
if (decode(&reader, std.testing.allocator)) |decoded_value| {
var unexpected = decoded_value;
unexpected.deinit();
return error.ExpectedDecodeFailure;
} else |_| {}
}
test "framed PAGE preserves following bytes" {
var source: std.Io.Reader = .fixed(
test_empty_framed_page_fixture ++ "next",
);
@@ -1290,7 +1165,11 @@ test "decode rejects undefined row and cell values" {
}
test "decode validates dimensions and native table capacities" {
const cases = .{
const Case = struct {
expected: anyerror,
header: Header,
};
const cases = [_]Case{
.{
.expected = error.InvalidDimensions,
.header = Header{
@@ -1345,7 +1224,7 @@ test "decode validates dimensions and native table capacities" {
},
};
inline for (cases) |case| {
for (cases) |case| {
var encoded: [Header.len]u8 = undefined;
var writer: std.Io.Writer = .fixed(&encoded);
try case.header.encode(&writer);

View File

@@ -1219,38 +1219,7 @@ fn testSavedCursor() SavedCursor {
};
}
fn expectHeaderRoundTrip(expected: Header) !void {
var encoded: [Header.len]u8 = undefined;
var writer: std.Io.Writer = .fixed(&encoded);
try expected.encode(&writer);
var reader: std.Io.Reader = .fixed(writer.buffered());
const actual = try Header.decode(&reader);
try std.testing.expectEqualDeep(expected, actual);
}
fn expectHyperlinkEqual(
expected: TerminalHyperlink,
actual: TerminalHyperlink,
) !void {
try std.testing.expectEqualStrings(expected.uri, actual.uri);
try std.testing.expectEqual(
std.meta.activeTag(expected.id),
std.meta.activeTag(actual.id),
);
switch (expected.id) {
.implicit => |expected_id| try std.testing.expectEqual(
expected_id,
actual.id.implicit,
),
.explicit => |expected_id| try std.testing.expectEqualStrings(
expected_id,
actual.id.explicit,
),
}
}
test "header golden encoding" {
test "SCREEN header golden encoding and decoding" {
try std.testing.expectEqual(Header.len, test_header_fixture.len);
var encoded: [Header.len]u8 = undefined;
@@ -1261,9 +1230,7 @@ test "header golden encoding" {
test_header_fixture,
writer.buffered(),
);
}
test "header decoding with a one-byte reader buffer" {
var source: std.Io.Reader = .fixed(test_header_fixture);
var buffer: [1]u8 = undefined;
var limited = source.limited(.unlimited, &buffer);
@@ -1274,12 +1241,13 @@ test "header decoding with a one-byte reader buffer" {
);
}
test "header registry values round trip" {
test "native enum values used by the SCREEN format" {
const keys = [_]TerminalScreenKey{ .primary, .alternate };
for (keys) |value| {
var header = testHeader();
header.key = value;
try expectHeaderRoundTrip(header);
for (keys, 0..) |value, expected| {
try std.testing.expectEqual(
expected,
@as(usize, @intCast(@intFromEnum(value))),
);
}
const cursor_styles = [_]TerminalScreen.CursorStyle{
@@ -1288,10 +1256,11 @@ test "header registry values round trip" {
.underline,
.block_hollow,
};
for (cursor_styles) |value| {
var header = testHeader();
header.cursor_style = value;
try expectHeaderRoundTrip(header);
for (cursor_styles, 0..) |value, expected| {
try std.testing.expectEqual(
expected,
@as(usize, @intCast(@intFromEnum(value))),
);
}
const semantic_contents = [_]terminal_page.Cell.SemanticContent{
@@ -1299,10 +1268,11 @@ test "header registry values round trip" {
.input,
.prompt,
};
for (semantic_contents) |value| {
var header = testHeader();
header.cursor_flags.semantic_content = value;
try expectHeaderRoundTrip(header);
for (semantic_contents, 0..) |value, expected| {
try std.testing.expectEqual(
expected,
@as(usize, @intCast(@intFromEnum(value))),
);
}
const charsets = [_]terminal_charsets.Charset{
@@ -1311,34 +1281,19 @@ test "header registry values round trip" {
.british,
.dec_special,
};
for (charsets) |value| {
var header = testHeader();
header.charset.charsets.set(.G0, value);
header.charset.charsets.set(.G1, value);
header.charset.charsets.set(.G2, value);
header.charset.charsets.set(.G3, value);
try expectHeaderRoundTrip(header);
for (charsets, 0..) |value, expected| {
try std.testing.expectEqual(
expected,
@as(usize, @intCast(@intFromEnum(value))),
);
}
const slots = [_]terminal_charsets.Slots{ .G0, .G1, .G2, .G3 };
for (slots) |value| {
var header = testHeader();
header.charset.gl = value;
header.charset.gr = value;
try expectHeaderRoundTrip(header);
}
const single_shifts = [_]?terminal_charsets.Slots{
null,
.G0,
.G1,
.G2,
.G3,
};
for (single_shifts) |value| {
var header = testHeader();
header.charset.single_shift = value;
try expectHeaderRoundTrip(header);
for (slots, 0..) |value, expected| {
try std.testing.expectEqual(
expected,
@as(usize, @intCast(@intFromEnum(value))),
);
}
const protected_modes = [_]terminal_ansi.ProtectedMode{
@@ -1346,74 +1301,116 @@ test "header registry values round trip" {
.iso,
.dec,
};
for (protected_modes) |value| {
var header = testHeader();
header.protected_mode = value;
try expectHeaderRoundTrip(header);
for (protected_modes, 0..) |value, expected| {
try std.testing.expectEqual(
expected,
@as(usize, @intCast(@intFromEnum(value))),
);
}
const semantic_clicks =
[_]TerminalScreen.SemanticPrompt.SemanticClick{
.none,
.{ .click_events = .absolute },
.{ .click_events = .relative },
.{ .cl = .line },
.{ .cl = .multiple },
.{ .cl = .conservative_vertical },
.{ .cl = .smart_vertical },
};
for (semantic_clicks) |value| {
var header = testHeader();
header.semantic_click = value;
try expectHeaderRoundTrip(header);
const click_kinds = [_]TerminalScreen.SemanticPrompt.SemanticClickKind{
.none,
.click_events,
.cl,
};
for (click_kinds, 0..) |value, expected| {
try std.testing.expectEqual(
expected,
@as(usize, @intCast(@intFromEnum(value))),
);
}
const click_events = [_]terminal_osc.semantic_prompt.ClickEvents{
.absolute,
.relative,
};
for (click_events, 0..) |value, expected| {
try std.testing.expectEqual(
expected,
@as(usize, @intCast(@intFromEnum(value))),
);
}
const clicks = [_]terminal_osc.semantic_prompt.Click{
.line,
.multiple,
.conservative_vertical,
.smart_vertical,
};
for (clicks, 0..) |value, expected| {
try std.testing.expectEqual(
expected,
@as(usize, @intCast(@intFromEnum(value))),
);
}
}
test "cursor, Kitty, and saved cursor flag bit layouts" {
const cursor_cases = .{
.{ CursorFlags{ .pending_wrap = true }, @as(u8, 1 << 0) },
.{ CursorFlags{ .protected = true }, @as(u8, 1 << 1) },
const CursorCase = struct {
value: CursorFlags,
expected: u8,
};
const cursor_cases = [_]CursorCase{
.{ .value = .{ .pending_wrap = true }, .expected = 1 << 0 },
.{ .value = .{ .protected = true }, .expected = 1 << 1 },
.{
CursorFlags{ .semantic_content = .input },
@as(u8, 1 << 2),
.value = .{ .semantic_content = .input },
.expected = 1 << 2,
},
.{
CursorFlags{ .semantic_content = .prompt },
@as(u8, 2 << 2),
.value = .{ .semantic_content = .prompt },
.expected = 2 << 2,
},
.{
CursorFlags{ .semantic_content_clear_eol = true },
@as(u8, 1 << 4),
.value = .{ .semantic_content_clear_eol = true },
.expected = 1 << 4,
},
};
inline for (cursor_cases) |case| {
try std.testing.expectEqual(case[1], @as(u8, @bitCast(case[0])));
for (cursor_cases) |case| {
try std.testing.expectEqual(
case.expected,
@as(u8, @bitCast(case.value)),
);
}
const kitty_cases = .{
.{ KittyKeyboard.Flags{ .disambiguate = true }, @as(u8, 1 << 0) },
.{ KittyKeyboard.Flags{ .report_events = true }, @as(u8, 1 << 1) },
const KittyCase = struct {
value: KittyKeyboard.Flags,
expected: u8,
};
const kitty_cases = [_]KittyCase{
.{ .value = .{ .disambiguate = true }, .expected = 1 << 0 },
.{ .value = .{ .report_events = true }, .expected = 1 << 1 },
.{
KittyKeyboard.Flags{ .report_alternates = true },
@as(u8, 1 << 2),
.value = .{ .report_alternates = true },
.expected = 1 << 2,
},
.{ KittyKeyboard.Flags{ .report_all = true }, @as(u8, 1 << 3) },
.{ .value = .{ .report_all = true }, .expected = 1 << 3 },
.{
KittyKeyboard.Flags{ .report_associated = true },
@as(u8, 1 << 4),
.value = .{ .report_associated = true },
.expected = 1 << 4,
},
};
inline for (kitty_cases) |case| {
try std.testing.expectEqual(case[1], @as(u8, @bitCast(case[0])));
for (kitty_cases) |case| {
try std.testing.expectEqual(
case.expected,
@as(u8, @bitCast(case.value)),
);
}
const saved_cases = .{
.{ SavedCursor.Flags{ .protected = true }, @as(u8, 1 << 0) },
.{ SavedCursor.Flags{ .pending_wrap = true }, @as(u8, 1 << 1) },
.{ SavedCursor.Flags{ .origin = true }, @as(u8, 1 << 2) },
const SavedCase = struct {
value: SavedCursor.Flags,
expected: u8,
};
inline for (saved_cases) |case| {
try std.testing.expectEqual(case[1], @as(u8, @bitCast(case[0])));
const saved_cases = [_]SavedCase{
.{ .value = .{ .protected = true }, .expected = 1 << 0 },
.{ .value = .{ .pending_wrap = true }, .expected = 1 << 1 },
.{ .value = .{ .origin = true }, .expected = 1 << 2 },
};
for (saved_cases) |case| {
try std.testing.expectEqual(
case.expected,
@as(u8, @bitCast(case.value)),
);
}
}
@@ -1543,12 +1540,12 @@ test "header decoding rejects invalid values" {
Header.decode(&semantic_click_kind_reader),
);
const invalid_semantic_clicks = .{
.{ @as(u8, 0), @as(u8, 1) },
.{ @as(u8, 1), @as(u8, 2) },
.{ @as(u8, 2), @as(u8, 4) },
const invalid_semantic_clicks = [_][2]u8{
.{ 0, 1 },
.{ 1, 2 },
.{ 2, 4 },
};
inline for (invalid_semantic_clicks) |invalid| {
for (invalid_semantic_clicks) |invalid| {
var fixture = valid;
fixture[42] = invalid[0];
fixture[43] = invalid[1];
@@ -1678,7 +1675,7 @@ test "saved cursor decoding rejects every truncation" {
}
}
test "cursor hyperlink encoding and decoding" {
test "cursor hyperlink null encoding and decoding" {
var none_encoded: [1]u8 = undefined;
var none_writer: std.Io.Writer = .fixed(&none_encoded);
try encodeCursorHyperlink(null, &none_writer);
@@ -1689,31 +1686,6 @@ test "cursor hyperlink encoding and decoding" {
null,
try decodeCursorHyperlink(&none_reader, std.testing.allocator),
);
const values = [_]TerminalHyperlink{
.{
.id = .{ .implicit = 0x01020304 },
.uri = "implicit",
},
.{
.id = .{ .explicit = "id" },
.uri = "explicit",
},
};
for (values) |expected| {
var encoded: [128]u8 = undefined;
var writer: std.Io.Writer = .fixed(&encoded);
try encodeCursorHyperlink(expected, &writer);
var reader: std.Io.Reader = .fixed(writer.buffered());
var actual = (try decodeCursorHyperlink(
&reader,
std.testing.allocator,
)).?;
defer actual.deinit(std.testing.allocator);
try expectHyperlinkEqual(expected, actual);
}
}
test "framed native SCREEN and PAGE sequence" {
@@ -1797,44 +1769,6 @@ test "framed native SCREEN and PAGE sequence" {
destination.written()[0..6],
);
var source: std.Io.Reader = .fixed(destination.written()[6..]);
var record_reader: record.Reader = undefined;
try record_reader.init(&source);
try std.testing.expectEqual(record.Tag.screen, record_reader.header.tag);
const payload_reader = record_reader.payloadReader();
var expected_header = testHeader();
expected_header.page_count = 1;
expected_header.cursor_x = 7;
expected_header.cursor_y = 6;
expected_header.hyperlink_implicit_id = 0x0a0b0c0e;
try std.testing.expectEqualDeep(
expected_header,
try Header.decode(payload_reader),
);
try std.testing.expectEqualDeep(
testSavedCursor(),
try SavedCursor.decode(payload_reader),
);
const expected_hyperlink: TerminalHyperlink = .{
.id = .{ .implicit = 0x0a0b0c0d },
.uri = "cursor-uri",
};
var actual_hyperlink = (try decodeCursorHyperlink(
payload_reader,
std.testing.allocator,
)).?;
defer actual_hyperlink.deinit(std.testing.allocator);
try expectHyperlinkEqual(expected_hyperlink, actual_hyperlink);
try record_reader.finish();
var decoded_page = try page.decode(&source, std.testing.allocator);
defer decoded_page.deinit();
try std.testing.expectEqual(@as(u16, 8), decoded_page.size.cols);
try std.testing.expectEqual(@as(u16, 8), decoded_page.size.rows);
try std.testing.expectError(error.EndOfStream, source.takeByte());
// The complete sequence restores directly into native Screen/PageList
// state, including page-local cursor references.
var restore_source: std.Io.Reader = .fixed(destination.written()[6..]);
@@ -1868,10 +1802,18 @@ test "framed native SCREEN and PAGE sequence" {
@as(u32, 0x0a0b0c0e),
restored.cursor.hyperlink_implicit_id,
);
try expectHyperlinkEqual(
expected_hyperlink,
restored.cursor.hyperlink.?.*,
const restored_hyperlink = restored.cursor.hyperlink.?.*;
try std.testing.expectEqualStrings(
"cursor-uri",
restored_hyperlink.uri,
);
switch (restored_hyperlink.id) {
.implicit => |id| try std.testing.expectEqual(
@as(u32, 0x0a0b0c0d),
id,
),
.explicit => try std.testing.expect(false),
}
const restored_saved = restored.saved_cursor.?;
try std.testing.expectEqual(@as(u16, 0x0102), restored_saved.x);
@@ -2138,62 +2080,6 @@ test "SCREEN sequence failure preserves preceding bytes" {
try std.testing.expectEqualStrings("prefix", destination.written());
}
test "cursor hyperlink rejects invalid kind and every truncation" {
var invalid_kind_reader: std.Io.Reader = .fixed("\x03");
try std.testing.expectError(
error.InvalidKind,
decodeCursorHyperlink(
&invalid_kind_reader,
std.testing.allocator,
),
);
const empty_cases = [_]struct {
fixture: []const u8,
expected: anyerror,
}{
.{
.fixture = "\x01\x04\x03\x02\x01\x00\x00\x00\x00",
.expected = error.InvalidUri,
},
.{
.fixture = "\x02\x00\x00\x00\x00",
.expected = error.InvalidExplicitId,
},
.{
.fixture = "\x02\x02\x00\x00\x00id\x00\x00\x00\x00",
.expected = error.InvalidUri,
},
};
for (empty_cases) |case| {
var reader: std.Io.Reader = .fixed(case.fixture);
try std.testing.expectError(
case.expected,
decodeCursorHyperlink(
&reader,
std.testing.allocator,
),
);
}
const fixtures = .{
"\x01\x04\x03\x02\x01\x03\x00\x00\x00uri",
"\x02\x02\x00\x00\x00id\x03\x00\x00\x00uri",
};
inline for (fixtures) |fixture| {
for (0..fixture.len) |fixture_len| {
var reader: std.Io.Reader = .fixed(fixture[0..fixture_len]);
try std.testing.expectError(
error.EndOfStream,
decodeCursorHyperlink(
&reader,
std.testing.allocator,
),
);
}
}
}
test "SCREEN decode rejects an empty cursor hyperlink URI" {
var destination: std.Io.Writer.Allocating = .init(
std.testing.allocator,

View File

@@ -84,6 +84,9 @@ pub const DecodeError = std.Io.Reader.Error || error{
/// A color kind is not defined by snapshot version 1.
InvalidColorKind,
/// Bytes unused by the selected color kind are not zero.
InvalidColor,
/// The encoded underline kind is not defined by snapshot version 1.
InvalidUnderline,
@@ -187,8 +190,14 @@ fn decodeColor(
};
return switch (kind) {
.none => .none,
.palette => .{ .palette = encoded[1] },
.none => if (std.mem.eql(u8, encoded[1..], &.{ 0, 0, 0 }))
.none
else
error.InvalidColor,
.palette => if (encoded[2] == 0 and encoded[3] == 0)
.{ .palette = encoded[1] }
else
error.InvalidColor,
.rgb => .{ .rgb = .{
.r = encoded[1],
.g = encoded[2],
@@ -206,7 +215,7 @@ fn computeLen() usize {
}
}
test "golden encoding" {
test "golden encoding and decoding" {
const value: terminal_style.Style = .{
.fg_color = .none,
.bg_color = .{ .palette = 0x7f },
@@ -227,103 +236,100 @@ test "golden encoding" {
.underline = .curly,
},
};
var buf: [len]u8 = undefined;
var writer: std.Io.Writer = .fixed(&buf);
try encode(value, &writer);
try std.testing.expectEqualStrings(
"\x00\x00\x00\x00" ++
"\x01\x7f\x00\x00" ++
"\x02\x12\x34\x56" ++
"\xff\x03\x00\x00",
writer.buffered(),
);
}
test "flag bit layout" {
const cases = .{
.{ Flags{ .bold = true }, @as(u16, 1 << 0) },
.{ Flags{ .italic = true }, @as(u16, 1 << 1) },
.{ Flags{ .faint = true }, @as(u16, 1 << 2) },
.{ Flags{ .blink = true }, @as(u16, 1 << 3) },
.{ Flags{ .inverse = true }, @as(u16, 1 << 4) },
.{ Flags{ .invisible = true }, @as(u16, 1 << 5) },
.{ Flags{ .strikethrough = true }, @as(u16, 1 << 6) },
.{ Flags{ .overline = true }, @as(u16, 1 << 7) },
.{
Flags{ .underline = @intFromEnum(sgr.Attribute.Underline.single) },
@as(u16, 1 << 8),
},
.{
Flags{ .underline = @intFromEnum(sgr.Attribute.Underline.double) },
@as(u16, 2 << 8),
},
.{
Flags{ .underline = @intFromEnum(sgr.Attribute.Underline.curly) },
@as(u16, 3 << 8),
},
.{
Flags{ .underline = @intFromEnum(sgr.Attribute.Underline.dotted) },
@as(u16, 4 << 8),
},
.{
Flags{ .underline = @intFromEnum(sgr.Attribute.Underline.dashed) },
@as(u16, 5 << 8),
},
.{ Flags{ .reserved = 1 }, @as(u16, 1 << 11) },
};
inline for (cases) |case| {
try std.testing.expectEqual(case[1], @as(u16, @bitCast(case[0])));
}
}
test "decoding" {
const fixture =
"\x00\x00\x00\x00" ++
"\x01\x7f\x00\x00" ++
"\x02\x12\x34\x56" ++
"\xff\x03\x00\x00";
var source: std.Io.Reader = .fixed(fixture);
var buf: [1]u8 = undefined;
var limited = source.limited(.unlimited, &buf);
const expected: terminal_style.Style = .{
.fg_color = .none,
.bg_color = .{ .palette = 0x7f },
.underline_color = .{ .rgb = .{
.r = 0x12,
.g = 0x34,
.b = 0x56,
} },
.flags = .{
.bold = true,
.italic = true,
.faint = true,
.blink = true,
.inverse = true,
.invisible = true,
.strikethrough = true,
.overline = true,
.underline = .curly,
},
};
const actual = try decode(&limited.interface);
try std.testing.expect(expected.eql(actual));
var buf: [len]u8 = undefined;
var writer: std.Io.Writer = .fixed(&buf);
try encode(value, &writer);
try std.testing.expectEqualStrings(fixture, writer.buffered());
var source: std.Io.Reader = .fixed(fixture);
var read_buf: [1]u8 = undefined;
var limited = source.limited(.unlimited, &read_buf);
try std.testing.expect(value.eql(try decode(&limited.interface)));
}
test "reject invalid color kinds" {
inline for (.{ 0, 4, 8 }) |offset| {
test "flag bit layout" {
const Case = struct {
value: Flags,
expected: u16,
};
const cases = [_]Case{
.{ .value = .{ .bold = true }, .expected = 1 << 0 },
.{ .value = .{ .italic = true }, .expected = 1 << 1 },
.{ .value = .{ .faint = true }, .expected = 1 << 2 },
.{ .value = .{ .blink = true }, .expected = 1 << 3 },
.{ .value = .{ .inverse = true }, .expected = 1 << 4 },
.{ .value = .{ .invisible = true }, .expected = 1 << 5 },
.{ .value = .{ .strikethrough = true }, .expected = 1 << 6 },
.{ .value = .{ .overline = true }, .expected = 1 << 7 },
.{
.value = .{
.underline = @intFromEnum(sgr.Attribute.Underline.single),
},
.expected = 1 << 8,
},
.{
.value = .{
.underline = @intFromEnum(sgr.Attribute.Underline.double),
},
.expected = 2 << 8,
},
.{
.value = .{
.underline = @intFromEnum(sgr.Attribute.Underline.curly),
},
.expected = 3 << 8,
},
.{
.value = .{
.underline = @intFromEnum(sgr.Attribute.Underline.dotted),
},
.expected = 4 << 8,
},
.{
.value = .{
.underline = @intFromEnum(sgr.Attribute.Underline.dashed),
},
.expected = 5 << 8,
},
.{ .value = .{ .reserved = 1 }, .expected = 1 << 11 },
};
for (cases) |case| {
try std.testing.expectEqual(
case.expected,
@as(u16, @bitCast(case.value)),
);
}
}
test "reject invalid colors" {
for ([_]usize{ 0, 4, 8 }) |offset| {
var invalid_kind: [len]u8 = @splat(0);
invalid_kind[offset] = 3;
var reader: std.Io.Reader = .fixed(&invalid_kind);
try std.testing.expectError(error.InvalidColorKind, decode(&reader));
}
var invalid_none: [len]u8 = @splat(0);
invalid_none[1] = 1;
var none_reader: std.Io.Reader = .fixed(&invalid_none);
try std.testing.expectError(error.InvalidColor, decode(&none_reader));
var invalid_palette: [len]u8 = @splat(0);
invalid_palette[0] = @intFromEnum(ColorKind.palette);
invalid_palette[2] = 1;
var palette_reader: std.Io.Reader = .fixed(&invalid_palette);
try std.testing.expectError(error.InvalidColor, decode(&palette_reader));
}
test "reject invalid flags and reserved field" {
inline for (.{ 6, 7 }) |underline| {
for ([_]u16{ 6, 7 }) |underline| {
var invalid_underline: [len]u8 = @splat(0);
std.mem.writeInt(
u16,

View File

@@ -1195,16 +1195,11 @@ const test_header_fixture =
"\xff\xff\xff\xff\xff\xff\xff\xff" ++
"\x08\x07\x06\x05\x04\x03\x02\x01";
test "TERMINAL payload type registries" {
try std.testing.expectEqual(@as(usize, 103), Header.len);
test "TERMINAL mode bit layout" {
try std.testing.expectEqual(
@as(usize, 42),
@bitSizeOf(terminal_modes.ModePacked),
);
try std.testing.expectEqual(
@as(usize, 8),
@sizeOf(terminal_modes.ModePacked),
);
var first: terminal_modes.ModePacked = std.mem.zeroes(
terminal_modes.ModePacked,
@@ -1232,15 +1227,6 @@ test "TERMINAL payload type registries" {
@as(u42, 1) << 41,
@as(u42, @bitCast(last)),
);
try std.testing.expectEqual(
@as(u1, 0),
@intFromEnum(terminal_ansi.StatusDisplay.main),
);
try std.testing.expectEqual(
@as(c_int, 33),
@intFromEnum(terminal_mouse.Shape.zoom_out),
);
}
test "TERMINAL header golden encoding and decoding" {
@@ -1297,28 +1283,33 @@ test "TERMINAL header rejects invalid values" {
// Single-byte registries and boolean encodings reject every value just
// beyond their current snapshot range.
const byte_cases = .{
.{ @as(usize, 20), @as(u8, 2), error.InvalidStatusDisplay },
.{ @as(usize, 21), @as(u8, 2), error.InvalidActiveScreenKey },
.{ @as(usize, 29), @as(u8, 2), error.InvalidCursorIsDefault },
.{ @as(usize, 30), @as(u8, 4), error.InvalidCursorStyle },
.{ @as(usize, 31), @as(u8, 3), error.InvalidCursorBlink },
.{ @as(usize, 32), @as(u8, 3), error.InvalidShellRedraw },
.{ @as(usize, 33), @as(u8, 2), error.InvalidModifyOtherKeys },
.{ @as(usize, 34), @as(u8, 5), error.InvalidMouseEvent },
.{ @as(usize, 35), @as(u8, 5), error.InvalidMouseFormat },
.{ @as(usize, 36), @as(u8, 3), error.InvalidMouseShiftCapture },
.{ @as(usize, 37), @as(u8, 34), error.InvalidMouseShape },
.{ @as(usize, 38), @as(u8, 2), error.InvalidPasswordInput },
.{ @as(usize, 44), @as(u8, 4), error.InvalidModes },
.{ @as(usize, 63), @as(u8, 2), error.InvalidDynamicRGB },
.{ @as(usize, 68), @as(u8, 1), error.InvalidDynamicRGB },
const ByteCase = struct {
offset: usize,
value: u8,
expected: anyerror,
};
inline for (byte_cases) |case| {
const byte_cases = [_]ByteCase{
.{ .offset = 20, .value = 2, .expected = error.InvalidStatusDisplay },
.{ .offset = 21, .value = 2, .expected = error.InvalidActiveScreenKey },
.{ .offset = 29, .value = 2, .expected = error.InvalidCursorIsDefault },
.{ .offset = 30, .value = 4, .expected = error.InvalidCursorStyle },
.{ .offset = 31, .value = 3, .expected = error.InvalidCursorBlink },
.{ .offset = 32, .value = 3, .expected = error.InvalidShellRedraw },
.{ .offset = 33, .value = 2, .expected = error.InvalidModifyOtherKeys },
.{ .offset = 34, .value = 5, .expected = error.InvalidMouseEvent },
.{ .offset = 35, .value = 5, .expected = error.InvalidMouseFormat },
.{ .offset = 36, .value = 3, .expected = error.InvalidMouseShiftCapture },
.{ .offset = 37, .value = 34, .expected = error.InvalidMouseShape },
.{ .offset = 38, .value = 2, .expected = error.InvalidPasswordInput },
.{ .offset = 44, .value = 4, .expected = error.InvalidModes },
.{ .offset = 63, .value = 2, .expected = error.InvalidDynamicRGB },
.{ .offset = 68, .value = 1, .expected = error.InvalidDynamicRGB },
};
for (byte_cases) |case| {
var fixture = test_header_fixture.*;
fixture[case[0]] = case[1];
fixture[case.offset] = case.value;
var reader: std.Io.Reader = .fixed(&fixture);
try testing.expectError(case[2], Header.decode(&reader));
try testing.expectError(case.expected, Header.decode(&reader));
}
// Cross-field and multi-byte invariants are validated after decoding.
@@ -1480,7 +1471,7 @@ test "TERMINAL payload rejects noncanonical state" {
try testing.expectEqual(@as(usize, 0), writer.end);
}
test "TERMINAL payload rejects padding and every truncation" {
test "TERMINAL payload rejects tab-stop padding" {
const testing = std.testing;
var tabstops = try TerminalTabstops.init(
@@ -1519,16 +1510,6 @@ test "TERMINAL payload rejects padding and every truncation" {
error.InvalidTabStops,
decodePayload(&padding_reader, testing.allocator),
);
for (0..destination.written().len) |fixture_len| {
var reader: std.Io.Reader = .fixed(
destination.written()[0..fixture_len],
);
try testing.expectError(
error.EndOfStream,
decodePayload(&reader, testing.allocator),
);
}
}
test "TERMINAL record encodes native terminal state" {
@@ -1579,87 +1560,14 @@ test "TERMINAL record encodes native terminal state" {
try terminal.setPwd("file:///tmp/native");
try terminal.setTitle("native title");
// Decode the generated framing and payload independently.
// Encode and restore through the public record codec.
var destination: std.Io.Writer.Allocating = .init(testing.allocator);
defer destination.deinit();
try encode(&terminal, &destination);
var source: std.Io.Reader = .fixed(destination.written());
var record_reader: record.Reader = undefined;
try record_reader.init(&source);
try testing.expectEqual(record.Tag.terminal, record_reader.header.tag);
var decoded = try decodePayload(
record_reader.payloadReader(),
testing.allocator,
);
defer decoded.deinit(testing.allocator);
try record_reader.finish();
// Native screen routing and policy are derived rather than caller-supplied.
try testing.expectEqual(@as(u16, 10), decoded.header.columns);
try testing.expectEqual(@as(u16, 3), decoded.header.rows);
try testing.expectEqual(@as(u32, 800), decoded.header.width_px);
try testing.expectEqual(@as(u32, 600), decoded.header.height_px);
try testing.expectEqual(
terminal.scrolling_region,
Terminal.ScrollingRegion{
.top = decoded.header.scrolling_region_top,
.bottom = decoded.header.scrolling_region_bottom,
.left = decoded.header.scrolling_region_left,
.right = decoded.header.scrolling_region_right,
},
);
try testing.expectEqual(terminal.status_display, decoded.header.status_display);
try testing.expectEqual(.primary, decoded.header.active_screen_key);
try testing.expectEqual(@as(u16, 1), decoded.header.screen_count);
try testing.expectEqual(@as(?u21, 'X'), decoded.header.previous_codepoint);
try testing.expectEqual(terminal.cursor.is_default, decoded.header.cursor_is_default);
try testing.expectEqual(
terminal.cursor.default_style,
decoded.header.cursor_default_style,
);
try testing.expectEqual(
terminal.cursor.default_blink,
decoded.header.cursor_default_blink,
);
try testing.expectEqual(
terminal.flags.shell_redraws_prompt,
decoded.header.shell_redraw,
);
try testing.expectEqual(
terminal.flags.modify_other_keys_2,
decoded.header.modify_other_keys_2,
);
try testing.expectEqual(terminal.flags.mouse_event, decoded.header.mouse_event);
try testing.expectEqual(
terminal.flags.mouse_format,
decoded.header.mouse_format,
);
try testing.expectEqual(@as(?bool, true), decoded.header.mouse_shift_capture);
try testing.expectEqual(terminal.mouse_shape, decoded.header.mouse_shape);
try testing.expectEqual(
terminal.flags.password_input,
decoded.header.password_input,
);
try testing.expectEqualDeep(terminal.modes.values, decoded.header.current_modes);
try testing.expectEqualDeep(terminal.modes.saved, decoded.header.saved_modes);
try testing.expectEqualDeep(terminal.modes.default, decoded.header.default_modes);
try testing.expectEqualDeep(
terminal.colors.background,
decoded.header.background,
);
try testing.expectEqualDeep(terminal.colors.palette, decoded.palette);
try testing.expectEqual(@as(?u64, 12_345), decoded.header.max_scrollback_bytes);
try testing.expectEqual(@as(?u64, 67), decoded.header.max_scrollback_rows);
try testing.expect(decoded.tabstops.get(1));
try testing.expect(decoded.tabstops.get(9));
try testing.expectEqualStrings("file:///tmp/native", decoded.pwd);
try testing.expectEqualStrings("native title", decoded.title);
// The public decoder installs the same payload into native ownership.
var native_source: std.Io.Reader = .fixed(destination.written());
var restored = try decode(
&native_source,
&source,
testing.io,
testing.allocator,
);
@@ -1675,6 +1583,31 @@ test "TERMINAL record encodes native terminal state" {
try testing.expect(restored.screens.get(.alternate) == null);
try testing.expectEqual(@as(?u21, 'X'), restored.previous_char);
try testing.expectEqualDeep(terminal.cursor, restored.cursor);
try testing.expectEqual(
terminal.flags.shell_redraws_prompt,
restored.flags.shell_redraws_prompt,
);
try testing.expectEqual(
terminal.flags.modify_other_keys_2,
restored.flags.modify_other_keys_2,
);
try testing.expectEqual(
terminal.flags.mouse_event,
restored.flags.mouse_event,
);
try testing.expectEqual(
terminal.flags.mouse_format,
restored.flags.mouse_format,
);
try testing.expectEqual(
terminal.flags.mouse_shift_capture,
restored.flags.mouse_shift_capture,
);
try testing.expectEqual(
terminal.flags.password_input,
restored.flags.password_input,
);
try testing.expectEqual(terminal.mouse_shape, restored.mouse_shape);
try testing.expectEqualDeep(terminal.modes, restored.modes);
try testing.expectEqualDeep(terminal.colors, restored.colors);
try testing.expectEqual(