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>
566 lines
18 KiB
Zig
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);
|
|
}
|