feat: zcatgui v0.14.0 - Phase 8 Accessibility & Testing

Accessibility System:
- Role enum for all widget types (button, checkbox, slider,
  tree, table, menu, dialog, etc.)
- State packed struct (disabled, focused, selected, checked,
  expanded, pressed, invalid, readonly, required, busy)
- Info struct with label, description, value, position,
  level, controls, labelled_by
- Manager for tracking widget accessibility info
- announce() method for screen reader announcements
- Live region support (polite, assertive)
- Helper constructors for common patterns

Testing Framework:
- TestRunner for simulating user interactions:
  - Mouse: click, doubleClick, drag, scroll, moveMouse
  - Keyboard: pressKey, typeText, shortcut
  - Time: tick, waitFrames, advanceTime
- SnapshotTester for visual regression testing:
  - capture() to save framebuffer
  - compare() to diff against baseline
  - update() to update baseline
- Assertions module:
  - assertVisible, assertContains, assertIntersects
  - assertColorEqual, assertColorNear
  - assertInRange

Widget count: 35 widgets
Test count: 274 tests passing

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
reugenio 2025-12-09 13:54:07 +01:00
parent 70fca5177b
commit 1a26d34aa3
4 changed files with 1064 additions and 0 deletions

554
src/core/accessibility.zig Normal file
View file

