From 1a26d34aa32768aafe9e9c9b19f8952801f080cf Mon Sep 17 00:00:00 2001 From: reugenio Date: Tue, 9 Dec 2025 13:54:07 +0100 Subject: [PATCH] feat: zcatgui v0.14.0 - Phase 8 Accessibility & Testing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/core/accessibility.zig | 554 +++++++++++++++++++++++++++++++++++++ src/utils/testing.zig | 497 +++++++++++++++++++++++++++++++++ src/utils/utils.zig | 8 + src/zcatgui.zig | 5 + 4 files changed, 1064 insertions(+) create mode 100644 src/core/accessibility.zig create mode 100644 src/utils/testing.zig diff --git a/src/core/accessibility.zig b/src/core/accessibility.zig new file mode 100644 index 0000000..8ae101c --- /dev/null +++ b/src/core/accessibility.zig @@ -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); +} diff --git a/src/utils/testing.zig b/src/utils/testing.zig new file mode 100644 index 0000000..1b825d4 --- /dev/null +++ b/src/utils/testing.zig @@ -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)); +} diff --git a/src/utils/utils.zig b/src/utils/utils.zig index 9339afd..14384d6 100644 --- a/src/utils/utils.zig +++ b/src/utils/utils.zig @@ -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; } diff --git a/src/zcatgui.zig b/src/zcatgui.zig index bdf338c..73d6259 100644 --- a/src/zcatgui.zig +++ b/src/zcatgui.zig @@ -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