//! Widget Testing Framework for zcatui //! //! Provides utilities for testing widgets in isolation: //! - TestBackend: Mock backend for capturing output //! - TestTerminal: Terminal that captures frames //! - Assertions: Test helpers for widget behavior //! - Snapshots: Golden file testing //! //! Example: //! ```zig //! const testing = @import("testing.zig"); //! const widgets = @import("root.zig").widgets; //! //! test "paragraph renders correctly" { //! var harness = testing.WidgetHarness.init(testing.testing_allocator, 40, 10); //! defer harness.deinit(); //! //! const para = widgets.Paragraph.init("Hello, World!"); //! harness.render(para); //! //! try harness.expectText(0, 0, "Hello, World!"); //! try harness.expectStyle(0, 0, .{ .foreground = .white }); //! } //! ``` const std = @import("std"); const Buffer = @import("buffer.zig").Buffer; const Cell = @import("buffer.zig").Cell; const Rect = @import("buffer.zig").Rect; const Style = @import("style.zig").Style; const Color = @import("style.zig").Color; /// Test allocator for use in tests pub const testing_allocator = std.testing.allocator; // ============================================================================ // Test Backend // ============================================================================ /// A mock backend that captures all output for testing pub const TestBackend = struct { /// Captured ANSI sequences output: std.ArrayListUnmanaged(u8), allocator: std.mem.Allocator, /// Cursor position cursor_x: u16 = 0, cursor_y: u16 = 0, /// Cursor visible cursor_visible: bool = true, /// Terminal size width: u16, height: u16, /// Alternate screen active alternate_screen: bool = false, /// Raw mode active raw_mode: bool = false, /// Mouse capture active mouse_capture: bool = false, pub fn init(allocator: std.mem.Allocator, width: u16, height: u16) TestBackend { return .{ .output = .{}, .allocator = allocator, .width = width, .height = height, }; } pub fn deinit(self: *TestBackend) void { self.output.deinit(self.allocator); } /// Write output (captured) pub fn write(self: *TestBackend, data: []const u8) !void { try self.output.appendSlice(self.allocator, data); } /// Clear captured output pub fn clearOutput(self: *TestBackend) void { self.output.clearRetainingCapacity(); } /// Get captured output as string pub fn getOutput(self: *const TestBackend) []const u8 { return self.output.items; } /// Check if output contains a string pub fn outputContains(self: *const TestBackend, needle: []const u8) bool { return std.mem.indexOf(u8, self.output.items, needle) != null; } /// Simulate terminal resize pub fn resize(self: *TestBackend, width: u16, height: u16) void { self.width = width; self.height = height; } /// Get terminal size pub fn size(self: *const TestBackend) struct { width: u16, height: u16 } { return .{ .width = self.width, .height = self.height }; } }; // ============================================================================ // Widget Test Harness // ============================================================================ /// Test harness for widget testing pub const WidgetHarness = struct { allocator: std.mem.Allocator, buffer: Buffer, area: Rect, /// Previous buffer for diff testing prev_buffer: ?Buffer = null, /// Frame counter frame_count: u32 = 0, /// Initialize test harness with given dimensions pub fn init(allocator: std.mem.Allocator, width: u16, height: u16) WidgetHarness { const area = Rect.init(0, 0, width, height); return .{ .allocator = allocator, .buffer = Buffer.init(allocator, area) catch unreachable, .area = area, }; } /// Deinitialize pub fn deinit(self: *WidgetHarness) void { self.buffer.deinit(); if (self.prev_buffer) |*prev| { prev.deinit(); } } /// Reset buffer for new test pub fn reset(self: *WidgetHarness) void { self.buffer.clear(); self.frame_count = 0; } /// Render a widget to the test buffer pub fn render(self: *WidgetHarness, widget: anytype) void { widget.render(self.area, &self.buffer); self.frame_count += 1; } /// Render a widget with state pub fn renderWithState(self: *WidgetHarness, widget: anytype, state: anytype) void { widget.render(self.area, &self.buffer, state); self.frame_count += 1; } /// Render to a specific area within the harness pub fn renderTo(self: *WidgetHarness, widget: anytype, area: Rect) void { widget.render(area, &self.buffer); self.frame_count += 1; } // ======================================================================== // Assertions // ======================================================================== /// Get cell at position pub fn getCell(self: *const WidgetHarness, x: u16, y: u16) ?*const Cell { return self.buffer.get(x, y); } /// Get text at position (single character) pub fn getChar(self: *const WidgetHarness, x: u16, y: u16) ?[]const u8 { if (self.buffer.get(x, y)) |cell| { return cell.symbol.slice(); } return null; } /// Get text from a row pub fn getRowText(self: *const WidgetHarness, y: u16) []const u8 { var result: [256]u8 = undefined; var len: usize = 0; var x: u16 = 0; while (x < self.area.width) : (x += 1) { if (self.buffer.get(x, y)) |cell| { const sym = cell.symbol.slice(); if (len + sym.len <= result.len) { @memcpy(result[len..][0..sym.len], sym); len += sym.len; } } } // Trim trailing spaces while (len > 0 and result[len - 1] == ' ') { len -= 1; } return result[0..len]; } /// Expect text at position pub fn expectText(self: *const WidgetHarness, x: u16, y: u16, expected: []const u8) !void { var pos_x = x; for (expected) |char| { if (self.buffer.get(pos_x, y)) |cell| { const sym = cell.symbol.slice(); if (sym.len > 0) { try std.testing.expectEqual(char, sym[0]); } } pos_x += 1; } } /// Expect style at position pub fn expectStyle(self: *const WidgetHarness, x: u16, y: u16, expected_style: Style) !void { if (self.buffer.get(x, y)) |cell| { try std.testing.expectEqual(expected_style.foreground, cell.style.foreground); try std.testing.expectEqual(expected_style.background, cell.style.background); } else { return error.CellNotFound; } } /// Expect foreground color at position pub fn expectFg(self: *const WidgetHarness, x: u16, y: u16, expected: Color) !void { if (self.buffer.get(x, y)) |cell| { try std.testing.expectEqual(expected, cell.style.foreground); } else { return error.CellNotFound; } } /// Expect background color at position pub fn expectBg(self: *const WidgetHarness, x: u16, y: u16, expected: Color) !void { if (self.buffer.get(x, y)) |cell| { try std.testing.expectEqual(expected, cell.style.background); } else { return error.CellNotFound; } } /// Expect area to be empty (all spaces) pub fn expectEmpty(self: *const WidgetHarness, area: Rect) !void { var y = area.y; while (y < area.bottom()) : (y += 1) { var x = area.x; while (x < area.right()) : (x += 1) { if (self.buffer.get(x, y)) |cell| { const sym = cell.symbol.slice(); if (sym.len > 0 and sym[0] != ' ') { std.debug.print("Expected empty at ({}, {}), found: '{s}'\n", .{ x, y, sym }); return error.NotEmpty; } } } } } /// Expect area to not be empty pub fn expectNotEmpty(self: *const WidgetHarness, area: Rect) !void { var y = area.y; while (y < area.bottom()) : (y += 1) { var x = area.x; while (x < area.right()) : (x += 1) { if (self.buffer.get(x, y)) |cell| { const sym = cell.symbol.slice(); if (sym.len > 0 and sym[0] != ' ') { return; // Found non-empty } } } } return error.IsEmpty; } /// Check if a character exists anywhere in the buffer pub fn containsChar(self: *const WidgetHarness, char: u8) bool { var y: u16 = 0; while (y < self.area.height) : (y += 1) { var x: u16 = 0; while (x < self.area.width) : (x += 1) { if (self.buffer.get(x, y)) |cell| { const sym = cell.symbol.slice(); if (sym.len > 0 and sym[0] == char) { return true; } } } } return false; } /// Check if text exists anywhere in the buffer pub fn containsText(self: *const WidgetHarness, text: []const u8) bool { var y: u16 = 0; while (y < self.area.height) : (y += 1) { var x: u16 = 0; while (x < self.area.width -| @as(u16, @intCast(text.len))) : (x += 1) { var matches = true; for (text, 0..) |char, i| { if (self.buffer.get(x + @as(u16, @intCast(i)), y)) |cell| { const sym = cell.symbol.slice(); if (sym.len == 0 or sym[0] != char) { matches = false; break; } } else { matches = false; break; } } if (matches) return true; } } return false; } // ======================================================================== // Snapshot Testing // ======================================================================== /// Render buffer to string for snapshot comparison pub fn toString(self: *const WidgetHarness) []const u8 { var result: [4096]u8 = undefined; var len: usize = 0; var y: u16 = 0; while (y < self.area.height) : (y += 1) { var x: u16 = 0; while (x < self.area.width) : (x += 1) { if (self.buffer.get(x, y)) |cell| { const sym = cell.symbol.slice(); if (sym.len > 0 and len + sym.len <= result.len) { @memcpy(result[len..][0..sym.len], sym); len += sym.len; } else if (len < result.len) { result[len] = ' '; len += 1; } } else if (len < result.len) { result[len] = ' '; len += 1; } } if (len < result.len) { result[len] = '\n'; len += 1; } } return result[0..len]; } /// Print buffer for debugging pub fn debugPrint(self: *const WidgetHarness) void { std.debug.print("\n=== Buffer ({} x {}) ===\n", .{ self.area.width, self.area.height }); var y: u16 = 0; while (y < self.area.height) : (y += 1) { var x: u16 = 0; while (x < self.area.width) : (x += 1) { if (self.buffer.get(x, y)) |cell| { const sym = cell.symbol.slice(); if (sym.len > 0) { std.debug.print("{s}", .{sym}); } else { std.debug.print(" ", .{}); } } else { std.debug.print(" ", .{}); } } std.debug.print("|\n", .{}); } std.debug.print("======================\n", .{}); } }; // ============================================================================ // Event Simulation // ============================================================================ const Event = @import("event.zig").Event; const KeyEvent = @import("event.zig").KeyEvent; const KeyCode = @import("event.zig").KeyCode; const MouseEvent = @import("event.zig").MouseEvent; const MouseButton = @import("event.zig").MouseButton; const MouseEventKind = @import("event.zig").MouseEventKind; /// Helper to create key events for testing pub const SimulatedInput = struct { /// Create a key press event pub fn key(code: KeyCode) Event { return .{ .key = .{ .code = code, .modifiers = .{}, .kind = .press, }, }; } /// Create a key press with char pub fn char(c: u21) Event { return .{ .key = .{ .code = .{ .char = c }, .modifiers = .{}, .kind = .press, }, }; } /// Create a key with modifiers pub fn keyWithMod(code: KeyCode, ctrl: bool, alt: bool, shift: bool) Event { return .{ .key = .{ .code = code, .modifiers = .{ .ctrl = ctrl, .alt = alt, .shift = shift, }, .kind = .press, }, }; } /// Create mouse click event pub fn click(col: u16, row: u16, button: MouseButton) Event { return .{ .mouse = .{ .column = col, .row = row, .kind = .down, .button = button, .modifiers = .{}, }, }; } /// Create mouse scroll event pub fn scroll(col: u16, row: u16, down: bool) Event { return .{ .mouse = .{ .column = col, .row = row, .kind = if (down) .scroll_down else .scroll_up, .button = .none, .modifiers = .{}, }, }; } /// Create resize event pub fn resize(width: u16, height: u16) Event { return .{ .resize = .{ .width = width, .height = height, }, }; } }; // ============================================================================ // Benchmark Utilities // ============================================================================ /// Simple timing utility for widget benchmarks pub const Benchmark = struct { start_time: i128, iterations: u32 = 0, total_ns: i128 = 0, pub fn start() Benchmark { return .{ .start_time = std.time.nanoTimestamp(), }; } pub fn lap(self: *Benchmark) void { const now = std.time.nanoTimestamp(); self.total_ns += now - self.start_time; self.iterations += 1; self.start_time = now; } pub fn avgNs(self: *const Benchmark) i128 { if (self.iterations == 0) return 0; return @divTrunc(self.total_ns, self.iterations); } pub fn avgUs(self: *const Benchmark) f64 { return @as(f64, @floatFromInt(self.avgNs())) / 1000.0; } pub fn avgMs(self: *const Benchmark) f64 { return @as(f64, @floatFromInt(self.avgNs())) / 1_000_000.0; } pub fn report(self: *const Benchmark, name: []const u8) void { std.debug.print( "{s}: {} iterations, avg {d:.2}µs ({d:.2}ms total)\n", .{ name, self.iterations, self.avgUs(), @as(f64, @floatFromInt(self.total_ns)) / 1_000_000.0, }, ); } }; // ============================================================================ // Tests // ============================================================================ test "TestBackend captures output" { var backend = TestBackend.init(testing_allocator, 80, 24); defer backend.deinit(); try backend.write("Hello"); try backend.write(" World"); try std.testing.expectEqualStrings("Hello World", backend.getOutput()); try std.testing.expect(backend.outputContains("World")); try std.testing.expect(!backend.outputContains("Foo")); } test "WidgetHarness basic operations" { var harness = WidgetHarness.init(testing_allocator, 20, 5); defer harness.deinit(); // Test basic harness creation try std.testing.expectEqual(@as(u16, 20), harness.area.width); try std.testing.expectEqual(@as(u16, 5), harness.area.height); } test "SimulatedInput creates events" { const key_event = SimulatedInput.key(.enter); try std.testing.expectEqual(KeyCode.enter, key_event.key.code); const char_event = SimulatedInput.char('a'); try std.testing.expectEqual(KeyCode{ .char = 'a' }, char_event.key.code); const click_event = SimulatedInput.click(10, 5, .left); try std.testing.expectEqual(@as(u16, 10), click_event.mouse.column); try std.testing.expectEqual(@as(u16, 5), click_event.mouse.row); } test "Benchmark timing" { var bench = Benchmark.start(); var i: u32 = 0; while (i < 100) : (i += 1) { // Simulate some work _ = @as(u32, 0) +% @as(u32, 1); bench.lap(); } try std.testing.expectEqual(@as(u32, 100), bench.iterations); try std.testing.expect(bench.total_ns > 0); }