@ -0,0 +1,554 @@
//! Accessibility System
//!
//! Provides accessibility information for widgets, enabling screen readers
//! and other assistive technologies to understand the UI.
const std = @import("std");
/// Accessibility role for widgets
pub const Role = enum {
/// No specific role
none,
/// Push button
button,
/// Checkbox (can be checked/unchecked)
checkbox,
/// Radio button (mutually exclusive selection)
radio,
/// Text input field
textbox,
/// Multi-line text editor
editor,
/// Slider/trackbar
slider,
/// Progress indicator
progressbar,
/// Dropdown list
combobox,
/// List box
listbox,
/// List item
listitem,
/// Tree view
tree,
/// Tree item
treeitem,
/// Table/grid
table,
/// Table row
row,
/// Table cell
cell,
/// Table header
columnheader,
/// Tab list container
tablist,
/// Individual tab
tab,
/// Tab content panel
tabpanel,
/// Menu bar
menubar,
/// Menu
menu,
/// Menu item
menuitem,
/// Dialog/modal
dialog,
/// Alert dialog
alertdialog,
/// Tooltip
tooltip,
/// Status message
status,
/// Link
link,
/// Image
img,
/// Group of related elements
group,
/// Panel/container
region,
/// Form
form,
/// Search field
search,
/// Separator
separator,
/// Scrollbar
scrollbar,
/// Spinner/loading indicator
spinbutton,
/// Static text
label,
/// Get human-readable name for the role
pub fn name(self: Role) []const u8 {
return switch (self) {
.none => "none",
.button => "button",
.checkbox => "checkbox",
.radio => "radio button",
.textbox => "text box",
.editor => "text editor",
.slider => "slider",
.progressbar => "progress bar",
.combobox => "combo box",
.listbox => "list box",
.listitem => "list item",
.tree => "tree",
.treeitem => "tree item",
.table => "table",
.row => "row",
.cell => "cell",
.columnheader => "column header",
.tablist => "tab list",
.tab => "tab",
.tabpanel => "tab panel",
.menubar => "menu bar",
.menu => "menu",
.menuitem => "menu item",
.dialog => "dialog",
.alertdialog => "alert dialog",
.tooltip => "tooltip",
.status => "status",
.link => "link",
.img => "image",
.group => "group",
.region => "region",
.form => "form",
.search => "search",
.separator => "separator",
.scrollbar => "scrollbar",
.spinbutton => "spin button",
.label => "label",
};
}
};
/// Accessibility state flags
pub const State = packed struct {
/// Widget is disabled
disabled: bool = false,
/// Widget is hidden
hidden: bool = false,
/// Widget has keyboard focus
focused: bool = false,
/// Widget is selected
selected: bool = false,
/// Checkbox/toggle is checked
checked: bool = false,
/// Node is expanded (tree, menu)
expanded: bool = false,
/// Widget is pressed (button)
pressed: bool = false,
/// Widget has invalid input
invalid: bool = false,
/// Widget is read-only
readonly: bool = false,
/// Widget is required
required: bool = false,
/// Content is being loaded
busy: bool = false,
/// Widget supports multiple selection
multiselectable: bool = false,
/// Item is draggable
draggable: bool = false,
_padding: u3 = 0,
pub fn default() State {
return .{};
}
};
/// Accessibility information for a widget
pub const Info = struct {
/// Widget's role
role: Role = .none,
/// Accessible label (name)
label: []const u8 = "",
/// Detailed description
description: []const u8 = "",
/// Current value (for sliders, progress, etc.)
value: ?[]const u8 = null,
/// Minimum value (for range widgets)
value_min: ?f64 = null,
/// Maximum value (for range widgets)
value_max: ?f64 = null,
/// Current value as number
value_now: ?f64 = null,
/// State flags
state: State = .{},
/// Position in set (e.g., "3 of 10")
pos_in_set: ?u32 = null,
/// Size of set
set_size: ?u32 = null,
/// Level (for headings, tree items)
level: ?u8 = null,
/// ID of element this controls
controls: ?u64 = null,
/// ID of element that labels this
labelled_by: ?u64 = null,
/// ID of element that describes this
described_by: ?u64 = null,
/// Error message (when invalid)
error_message: []const u8 = "",
/// Keyboard shortcut
shortcut: []const u8 = "",
/// Live region type
live: LiveRegion = .off,
/// Auto-complete type
autocomplete: AutoComplete = .none,
const Self = @This();
/// Create info for a button
pub fn button(label_text: []const u8) Self {
return .{
.role = .button,
.label = label_text,
};
}
/// Create info for a checkbox
pub fn checkbox(label_text: []const u8, is_checked: bool) Self {
return .{
.role = .checkbox,
.label = label_text,
.state = .{ .checked = is_checked },
};
}
/// Create info for a text input
pub fn textInput(label_text: []const u8, placeholder: []const u8) Self {
return .{
.role = .textbox,
.label = label_text,
.description = placeholder,
};
}
/// Create info for a slider
pub fn slider(label_text: []const u8, min: f64, max: f64, current: f64) Self {
return .{
.role = .slider,
.label = label_text,
.value_min = min,
.value_max = max,
.value_now = current,
};
}
/// Create info for a progress bar
pub fn progressBar(label_text: []const u8, progress: f64) Self {
return .{
.role = .progressbar,
.label = label_text,
.value_min = 0,
.value_max = 100,
.value_now = progress * 100,
};
}
/// Create info for a list item
pub fn listItem(label_text: []const u8, position: u32, total: u32) Self {
return .{
.role = .listitem,
.label = label_text,
.pos_in_set = position,
.set_size = total,
};
}
/// Create info for a tree item
pub fn treeItem(label_text: []const u8, lvl: u8, is_expanded: bool) Self {
return .{
.role = .treeitem,
.label = label_text,
.level = lvl,
.state = .{ .expanded = is_expanded },
};
}
/// Create info for a dialog
pub fn dialog(title: []const u8, is_modal: bool) Self {
_ = is_modal;
return .{
.role = .dialog,
.label = title,
};
}
/// Create info for a menu item
pub fn menuItem(label_text: []const u8, has_shortcut: []const u8) Self {
return .{
.role = .menuitem,
.label = label_text,
.shortcut = has_shortcut,
};
}
/// Set disabled state
pub fn setDisabled(self: *Self, disabled: bool) void {
self.state.disabled = disabled;
}
/// Set focused state
pub fn setFocused(self: *Self, focused: bool) void {
self.state.focused = focused;
}
/// Set selected state
pub fn setSelected(self: *Self, selected: bool) void {
self.state.selected = selected;
}
/// Set expanded state
pub fn setExpanded(self: *Self, expanded: bool) void {
self.state.expanded = expanded;
}
/// Format as accessible text announcement
pub fn announce(self: Self, buf: []u8) []const u8 {
var stream = std.io.fixedBufferStream(buf);
const writer = stream.writer();
// Label
if (self.label.len > 0) {
writer.writeAll(self.label) catch return buf[0..0];
}
// Role
writer.print(", {s}", .{self.role.name()}) catch return buf[0..stream.pos];
// Value
if (self.value) |v| {
writer.print(", {s}", .{v}) catch return buf[0..stream.pos];
} else if (self.value_now) |v| {
if (self.value_min != null and self.value_max != null) {
writer.print(", {d:.0} of {d:.0}", .{ v, self.value_max.? }) catch return buf[0..stream.pos];
}
}
// State
if (self.state.disabled) {
writer.writeAll(", disabled") catch return buf[0..stream.pos];
}
if (self.state.checked) {
writer.writeAll(", checked") catch return buf[0..stream.pos];
}
if (self.state.expanded) {
writer.writeAll(", expanded") catch return buf[0..stream.pos];
}
if (self.state.selected) {
writer.writeAll(", selected") catch return buf[0..stream.pos];
}
if (self.state.invalid) {
writer.writeAll(", invalid") catch return buf[0..stream.pos];
}
// Position
if (self.pos_in_set != null and self.set_size != null) {
writer.print(", {d} of {d}", .{ self.pos_in_set.?, self.set_size.? }) catch return buf[0..stream.pos];
}
return buf[0..stream.pos];
}
};
/// Live region types for dynamic content
pub const LiveRegion = enum {
/// Not a live region
off,
/// Polite - announce when convenient
polite,
/// Assertive - announce immediately
assertive,
};
/// Auto-complete types
pub const AutoComplete = enum {
/// No auto-complete
none,
/// Inline completion
@"inline",
/// List of suggestions
list,
/// Both inline and list
both,
};
/// Accessibility manager for tracking widget info
pub const Manager = struct {
/// Map of widget IDs to accessibility info
info: std.AutoHashMap(u64, Info),
/// Current focus ID
focus_id: ?u64 = null,
/// Pending announcements
announcements: [8]Announcement = undefined,
announcement_count: usize = 0,
const Self = @This();
pub fn init(allocator: std.mem.Allocator) Self {
return .{
.info = std.AutoHashMap(u64, Info).init(allocator),
};
}
pub fn deinit(self: *Self) void {
self.info.deinit();
}
/// Register accessibility info for a widget
pub fn register(self: *Self, widget_id: u64, acc_info: Info) void {
self.info.put(widget_id, acc_info) catch {};
}
/// Unregister a widget
pub fn unregister(self: *Self, widget_id: u64) void {
_ = self.info.remove(widget_id);
}
/// Get info for a widget
pub fn get(self: Self, widget_id: u64) ?Info {
return self.info.get(widget_id);
}
/// Update focus
pub fn setFocus(self: *Self, widget_id: u64) void {
// Clear old focus
if (self.focus_id) |old_id| {
if (self.info.getPtr(old_id)) |info| {
info.state.focused = false;
}
}
// Set new focus
self.focus_id = widget_id;
if (self.info.getPtr(widget_id)) |info| {
info.state.focused = true;
}
}
/// Queue an announcement
pub fn queueAnnouncement(self: *Self, text: []const u8, priority: LiveRegion) void {
if (self.announcement_count < 8) {
self.announcements[self.announcement_count] = .{
.text = text,
.priority = priority,
};
self.announcement_count += 1;
}
}
/// Get and clear pending announcements
pub fn getAnnouncements(self: *Self) []const Announcement {
const count = self.announcement_count;
self.announcement_count = 0;
return self.announcements[0..count];
}
/// Clear all info
pub fn clear(self: *Self) void {
self.info.clearRetainingCapacity();
self.focus_id = null;
self.announcement_count = 0;
}
};
/// Pending announcement
pub const Announcement = struct {
text: []const u8,
priority: LiveRegion,
};
// =============================================================================
// Tests
// =============================================================================
test "Role name" {
try std.testing.expectEqualStrings("button", Role.button.name());
try std.testing.expectEqualStrings("checkbox", Role.checkbox.name());
try std.testing.expectEqualStrings("text box", Role.textbox.name());
}
test "State packed size" {
try std.testing.expectEqual(@as(usize, 2), @sizeOf(State));
}
test "Info button" {
const info = Info.button("Submit");
try std.testing.expectEqual(Role.button, info.role);
try std.testing.expectEqualStrings("Submit", info.label);
}
test "Info checkbox" {
const info = Info.checkbox("Accept terms", true);
try std.testing.expectEqual(Role.checkbox, info.role);
try std.testing.expect(info.state.checked);
}
test "Info slider" {
const info = Info.slider("Volume", 0, 100, 50);
try std.testing.expectEqual(Role.slider, info.role);
try std.testing.expectEqual(@as(?f64, 0), info.value_min);
try std.testing.expectEqual(@as(?f64, 100), info.value_max);
try std.testing.expectEqual(@as(?f64, 50), info.value_now);
}
test "Info announce" {
var buf: [256]u8 = undefined;
const info = Info.checkbox("Accept terms", true);
const text = info.announce(&buf);
try std.testing.expect(std.mem.indexOf(u8, text, "Accept terms") != null);
try std.testing.expect(std.mem.indexOf(u8, text, "checkbox") != null);
try std.testing.expect(std.mem.indexOf(u8, text, "checked") != null);
}
test "Manager basic" {
var manager = Manager.init(std.testing.allocator);
defer manager.deinit();
manager.register(1, Info.button("Test"));
const info = manager.get(1);
try std.testing.expect(info != null);
try std.testing.expectEqualStrings("Test", info.?.label);
manager.unregister(1);
try std.testing.expect(manager.get(1) == null);
}
test "Manager focus" {
var manager = Manager.init(std.testing.allocator);
defer manager.deinit();
manager.register(1, Info.button("Button 1"));
manager.register(2, Info.button("Button 2"));
manager.setFocus(1);
try std.testing.expectEqual(@as(?u64, 1), manager.focus_id);
if (manager.get(1)) |info| {
try std.testing.expect(info.state.focused);
}
manager.setFocus(2);
try std.testing.expectEqual(@as(?u64, 2), manager.focus_id);
}
test "Manager announcements" {
var manager = Manager.init(std.testing.allocator);
defer manager.deinit();
manager.queueAnnouncement("File saved", .polite);
manager.queueAnnouncement("Error occurred", .assertive);
const announcements = manager.getAnnouncements();
try std.testing.expectEqual(@as(usize, 2), announcements.len);
try std.testing.expectEqualStrings("File saved", announcements[0].text);
try std.testing.expectEqualStrings("Error occurred", announcements[1].text);
// Should be cleared
try std.testing.expectEqual(@as(usize, 0), manager.getAnnouncements().len);
}

497
src/utils/testing.zig Normal file
View file

@ -0,0 +1,497 @@
//! Testing Framework
//!
//! Provides tools for testing zcatgui widgets and applications.
//! Includes simulated input, widget finding, and assertions.
const std = @import("std");
const Context = @import("../core/context.zig").Context;
const Input = @import("../core/input.zig");
const Layout = @import("../core/layout.zig");
const Style = @import("../core/style.zig");
const Framebuffer = @import("../render/framebuffer.zig").Framebuffer;
/// Test runner for simulating user interactions
pub const TestRunner = struct {
/// Simulated input state
input: Input.InputState,
/// Current time in milliseconds
current_time_ms: i64 = 0,
/// Frame count
frame_count: u64 = 0,
/// Last action result
last_result: ?ActionResult = null,
const Self = @This();
/// Initialize a new test runner
pub fn init() Self {
return .{
.input = Input.InputState{},
};
}
/// Reset the test runner state
pub fn reset(self: *Self) void {
self.input = Input.InputState{};
self.current_time_ms = 0;
self.frame_count = 0;
self.last_result = null;
}
/// Advance time by milliseconds
pub fn advanceTime(self: *Self, ms: i64) void {
self.current_time_ms += ms;
}
/// Simulate a frame update
pub fn tick(self: *Self) void {
self.frame_count += 1;
self.advanceTime(16); // ~60fps
self.input.endFrame();
}
/// Wait for specified number of frames
pub fn waitFrames(self: *Self, count: u32) void {
var i: u32 = 0;
while (i < count) : (i += 1) {
self.tick();
}
}
/// Simulate mouse move
pub fn moveMouse(self: *Self, x: i32, y: i32) void {
self.input.mouse_x = x;
self.input.mouse_y = y;
}
/// Simulate mouse click at position
pub fn click(self: *Self, x: i32, y: i32) void {
self.moveMouse(x, y);
self.input.setMouseButton(.left, true);
self.tick();
self.input.setMouseButton(.left, false);
self.tick();
}
/// Simulate mouse press (without release)
pub fn mouseDown(self: *Self, x: i32, y: i32) void {
self.moveMouse(x, y);
self.input.setMouseButton(.left, true);
}
/// Simulate mouse release
pub fn mouseUp(self: *Self) void {
self.input.setMouseButton(.left, false);
}
/// Simulate double click
pub fn doubleClick(self: *Self, x: i32, y: i32) void {
self.click(x, y);
self.advanceTime(100); // Short delay
self.click(x, y);
}
/// Simulate mouse drag
pub fn drag(self: *Self, start_x: i32, start_y: i32, end_x: i32, end_y: i32, steps: u32) void {
self.mouseDown(start_x, start_y);
self.tick();
const actual_steps = if (steps == 0) 10 else steps;
var i: u32 = 1;
while (i <= actual_steps) : (i += 1) {
const t = @as(f32, @floatFromInt(i)) / @as(f32, @floatFromInt(actual_steps));
const x = start_x + @as(i32, @intFromFloat(@as(f32, @floatFromInt(end_x - start_x)) * t));
const y = start_y + @as(i32, @intFromFloat(@as(f32, @floatFromInt(end_y - start_y)) * t));
self.moveMouse(x, y);
self.tick();
}
self.mouseUp();
self.tick();
}
/// Simulate scroll
pub fn scroll(self: *Self, x: i32, y: i32, delta_y: i32) void {
self.moveMouse(x, y);
self.input.scroll_y = delta_y;
self.tick();
self.input.scroll_y = 0;
}
/// Simulate key press
pub fn pressKey(self: *Self, key: Input.Key) void {
self.input.setKeyState(key, true);
self.tick();
self.input.setKeyState(key, false);
self.tick();
}
/// Simulate key down (without release)
pub fn keyDown(self: *Self, key: Input.Key) void {
self.input.setKeyState(key, true);
}
/// Simulate key up
pub fn keyUp(self: *Self, key: Input.Key) void {
self.input.setKeyState(key, false);
}
/// Simulate typing text
pub fn typeText(self: *Self, text: []const u8) void {
for (text) |char| {
if (charToKey(char)) |key| {
// Check if shift is needed
const needs_shift = std.ascii.isUpper(char);
if (needs_shift) {
self.keyDown(.left_shift);
}
self.pressKey(key);
if (needs_shift) {
self.keyUp(.left_shift);
}
}
}
}
/// Simulate keyboard shortcut (e.g., Ctrl+S)
pub fn shortcut(self: *Self, key: Input.Key, ctrl: bool, shift: bool, alt: bool) void {
if (ctrl) self.keyDown(.left_ctrl);
if (shift) self.keyDown(.left_shift);
if (alt) self.keyDown(.left_alt);
self.pressKey(key);
if (alt) self.keyUp(.left_alt);
if (shift) self.keyUp(.left_shift);
if (ctrl) self.keyUp(.left_ctrl);
}
/// Press Enter key
pub fn pressEnter(self: *Self) void {
self.pressKey(.enter);
}
/// Press Tab key
pub fn pressTab(self: *Self) void {
self.pressKey(.tab);
}
/// Press Escape key
pub fn pressEscape(self: *Self) void {
self.pressKey(.escape);
}
/// Get the simulated input state
pub fn getInput(self: *Self) *Input.InputState {
return &self.input;
}
};
/// Result of a test action
pub const ActionResult = struct {
success: bool,
message: []const u8 = "",
widget_id: ?u64 = null,
};
/// Convert ASCII char to key
fn charToKey(char: u8) ?Input.Key {
return switch (char) {
'a', 'A' => .a,
'b', 'B' => .b,
'c', 'C' => .c,
'd', 'D' => .d,
'e', 'E' => .e,
'f', 'F' => .f,
'g', 'G' => .g,
'h', 'H' => .h,
'i', 'I' => .i,
'j', 'J' => .j,
'k', 'K' => .k,
'l', 'L' => .l,
'm', 'M' => .m,
'n', 'N' => .n,
'o', 'O' => .o,
'p', 'P' => .p,
'q', 'Q' => .q,
'r', 'R' => .r,
's', 'S' => .s,
't', 'T' => .t,
'u', 'U' => .u,
'v', 'V' => .v,
'w', 'W' => .w,
'x', 'X' => .x,
'y', 'Y' => .y,
'z', 'Z' => .z,
'0' => .@"0",
'1' => .@"1",
'2' => .@"2",
'3' => .@"3",
'4' => .@"4",
'5' => .@"5",
'6' => .@"6",
'7' => .@"7",
'8' => .@"8",
'9' => .@"9",
' ' => .space,
else => null,
};
}
/// Snapshot testing for visual regression
pub const SnapshotTester = struct {
/// Directory for storing snapshots
snapshot_dir: []const u8,
/// Allocator
allocator: std.mem.Allocator,
/// Tolerance for pixel comparison (0-255)
tolerance: u8 = 0,
const Self = @This();
/// Initialize snapshot tester
pub fn init(allocator: std.mem.Allocator, dir: []const u8) Self {
return .{
.allocator = allocator,
.snapshot_dir = dir,
};
}
/// Capture a snapshot
pub fn capture(self: Self, fb: *const Framebuffer, name: []const u8) !void {
const path = try std.fmt.allocPrint(self.allocator, "{s}/{s}.raw", .{ self.snapshot_dir, name });
defer self.allocator.free(path);
const file = try std.fs.cwd().createFile(path, .{});
defer file.close();
// Write dimensions
var header: [8]u8 = undefined;
std.mem.writeInt(u32, header[0..4], fb.width, .little);
std.mem.writeInt(u32, header[4..8], fb.height, .little);
try file.writeAll(&header);
// Write pixel data
try file.writeAll(fb.pixels);
}
/// Compare framebuffer to stored snapshot
pub fn compare(self: Self, fb: *const Framebuffer, name: []const u8) !CompareResult {
const path = try std.fmt.allocPrint(self.allocator, "{s}/{s}.raw", .{ self.snapshot_dir, name });
defer self.allocator.free(path);
const file = std.fs.cwd().openFile(path, .{}) catch |err| {
if (err == error.FileNotFound) {
return .{ .status = .missing };
}
return err;
};
defer file.close();
// Read header
var header: [8]u8 = undefined;
_ = try file.readAll(&header);
const stored_width = std.mem.readInt(u32, header[0..4], .little);
const stored_height = std.mem.readInt(u32, header[4..8], .little);
// Check dimensions
if (stored_width != fb.width or stored_height != fb.height) {
return .{
.status = .size_mismatch,
.expected_width = stored_width,
.expected_height = stored_height,
};
}
// Read and compare pixels
const stored_pixels = try self.allocator.alloc(u8, fb.pixels.len);
defer self.allocator.free(stored_pixels);
_ = try file.readAll(stored_pixels);
var diff_count: u64 = 0;
var max_diff: u8 = 0;
var first_diff_x: ?u32 = null;
var first_diff_y: ?u32 = null;
var i: usize = 0;
while (i < fb.pixels.len) : (i += 1) {
const diff = if (fb.pixels[i] > stored_pixels[i])
fb.pixels[i] - stored_pixels[i]
else
stored_pixels[i] - fb.pixels[i];
if (diff > self.tolerance) {
diff_count += 1;
if (diff > max_diff) max_diff = diff;
if (first_diff_x == null) {
const pixel_idx = i / 4;
first_diff_x = @intCast(pixel_idx % fb.width);
first_diff_y = @intCast(pixel_idx / fb.width);
}
}
}
if (diff_count == 0) {
return .{ .status = .match };
}
return .{
.status = .mismatch,
.diff_pixels = diff_count / 4,
.max_diff = max_diff,
.first_diff_x = first_diff_x,
.first_diff_y = first_diff_y,
};
}
/// Update snapshot (overwrite with current)
pub fn update(self: Self, fb: *const Framebuffer, name: []const u8) !void {
try self.capture(fb, name);
}
};
/// Result of snapshot comparison
pub const CompareResult = struct {
status: Status,
diff_pixels: u64 = 0,
max_diff: u8 = 0,
first_diff_x: ?u32 = null,
first_diff_y: ?u32 = null,
expected_width: u32 = 0,
expected_height: u32 = 0,
pub const Status = enum {
match,
mismatch,
missing,
size_mismatch,
};
pub fn isMatch(self: CompareResult) bool {
return self.status == .match;
}
};
/// Test assertions for widgets
pub const Assertions = struct {
/// Assert a rect is visible (non-empty)
pub fn assertVisible(rect: Layout.Rect) !void {
if (rect.isEmpty()) {
return error.AssertionFailed;
}
}
/// Assert rect contains point
pub fn assertContains(rect: Layout.Rect, x: i32, y: i32) !void {
if (!rect.contains(x, y)) {
return error.AssertionFailed;
}
}
/// Assert rects intersect
pub fn assertIntersects(a: Layout.Rect, b: Layout.Rect) !void {
if (!a.intersects(b)) {
return error.AssertionFailed;
}
}
/// Assert colors are equal
pub fn assertColorEqual(a: Style.Color, b: Style.Color) !void {
if (a.r != b.r or a.g != b.g or a.b != b.b or a.a != b.a) {
return error.AssertionFailed;
}
}
/// Assert colors are approximately equal (within tolerance)
pub fn assertColorNear(a: Style.Color, b: Style.Color, tolerance: u8) !void {
const dr = if (a.r > b.r) a.r - b.r else b.r - a.r;
const dg = if (a.g > b.g) a.g - b.g else b.g - a.g;
const db = if (a.b > b.b) a.b - b.b else b.b - a.b;
const da = if (a.a > b.a) a.a - b.a else b.a - a.a;
if (dr > tolerance or dg > tolerance or db > tolerance or da > tolerance) {
return error.AssertionFailed;
}
}
/// Assert value is within range
pub fn assertInRange(value: anytype, min: @TypeOf(value), max: @TypeOf(value)) !void {
if (value < min or value > max) {
return error.AssertionFailed;
}
}
};
// =============================================================================
// Tests
// =============================================================================
test "TestRunner init" {
const runner = TestRunner.init();
try std.testing.expectEqual(@as(i64, 0), runner.current_time_ms);
try std.testing.expectEqual(@as(u64, 0), runner.frame_count);
}
test "TestRunner tick" {
var runner = TestRunner.init();
runner.tick();
try std.testing.expectEqual(@as(u64, 1), runner.frame_count);
try std.testing.expectEqual(@as(i64, 16), runner.current_time_ms);
}
test "TestRunner mouse" {
var runner = TestRunner.init();
runner.moveMouse(100, 200);
try std.testing.expectEqual(@as(i32, 100), runner.input.mouse_x);
try std.testing.expectEqual(@as(i32, 200), runner.input.mouse_y);
runner.click(150, 250);
try std.testing.expectEqual(@as(i32, 150), runner.input.mouse_x);
try std.testing.expectEqual(@as(i32, 250), runner.input.mouse_y);
}
test "TestRunner keyboard" {
var runner = TestRunner.init();
runner.pressKey(.a);
try std.testing.expectEqual(@as(u64, 2), runner.frame_count); // Press + release
}
test "TestRunner shortcut" {
var runner = TestRunner.init();
runner.shortcut(.s, true, false, false); // Ctrl+S
// Should have pressed and released the keys
try std.testing.expect(runner.frame_count >= 2);
}
test "charToKey" {
try std.testing.expectEqual(Input.Key.a, charToKey('a').?);
try std.testing.expectEqual(Input.Key.a, charToKey('A').?);
try std.testing.expectEqual(Input.Key.space, charToKey(' ').?);
try std.testing.expect(charToKey('!') == null);
}
test "Assertions visible" {
const visible = Layout.Rect{ .x = 0, .y = 0, .w = 100, .h = 100 };
const empty = Layout.Rect{ .x = 0, .y = 0, .w = 0, .h = 0 };
try Assertions.assertVisible(visible);
try std.testing.expectError(error.AssertionFailed, Assertions.assertVisible(empty));
}
test "Assertions contains" {
const rect = Layout.Rect{ .x = 0, .y = 0, .w = 100, .h = 100 };
try Assertions.assertContains(rect, 50, 50);
try std.testing.expectError(error.AssertionFailed, Assertions.assertContains(rect, 150, 50));
}
test "Assertions color" {
const red = Style.Color.rgba(255, 0, 0, 255);
const also_red = Style.Color.rgba(255, 0, 0, 255);
const almost_red = Style.Color.rgba(250, 5, 0, 255);
try Assertions.assertColorEqual(red, also_red);
try Assertions.assertColorNear(red, almost_red, 10);
try std.testing.expectError(error.AssertionFailed, Assertions.assertColorEqual(red, almost_red));
}

View file

@ -12,6 +12,7 @@
pub const arena = @import("arena.zig");
pub const pool = @import("pool.zig");
pub const benchmark = @import("benchmark.zig");
pub const testing_utils = @import("testing.zig");
// Re-exports
pub const FrameArena = arena.FrameArena;
@ -26,6 +27,12 @@ pub const Timer = benchmark.Timer;
pub const FrameTimer = benchmark.FrameTimer;
pub const AllocationTracker = benchmark.AllocationTracker;
// Testing utilities
pub const TestRunner = testing_utils.TestRunner;
pub const SnapshotTester = testing_utils.SnapshotTester;
pub const CompareResult = testing_utils.CompareResult;
pub const Assertions = testing_utils.Assertions;
// =============================================================================
// Tests
// =============================================================================
@ -34,4 +41,5 @@ test {
_ = arena;
_ = pool;
_ = benchmark;
_ = testing_utils;
}

View file

@ -50,6 +50,11 @@ pub const Shortcut = shortcuts.Shortcut;
pub const focus_group = @import("core/focus_group.zig");
pub const FocusGroup = focus_group.FocusGroup;
pub const FocusGroupManager = focus_group.FocusGroupManager;
pub const accessibility = @import("core/accessibility.zig");
pub const A11yRole = accessibility.Role;
pub const A11yState = accessibility.State;
pub const A11yInfo = accessibility.Info;
pub const A11yManager = accessibility.Manager;
// =============================================================================
// Macro system