lib/windowing/src/properties/input.zig

daab053ee43316e1809a84551d573ddd1e5bf3d2

  1 const std = @import("std");
  2 const hypothesis = @import("hypothesis");
  3 const windowing = @import("windowing");
  4 
  5 const State = windowing.input.State;
  6 const Point = windowing.input.Point;
  7 const Wheel = windowing.input.Wheel;
  8 const Key = windowing.Key;
  9 const MouseButton = windowing.MouseButton;
 10 
 11 const input_limits = windowing.input.Limits{
 12     .retained_pressed_key_count = 8,
 13     .retained_text_codepoint_count = 8,
 14 };
 15 const pressed_key_capacity = input_limits.retained_pressed_key_count;
 16 const text_codepoint_capacity = input_limits.retained_text_codepoint_count;
 17 const max_text_input_bytes = @typeInfo(@TypeOf((windowing.TextInputEvent{}).bytes)).array.len;
 18 
 19 const valid_keys = [_]Key{
 20     .a,
 21     .b,
 22     .enter,
 23     .tab,
 24     .left_shift,
 25     .right_shift,
 26     .left_ctrl,
 27     .right_ctrl,
 28     .left_alt,
 29     .right_alt,
 30     .left_super,
 31     .right_super,
 32 };
 33 const invalid_keys = [_]Key{
 34     .unknown,
 35     @fromBackingInt(@intCast(1)),
 36     @fromBackingInt(@intCast(31)),
 37     @fromBackingInt(@intCast(100)),
 38     @fromBackingInt(@intCast(349)),
 39     @fromBackingInt(@intCast(4096)),
 40     @fromBackingInt(@intCast(std.math.maxInt(u16))),
 41 };
 42 const mouse_buttons = [_]MouseButton{
 43     .left,
 44     .right,
 45     .middle,
 46     .x1,
 47     .x2,
 48 };
 49 const coordinates = [_]i32{
 50     std.math.minInt(i32),
 51     std.math.minInt(i32) + 1,
 52     -1024,
 53     -1,
 54     0,
 55     1,
 56     1024,
 57     std.math.maxInt(i32) - 1,
 58     std.math.maxInt(i32),
 59 };
 60 const wheel_values = [_]f32{
 61     -120.0,
 62     -1.5,
 63     -0.25,
 64     0.0,
 65     0.25,
 66     1.5,
 67     120.0,
 68 };
 69 const text_inputs = [_][]const u8{
 70     "",
 71     "\n",
 72     "\x7f",
 73     "\xff",
 74     "a",
 75     "z",
 76     "A",
 77     " ",
 78     "lambda",
 79     "a\xce\xbb",
 80     "1234567890123456",
 81     "12345678901234567",
 82 };
 83 
 84 pub fn settings() hypothesis.Settings {
 85     return hypothesis.Settings.quick()
 86         .withSeed(0x5447_4657_494e_5055)
 87         .withDatabase("zig-out/hypothesis-failures/windowing");
 88 }
 89 
 90 fn drawUsize(
 91     data: *hypothesis.ConjectureData,
 92     min: usize,
 93     max: usize,
 94     shrink_towards: usize,
 95 ) !usize {
 96     return @intCast(try data.drawInteger(
 97         @intCast(min),
 98         @intCast(max),
 99         @intCast(shrink_towards),
100     ));
101 }
102 
103 fn drawCoordinate(data: *hypothesis.ConjectureData) !i32 {
104     if (try data.drawBoolean()) {
105         return coordinates[try drawUsize(data, 0, coordinates.len - 1, 4)];
106     }
107     const raw = try drawUsize(data, 0, 2048, 1024);
108     return @as(i32, @intCast(raw)) - 1024;
109 }
110 
111 fn drawKey(data: *hypothesis.ConjectureData) !Key {
112     if (try data.drawBoolean()) {
113         return valid_keys[try drawUsize(data, 0, valid_keys.len - 1, 0)];
114     }
115     return invalid_keys[try drawUsize(data, 0, invalid_keys.len - 1, 0)];
116 }
117 
118 fn drawMouseButton(data: *hypothesis.ConjectureData) !MouseButton {
119     return mouse_buttons[try drawUsize(data, 0, mouse_buttons.len - 1, 0)];
120 }
121 
122 fn drawWheel(data: *hypothesis.ConjectureData) !f32 {
123     return wheel_values[try drawUsize(data, 0, wheel_values.len - 1, 3)];
124 }
125 
126 const TextAppend = struct {
127     accepted: bool,
128     codepoints: [max_text_input_bytes]u21 = undefined,
129     count: usize = 0,
130 };
131 
132 const Model = struct {
133     keys: [valid_keys.len]bool = @as([valid_keys.len]bool, @splat(false)),
134     pressed: [valid_keys.len]bool = @as([valid_keys.len]bool, @splat(false)),
135     released: [valid_keys.len]bool = @as([valid_keys.len]bool, @splat(false)),
136     pressed_keys: [pressed_key_capacity]Key = undefined,
137     pressed_key_read_index: usize = 0,
138     pressed_key_count: usize = 0,
139     dropped_pressed_key_count: u64 = 0,
140     mouse_buttons: [mouse_buttons.len]bool = @as([mouse_buttons.len]bool, @splat(false)),
141     mouse_pressed: [mouse_buttons.len]bool = @as([mouse_buttons.len]bool, @splat(false)),
142     mouse_released: [mouse_buttons.len]bool = @as([mouse_buttons.len]bool, @splat(false)),
143     mouse_position: Point = .{},
144     mouse_delta: Point = .{},
145     mouse_wheel: Wheel = .{},
146     text_codepoints: [text_codepoint_capacity]u21 = undefined,
147     text_read_index: usize = 0,
148     text_count: usize = 0,
149     dropped_text_codepoint_count: u64 = 0,
150 
151     fn beginPoll(self: *Model) void {
152         @memset(&self.pressed, false);
153         @memset(&self.released, false);
154         self.pressed_key_read_index = 0;
155         self.pressed_key_count = 0;
156         @memset(&self.mouse_pressed, false);
157         @memset(&self.mouse_released, false);
158         self.mouse_delta = .{};
159         self.mouse_wheel = .{};
160         self.text_read_index = 0;
161         self.text_count = 0;
162     }
163 
164     fn pressKey(self: *Model, key: Key) void {
165         const index = validKeyIndex(key) orelse return;
166         self.keys[index] = true;
167         self.pressed[index] = true;
168         if (self.pressed_key_count < self.pressed_keys.len) {
169             self.pressed_keys[self.pressed_key_count] = key;
170             self.pressed_key_count += 1;
171         } else {
172             self.dropped_pressed_key_count +|= 1;
173         }
174     }
175 
176     fn releaseKey(self: *Model, key: Key) void {
177         const index = validKeyIndex(key) orelse return;
178         self.keys[index] = false;
179         self.released[index] = true;
180     }
181 
182     fn nextPressedKey(self: *Model) ?Key {
183         if (self.pressed_key_read_index >= self.pressed_key_count) return null;
184         const key = self.pressed_keys[self.pressed_key_read_index];
185         self.pressed_key_read_index += 1;
186         return key;
187     }
188 
189     fn pressMouse(self: *Model, button: MouseButton) void {
190         const index = mouseIndex(button);
191         self.mouse_buttons[index] = true;
192         self.mouse_pressed[index] = true;
193     }
194 
195     fn releaseMouse(self: *Model, button: MouseButton) void {
196         const index = mouseIndex(button);
197         self.mouse_buttons[index] = false;
198         self.mouse_released[index] = true;
199     }
200 
201     fn setMousePosition(self: *Model, x: i32, y: i32) void {
202         self.mouse_position = .{ .x = x, .y = y };
203     }
204 
205     fn moveMouse(self: *Model, x: i32, y: i32) void {
206         const previous = self.mouse_position;
207         self.mouse_position = .{ .x = x, .y = y };
208         self.mouse_delta.x = saturatingI32Add(
209             self.mouse_delta.x,
210             saturatingI32Diff(x, previous.x),
211         );
212         self.mouse_delta.y = saturatingI32Add(
213             self.mouse_delta.y,
214             saturatingI32Diff(y, previous.y),
215         );
216     }
217 
218     fn addMouseWheel(self: *Model, x: f32, y: f32) void {
219         self.mouse_wheel.x += x;
220         self.mouse_wheel.y += y;
221     }
222 
223     fn pushTextInput(self: *Model, bytes: []const u8) bool {
224         const validated = validateText(bytes);
225         if (!validated.accepted) return false;
226 
227         var index: usize = 0;
228         while (index < validated.count) : (index += 1) {
229             if (self.text_count >= self.text_codepoints.len) {
230                 self.dropped_text_codepoint_count +|= 1;
231                 continue;
232             }
233             self.text_codepoints[self.text_count] = validated.codepoints[index];
234             self.text_count += 1;
235         }
236         return true;
237     }
238 
239     fn nextTextCodepoint(self: *Model) ?u21 {
240         if (self.text_read_index >= self.text_count) return null;
241         const codepoint = self.text_codepoints[self.text_read_index];
242         self.text_read_index += 1;
243         return codepoint;
244     }
245 };
246 
247 fn expectState(state: *State, model: *const Model) !void {
248     for (valid_keys, 0..) |key, index| {
249         try std.testing.expectEqual(model.keys[index], state.isKeyDown(key));
250         try std.testing.expectEqual(model.pressed[index], state.isKeyPressed(key));
251         try std.testing.expectEqual(model.released[index], state.isKeyReleased(key));
252     }
253 
254     for (invalid_keys) |key| {
255         try std.testing.expect(!state.isKeyDown(key));
256         try std.testing.expect(!state.isKeyPressed(key));
257         try std.testing.expect(!state.isKeyReleased(key));
258     }
259 
260     const modifiers = state.modifiers();
261     try std.testing.expectEqual(
262         model.keys[validKeyIndex(.left_shift).?] or model.keys[validKeyIndex(.right_shift).?],
263         modifiers.shift,
264     );
265     try std.testing.expectEqual(
266         model.keys[validKeyIndex(.left_ctrl).?] or model.keys[validKeyIndex(.right_ctrl).?],
267         modifiers.ctrl,
268     );
269     try std.testing.expectEqual(
270         model.keys[validKeyIndex(.left_alt).?] or model.keys[validKeyIndex(.right_alt).?],
271         modifiers.alt,
272     );
273     try std.testing.expectEqual(
274         model.keys[validKeyIndex(.left_super).?] or model.keys[validKeyIndex(.right_super).?],
275         modifiers.super,
276     );
277 
278     for (mouse_buttons, 0..) |button, index| {
279         try std.testing.expectEqual(model.mouse_buttons[index], state.isMouseButtonDown(button));
280         try std.testing.expectEqual(model.mouse_pressed[index], state.isMouseButtonPressed(button));
281         try std.testing.expectEqual(model.mouse_released[index], state.isMouseButtonReleased(button));
282     }
283 
284     try std.testing.expectEqual(model.mouse_position, state.mouse_position);
285     try std.testing.expectEqual(model.mouse_delta, state.mouse_delta);
286     try std.testing.expectEqual(model.mouse_wheel, state.mouse_wheel);
287     try std.testing.expectEqual(windowing.input.Status{
288         .dropped_pressed_key_count = model.dropped_pressed_key_count,
289         .dropped_text_codepoint_count = model.dropped_text_codepoint_count,
290     }, state.status());
291 }
292 
293 fn expectDrainedQueues(state: *State, model: *Model) !void {
294     while (true) {
295         const expected = model.nextPressedKey();
296         const actual = state.nextPressedKey();
297         if (expected) |key| {
298             try std.testing.expect(actual != null);
299             try std.testing.expectEqual(@backingInt(key), @backingInt(actual.?));
300         } else {
301             try std.testing.expect(actual == null);
302             break;
303         }
304     }
305 
306     while (true) {
307         const expected = model.nextTextCodepoint();
308         const actual = state.nextTextCodepoint();
309         if (expected) |codepoint| {
310             try std.testing.expect(actual != null);
311             try std.testing.expectEqual(codepoint, actual.?);
312         } else {
313             try std.testing.expect(actual == null);
314             break;
315         }
316     }
317 }
318 
319 fn validKeyIndex(key: Key) ?usize {
320     for (valid_keys, 0..) |candidate, index| {
321         if (candidate == key) return index;
322     }
323     return null;
324 }
325 
326 fn mouseIndex(button: MouseButton) usize {
327     return @backingInt(button);
328 }
329 
330 fn validateText(bytes: []const u8) TextAppend {
331     if (bytes.len == 0 or bytes.len > max_text_input_bytes) return .{ .accepted = false };
332     if (!std.unicode.utf8ValidateSlice(bytes)) return .{ .accepted = false };
333 
334     var out = TextAppend{ .accepted = true };
335     var iterator = std.unicode.Utf8Iterator{ .bytes = bytes, .i = 0 };
336     while (iterator.nextCodepoint()) |codepoint| {
337         if (codepoint < 0x20 or codepoint == 0x7f) return .{ .accepted = false };
338         out.codepoints[out.count] = codepoint;
339         out.count += 1;
340     }
341     return out;
342 }
343 
344 fn saturatingI32FromI64(value: i64) i32 {
345     if (value < std.math.minInt(i32)) return std.math.minInt(i32);
346     if (value > std.math.maxInt(i32)) return std.math.maxInt(i32);
347     return @intCast(value);
348 }
349 
350 fn saturatingI32Add(left: i32, right: i32) i32 {
351     return std.math.add(i32, left, right) catch if (right < 0)
352         std.math.minInt(i32)
353     else
354         std.math.maxInt(i32);
355 }
356 
357 fn saturatingI32Diff(next: i32, previous: i32) i32 {
358     return saturatingI32FromI64(@as(i64, next) - @as(i64, previous));
359 }
360 
361 pub const TraceProperty = struct {
362     pub fn property(conjecture: *hypothesis.ConjectureData, allocator: std.mem.Allocator) !void {
363         var state = try State.init(allocator, try windowing.input.Capacity.derive(input_limits));
364         defer state.deinit();
365         var expected = Model{};
366 
367         const step_count = try drawUsize(conjecture, 1, 128, 16);
368         var step_index: usize = 0;
369         while (step_index < step_count) : (step_index += 1) {
370             switch (try drawUsize(conjecture, 0, 9, 0)) {
371                 0 => {
372                     state.beginPoll();
373                     expected.beginPoll();
374                 },
375                 1 => {
376                     const key = try drawKey(conjecture);
377                     state.pressKey(key);
378                     expected.pressKey(key);
379                 },
380                 2 => {
381                     const key = try drawKey(conjecture);
382                     state.releaseKey(key);
383                     expected.releaseKey(key);
384                 },
385                 3 => {
386                     const expected_key = expected.nextPressedKey();
387                     const actual = state.nextPressedKey();
388                     if (expected_key) |key| {
389                         try std.testing.expect(actual != null);
390                         try std.testing.expectEqual(@backingInt(key), @backingInt(actual.?));
391                     } else {
392                         try std.testing.expect(actual == null);
393                     }
394                 },
395                 4 => {
396                     const button = try drawMouseButton(conjecture);
397                     state.pressMouse(button);
398                     expected.pressMouse(button);
399                 },
400                 5 => {
401                     const button = try drawMouseButton(conjecture);
402                     state.releaseMouse(button);
403                     expected.releaseMouse(button);
404                 },
405                 6 => {
406                     const x = try drawCoordinate(conjecture);
407                     const y = try drawCoordinate(conjecture);
408                     state.setMousePosition(x, y);
409                     expected.setMousePosition(x, y);
410                 },
411                 7 => {
412                     const x = try drawCoordinate(conjecture);
413                     const y = try drawCoordinate(conjecture);
414                     state.moveMouse(x, y);
415                     expected.moveMouse(x, y);
416                 },
417                 8 => {
418                     const x = try drawWheel(conjecture);
419                     const y = try drawWheel(conjecture);
420                     state.addMouseWheel(x, y);
421                     expected.addMouseWheel(x, y);
422                 },
423                 else => {
424                     const bytes = text_inputs[try drawUsize(conjecture, 0, text_inputs.len - 1, 4)];
425                     try std.testing.expectEqual(expected.pushTextInput(bytes), state.pushTextInput(bytes));
426                 },
427             }
428 
429             try expectState(&state, &expected);
430         }
431 
432         try expectDrainedQueues(&state, &expected);
433         try expectState(&state, &expected);
434     }
435 };
436 
437 test "property: input state matches generated trace model" {
438     try hypothesis.checkNamed(TraceProperty, "windowing-input-trace", settings());
439 }