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:
parent
70fca5177b
commit
1a26d34aa3
4 changed files with 1064 additions and 0 deletions
554
src/core/accessibility.zig
Normal file
554
src/core/accessibility.zig
Normal 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
497
src/utils/testing.zig
Normal 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));
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue