add(testing): new namespace containing testing capabilities for zterm
Some checks failed
Zig Project Action / Lint, Spell-check and test zig project (push) Failing after 39s

The namespace shall also be used for testing the rendering of
`Container`s and `Element`s (including the `Scrollable` element).

The testing renderer currently is a striped down version of the double
buffered render without the secondary buffer and the flushing to stdout.
The internal `Cell` slice (the *screen*) is used for equality checks.

The testing namespace shall provide a way to describe the expected
`Cell` slices that should be validated against.
This commit is contained in:
2025-02-24 17:14:57 +01:00
parent 5c5c59cbfc
commit 33262c9638
6 changed files with 180 additions and 19 deletions

View File

@@ -175,6 +175,7 @@ pub fn build(b: *std.Build) void {
.optimize = optimize, .optimize = optimize,
}); });
lib_unit_tests.root_module.addImport("code_point", zg.module("code_point")); lib_unit_tests.root_module.addImport("code_point", zg.module("code_point"));
lib_unit_tests.root_module.addImport("DisplayWidth", zg.module("DisplayWidth"));
const run_lib_unit_tests = b.addRunArtifact(lib_unit_tests); const run_lib_unit_tests = b.addRunArtifact(lib_unit_tests);

View File

@@ -74,10 +74,7 @@ pub fn main() !void {
var container = try App.Container.init(allocator, .{ var container = try App.Container.init(allocator, .{
.border = .{ .border = .{
.separator = .{ .separator = .{ .enabled = true },
.enabled = true,
.line = .double,
},
}, },
.layout = .{ .layout = .{
.gap = 2, .gap = 2,

View File

@@ -431,3 +431,9 @@ pub fn Container(comptime Event: type) type {
} }
}; };
} }
test {
_ = Border;
_ = Layout;
_ = Rectangle;
}

View File

@@ -7,8 +7,6 @@ const Size = @import("size.zig").Size;
/// Double-buffered intermediate rendering pipeline /// Double-buffered intermediate rendering pipeline
pub const Buffered = struct { pub const Buffered = struct {
const log = std.log.scoped(.renderer_buffered);
// _ = log;
allocator: std.mem.Allocator, allocator: std.mem.Allocator,
created: bool, created: bool,
size: Size, size: Size,
@@ -33,21 +31,22 @@ pub const Buffered = struct {
} }
pub fn resize(this: *@This(), size: Size) !void { pub fn resize(this: *@This(), size: Size) !void {
log.debug("renderer::resize", .{}); this.size = size;
defer this.size = size; const n = @as(usize, size.cols) * @as(usize, size.rows);
if (!this.created) { if (!this.created) {
this.screen = this.allocator.alloc(Cell, @as(usize, size.cols) * @as(usize, size.rows)) catch @panic("render.zig: Out of memory."); this.screen = this.allocator.alloc(Cell, n) catch @panic("render.zig: Out of memory.");
@memset(this.screen, .{});
this.virtual_screen = this.allocator.alloc(Cell, @as(usize, size.cols) * @as(usize, size.rows)) catch @panic("render.zig: Out of memory."); this.virtual_screen = this.allocator.alloc(Cell, n) catch @panic("render.zig: Out of memory.");
@memset(this.virtual_screen, .{}); @memset(this.virtual_screen, .{});
this.created = true; this.created = true;
} else { } else {
this.allocator.free(this.screen); this.allocator.free(this.screen);
this.screen = this.allocator.alloc(Cell, @as(usize, size.cols) * @as(usize, size.rows)) catch @panic("render.zig: Out of memory."); this.screen = this.allocator.alloc(Cell, n) catch @panic("render.zig: Out of memory.");
@memset(this.screen, .{});
this.allocator.free(this.virtual_screen); this.allocator.free(this.virtual_screen);
this.virtual_screen = this.allocator.alloc(Cell, @as(usize, size.cols) * @as(usize, size.rows)) catch @panic("render.zig: Out of memory."); this.virtual_screen = this.allocator.alloc(Cell, n) catch @panic("render.zig: Out of memory.");
@memset(this.virtual_screen, .{}); @memset(this.virtual_screen, .{});
} }
try this.clear(); try this.clear();
@@ -55,7 +54,6 @@ pub const Buffered = struct {
/// Clear the entire screen and reset the screen buffer, to force a re-draw with the next `flush` call. /// Clear the entire screen and reset the screen buffer, to force a re-draw with the next `flush` call.
pub fn clear(this: *@This()) !void { pub fn clear(this: *@This()) !void {
log.debug("renderer::clear", .{});
try terminal.clearScreen(); try terminal.clearScreen();
@memset(this.screen, .{}); @memset(this.screen, .{});
} }
@@ -85,15 +83,12 @@ pub const Buffered = struct {
// free immediately // free immediately
container.allocator.free(cells); container.allocator.free(cells);
for (container.elements.items) |*element| { for (container.elements.items) |*element| try this.render(T, element);
try this.render(T, element);
}
} }
/// Write *virtual screen* to alternate screen (should be called once and last during each render loop iteration in the main loop). /// Write *virtual screen* to alternate screen (should be called once and last during each render loop iteration in the main loop).
pub fn flush(this: *@This()) !void { pub fn flush(this: *@This()) !void {
// TODO: measure timings of rendered frames? // TODO: measure timings of rendered frames?
log.debug("renderer::flush", .{});
const writer = terminal.writer(); const writer = terminal.writer();
const s = this.screen; const s = this.screen;
const vs = this.virtual_screen; const vs = this.virtual_screen;

160
src/testing.zig Normal file
View File

@@ -0,0 +1,160 @@
//! Testing namespace for `zterm` to provide testing capabilites for `Containers`, `Event` handling, `App`s and `Element` implementations.
const std = @import("std");
const event = @import("event.zig");
const Container = @import("container.zig").Container;
const Cell = @import("cell.zig");
const DisplayWidth = @import("DisplayWidth");
const Position = @import("size.zig").Position;
const Size = @import("size.zig").Size;
// TODO: how would I describe the expected screens?
// - including styling?
// - compare generated strings instead? -> how would this be generated for the user?
/// Single-buffer test rendering pipeline for testing purposes.
pub const Renderer = struct {
allocator: std.mem.Allocator,
size: Size,
screen: []Cell,
pub fn init(allocator: std.mem.Allocator, size: Size) @This() {
const screen = allocator.alloc(Cell, @as(usize, size.cols) * @as(usize, size.rows)) catch @panic("testing.zig: Out of memory.");
@memset(screen, .{});
return .{
.allocator = allocator,
.size = size,
.screen = screen,
};
}
pub fn deinit(this: *@This()) void {
this.allocator.free(this.screen);
}
pub fn resize(this: *@This(), size: Size) !void {
this.size = size;
const n = @as(usize, size.cols) * @as(usize, size.rows);
this.allocator.free(this.screen);
this.screen = this.allocator.alloc(Cell, n) catch @panic("testing.zig: Out of memory.");
@memset(this.screen, .{});
}
pub fn clear(this: *@This()) !void {
@memset(this.screen, .{});
}
pub fn render(this: *@This(), comptime T: type, container: *const T) !void {
const size: Size = container.size;
const cells: []const Cell = try container.contents();
if (cells.len == 0) return;
var idx: usize = 0;
const anchor = (@as(usize, size.anchor.row) * @as(usize, this.size.cols)) + @as(usize, size.anchor.col);
blk: for (0..size.rows) |row| {
for (0..size.cols) |col| {
const cell = cells[idx];
idx += 1;
this.screen[anchor + (row * this.size.cols) + col].style = cell.style;
this.screen[anchor + (row * this.size.cols) + col].cp = cell.cp;
if (cells.len == idx) break :blk;
}
}
// free immediately
container.allocator.free(cells);
for (container.elements.items) |*element| try this.render(T, element);
}
};
/// This function is intended to be used only in tests. Test if a `Container`'s
/// rendered contents are equal to the expected `Cell` slice.
pub fn expectContainerScreen(size: Size, container: *Container(event.SystemEvent), expected: []const Cell) !void {
const allocator = std.testing.allocator;
var renderer: Renderer = .init(allocator, size);
defer renderer.deinit();
try container.handle(.{ .resize = size });
try renderer.render(Container(event.SystemEvent), container);
try expectEqualCells(renderer.size, expected, renderer.screen);
}
/// This function is intended to be used only in tests. Test if the two
/// provided cell arrays are identical. Usually the `Cell` slices are
/// the contents of a given screen from the `zterm.testing.Renderer`. See
/// `zterm.testing.expectContainerScreen` for an example usage.
pub fn expectEqualCells(size: Size, expected: []const Cell, actual: []const Cell) !void {
const allocator = std.testing.allocator;
try std.testing.expectEqual(expected.len, actual.len);
try std.testing.expectEqual(expected.len, @as(usize, size.rows) * @as(usize, size.cols));
var expected_cps = try std.ArrayList(Cell).initCapacity(allocator, size.cols -| size.anchor.col);
defer expected_cps.deinit();
var actual_cps = try std.ArrayList(Cell).initCapacity(allocator, size.cols -| size.anchor.col);
defer actual_cps.deinit();
var output = try std.ArrayList(u8).initCapacity(allocator, expected_cps.capacity * actual_cps.capacity + 5 * size.rows);
defer output.deinit();
var buffer = std.io.bufferedWriter(output.writer());
defer buffer.flush() catch {};
const writer = buffer.writer();
var differ = false;
const dwd = try DisplayWidth.DisplayWidthData.init(allocator);
defer dwd.deinit();
const dw: DisplayWidth = .{ .data = &dwd };
const expected_centered = try dw.center(allocator, "Expected Screen", size.cols, " ");
defer allocator.free(expected_centered);
const actual_centered = try dw.center(allocator, "Actual Screen", size.cols, " ");
defer allocator.free(actual_centered);
try writer.print("Screens are not equivalent.\n{s} ┆ {s}\n", .{ expected_centered, actual_centered });
const anchor = (size.anchor.row * size.cols) + size.anchor.col;
for (size.anchor.row..size.rows) |row| {
defer {
expected_cps.clearRetainingCapacity();
actual_cps.clearRetainingCapacity();
}
for (size.anchor.col..size.cols) |col| {
const expected_cell = expected[anchor + (row * size.cols) + col];
const actual_cell = actual[anchor + (row * size.cols) + col];
if (!expected_cell.eql(actual_cell)) differ = true;
try expected_cps.append(expected_cell);
try actual_cps.append(actual_cell);
}
// write screens both formatted to buffer
for (expected_cps.items) |cell| try cell.value(writer);
_ = try writer.write("");
for (actual_cps.items) |cell| try cell.value(writer);
_ = try writer.write("\n");
}
if (!differ) return;
// test failed
try buffer.flush();
std.debug.lockStdErr();
defer std.debug.unlockStdErr();
const std_writer = std.io.getStdErr().writer();
try std_writer.writeAll(output.items);
return error.TestExpectEqualCells;
}

View File

@@ -5,6 +5,7 @@ const size = @import("size.zig");
// public exports // public exports
pub const input = @import("input.zig"); pub const input = @import("input.zig");
pub const testing = @import("testing.zig");
pub const App = @import("app.zig").App; pub const App = @import("app.zig").App;
// App also exports further types once initialized with the user events at compile time: // App also exports further types once initialized with the user events at compile time:
@@ -28,6 +29,7 @@ pub const Style = @import("style.zig");
test { test {
_ = @import("terminal.zig"); _ = @import("terminal.zig");
_ = @import("container.zig");
_ = @import("queue.zig"); _ = @import("queue.zig");
_ = color; _ = color;