zcatui/src/testing.zig
reugenio c8316f2134 feat: zcatui v2.1 - 7 new widgets, innovations, and technical docs
New widgets (Phase 1-3):
- Spinner: 10 animation styles (dots, line, arc, pulse, etc.)
- Help: Keybinding display with categories
- Viewport: Content scrolling (static/scrollable)
- Progress: Multi-step progress with styles
- Markdown: Basic markdown rendering (headers, lists, code)
- DirectoryTree: File browser with icons and filters
- SyntaxHighlighter: Code highlighting (Zig, Rust, Python, etc.)

Innovation modules:
- testing.zig: Widget testing framework (harness, simulated input, benchmarks)
- theme_loader.zig: Theme hot-reload from JSON/KV files
- serialize.zig: State serialization, undo/redo stack
- accessibility.zig: A11y support (ARIA roles, screen reader, high contrast)

Layout improvements:
- Flex layout with JustifyContent and AlignItems

Documentation:
- TECHNICAL_REFERENCE.md: Comprehensive 1200+ line technical manual

Stats: 67 files, 34 widgets, 250+ tests

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-08 20:29:18 +01:00

566 lines
18 KiB
Zig

//! 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);
}