569 lines
19 KiB
Python
569 lines
19 KiB
Python
"""
|
|
.. warning::
|
|
|
|
The classes in this file are internal and may well be removed to an
|
|
external kivy-pytest package or similar in the future. Use at your own
|
|
risk.
|
|
"""
|
|
import random
|
|
import time
|
|
import math
|
|
import os
|
|
from collections import deque
|
|
|
|
from kivy.tests import UnitTestTouch
|
|
|
|
__all__ = ('UnitKivyApp', )
|
|
|
|
|
|
class AsyncUnitTestTouch(UnitTestTouch):
|
|
|
|
def __init__(self, *largs, **kwargs):
|
|
self.grab_exclusive_class = None
|
|
self.is_touch = True
|
|
super(AsyncUnitTestTouch, self).__init__(*largs, **kwargs)
|
|
|
|
def touch_down(self, *args):
|
|
self.eventloop._dispatch_input("begin", self)
|
|
|
|
def touch_move(self, x, y):
|
|
win = self.eventloop.window
|
|
self.move({
|
|
"x": x / (win.width - 1.0),
|
|
"y": y / (win.height - 1.0)
|
|
})
|
|
self.eventloop._dispatch_input("update", self)
|
|
|
|
def touch_up(self, *args):
|
|
self.eventloop._dispatch_input("end", self)
|
|
|
|
|
|
_unique_value = object
|
|
|
|
|
|
class WidgetResolver(object):
|
|
"""It assumes that the widget tree strictly forms a DAG.
|
|
"""
|
|
|
|
base_widget = None
|
|
|
|
matched_widget = None
|
|
|
|
_kwargs_filter = {}
|
|
|
|
_funcs_filter = []
|
|
|
|
def __init__(self, base_widget, **kwargs):
|
|
self.base_widget = base_widget
|
|
self._kwargs_filter = {}
|
|
self._funcs_filter = []
|
|
super(WidgetResolver, self).__init__(**kwargs)
|
|
|
|
def __call__(self):
|
|
if self.matched_widget is not None:
|
|
return self.matched_widget
|
|
|
|
if not self._kwargs_filter and not self._funcs_filter:
|
|
return self.base_widget
|
|
return None
|
|
|
|
def match(self, **kwargs_filter):
|
|
self._kwargs_filter.update(kwargs_filter)
|
|
|
|
def match_funcs(self, funcs_filter=()):
|
|
self._funcs_filter.extend(funcs_filter)
|
|
|
|
def check_widget(self, widget):
|
|
if not all(func(widget) for func in self._funcs_filter):
|
|
return False
|
|
|
|
for attr, val in self._kwargs_filter.items():
|
|
if getattr(widget, attr, _unique_value) != val:
|
|
return False
|
|
|
|
return True
|
|
|
|
def not_found(self, op):
|
|
raise ValueError(
|
|
'Cannot find widget matching <{}, {}> starting from base '
|
|
'widget "{}" doing "{}" traversal'.format(
|
|
self._kwargs_filter, self._funcs_filter, self.base_widget, op))
|
|
|
|
def down(self, **kwargs_filter):
|
|
self.match(**kwargs_filter)
|
|
check = self.check_widget
|
|
|
|
fifo = deque([self.base_widget])
|
|
while fifo:
|
|
widget = fifo.popleft()
|
|
if check(widget):
|
|
return WidgetResolver(base_widget=widget)
|
|
|
|
fifo.extend(widget.children)
|
|
|
|
self.not_found('down')
|
|
|
|
def up(self, **kwargs_filter):
|
|
self.match(**kwargs_filter)
|
|
check = self.check_widget
|
|
|
|
parent = self.base_widget
|
|
while parent is not None:
|
|
if check(parent):
|
|
return WidgetResolver(base_widget=parent)
|
|
|
|
new_parent = parent.parent
|
|
# Window is its own parent oO
|
|
if new_parent is parent:
|
|
break
|
|
parent = new_parent
|
|
|
|
self.not_found('up')
|
|
|
|
def family_up(self, **kwargs_filter):
|
|
self.match(**kwargs_filter)
|
|
check = self.check_widget
|
|
|
|
base_widget = self.base_widget
|
|
already_checked_base = None
|
|
while base_widget is not None:
|
|
fifo = deque([base_widget])
|
|
while fifo:
|
|
widget = fifo.popleft()
|
|
# don't check the child we checked before moving up
|
|
if widget is already_checked_base:
|
|
continue
|
|
|
|
if check(widget):
|
|
return WidgetResolver(base_widget=widget)
|
|
|
|
fifo.extend(widget.children)
|
|
|
|
already_checked_base = base_widget
|
|
new_base_widget = base_widget.parent
|
|
# Window is its own parent oO
|
|
if new_base_widget is base_widget:
|
|
break
|
|
base_widget = new_base_widget
|
|
|
|
self.not_found('family_up')
|
|
|
|
|
|
class UnitKivyApp(object):
|
|
"""Base class to use with async test apps.
|
|
|
|
.. warning::
|
|
|
|
The classes in this file are internal and may well be removed to an
|
|
external kivy-pytest package or similar in the future. Use at your own
|
|
risk.
|
|
"""
|
|
|
|
app_has_started = False
|
|
|
|
app_has_stopped = False
|
|
|
|
async_sleep = None
|
|
|
|
def __init__(self, **kwargs):
|
|
super().__init__(**kwargs)
|
|
|
|
def started_app(*largs):
|
|
self.app_has_started = True
|
|
self.fbind('on_start', started_app)
|
|
|
|
def stopped_app(*largs):
|
|
self.app_has_stopped = True
|
|
self.fbind('on_stop', stopped_app)
|
|
|
|
def set_async_lib(self, async_lib):
|
|
from kivy.clock import Clock
|
|
if async_lib is not None:
|
|
Clock.init_async_lib(async_lib)
|
|
self.async_sleep = Clock._async_lib.sleep
|
|
|
|
async def async_run(self, async_lib=None):
|
|
self.set_async_lib(async_lib)
|
|
return await super(UnitKivyApp, self).async_run(async_lib=async_lib)
|
|
|
|
def resolve_widget(self, base_widget=None):
|
|
if base_widget is None:
|
|
from kivy.core.window import Window
|
|
base_widget = Window
|
|
return WidgetResolver(base_widget=base_widget)
|
|
|
|
async def wait_clock_frames(self, n, sleep_time=1 / 60.):
|
|
from kivy.clock import Clock
|
|
frames_start = Clock.frames
|
|
while Clock.frames < frames_start + n:
|
|
await self.async_sleep(sleep_time)
|
|
|
|
def get_widget_pos_pixel(self, widget, positions):
|
|
from kivy.graphics import Fbo, ClearColor, ClearBuffers
|
|
|
|
canvas_parent_index = -2
|
|
if widget.parent is not None:
|
|
canvas_parent_index = widget.parent.canvas.indexof(widget.canvas)
|
|
if canvas_parent_index > -1:
|
|
widget.parent.canvas.remove(widget.canvas)
|
|
|
|
w, h = int(widget.width), int(widget.height)
|
|
fbo = Fbo(size=(w, h), with_stencilbuffer=True)
|
|
|
|
with fbo:
|
|
ClearColor(0, 0, 0, 0)
|
|
ClearBuffers()
|
|
|
|
fbo.add(widget.canvas)
|
|
fbo.draw()
|
|
pixels = fbo.pixels
|
|
fbo.remove(widget.canvas)
|
|
|
|
if widget.parent is not None and canvas_parent_index > -1:
|
|
widget.parent.canvas.insert(canvas_parent_index, widget.canvas)
|
|
|
|
values = []
|
|
for x, y in positions:
|
|
x = int(x)
|
|
y = int(y)
|
|
i = y * w * 4 + x * 4
|
|
values.append(tuple(pixels[i:i + 4]))
|
|
|
|
return values
|
|
|
|
async def do_touch_down_up(
|
|
self, pos=None, widget=None, duration=.2, pos_jitter=None,
|
|
widget_jitter=False, jitter_dt=1 / 15., end_on_pos=False):
|
|
if widget is None:
|
|
x, y = pos
|
|
else:
|
|
if pos is None:
|
|
x, y = widget.to_window(*widget.center)
|
|
else:
|
|
x, y = widget.to_window(*pos, initial=False)
|
|
touch = AsyncUnitTestTouch(x, y)
|
|
|
|
ts = time.perf_counter()
|
|
touch.touch_down()
|
|
await self.wait_clock_frames(1)
|
|
yield 'down', touch.pos
|
|
|
|
if not pos_jitter and not widget_jitter:
|
|
await self.async_sleep(duration)
|
|
touch.touch_up()
|
|
await self.wait_clock_frames(1)
|
|
yield 'up', touch.pos
|
|
|
|
return
|
|
|
|
moved = False
|
|
if pos_jitter:
|
|
dx, dy = pos_jitter
|
|
else:
|
|
dx = widget.width / 2.
|
|
dy = widget.height / 2.
|
|
|
|
while time.perf_counter() - ts < duration:
|
|
moved = True
|
|
await self.async_sleep(jitter_dt)
|
|
|
|
touch.touch_move(
|
|
x + (random.random() * 2 - 1) * dx,
|
|
y + (random.random() * 2 - 1) * dy
|
|
)
|
|
await self.wait_clock_frames(1)
|
|
yield 'move', touch.pos
|
|
|
|
if end_on_pos and moved:
|
|
touch.touch_move(x, y)
|
|
await self.wait_clock_frames(1)
|
|
yield 'move', touch.pos
|
|
|
|
touch.touch_up()
|
|
await self.wait_clock_frames(1)
|
|
yield 'up', touch.pos
|
|
|
|
async def do_touch_drag(
|
|
self, pos=None, widget=None,
|
|
widget_loc=('center_x', 'center_y'), dx=0, dy=0,
|
|
target_pos=None, target_widget=None, target_widget_offset=(0, 0),
|
|
target_widget_loc=('center_x', 'center_y'), long_press=0,
|
|
duration=.2, drag_n=5):
|
|
"""Initiates a touch down, followed by some dragging to a target
|
|
location, ending with a touch up.
|
|
|
|
`origin`: These parameters specify where the drag starts.
|
|
- If ``widget`` is None, it starts at ``pos`` (in window coordinates).
|
|
If ``dx``/``dy`` is used, it is in the window coordinate system also.
|
|
- If ``pos`` is None, it starts on the ``widget`` as specified by
|
|
``widget_loc``. If ``dx``/``dy`` is used, it is in the ``widget``
|
|
coordinate system.
|
|
- If neither is None, it starts at ``pos``, but in the ``widget``'s
|
|
coordinate system (:meth:`~kivy.uix.widget.Widget.to_window` is used
|
|
on it). If ``dx``/``dy`` is used, it is in the ``widget``
|
|
coordinate system.
|
|
|
|
`target`: These parameters specify where the drag ends.
|
|
- If ``target_pos`` and ``target_widget`` is None, then ``dx`` and
|
|
``dy`` is used relative to the position where the drag started.
|
|
- If ``target_widget`` is None, it ends at ``target_pos``
|
|
(in window coordinates).
|
|
- If ``target_pos`` is None, it ends on the ``target_widget`` as
|
|
specified by ``target_widget_loc``. ``target_widget_offset``, is an
|
|
additional ``(x, y)`` offset relative to ``target_widget_loc``.
|
|
- If neither is None, it starts at ``target_pos``, but in the
|
|
``target_widget``'s coordinate system
|
|
(:meth:`~kivy.uix.widget.Widget.to_window` is used on it).
|
|
|
|
When ``widget`` and/or ``target_widget`` are specified, ``widget_loc``
|
|
and ``target_widget_loc``, respectively, indicate where on the widget
|
|
the drag starts/ends. It is a a tuple with property names of the widget
|
|
to loop up to get the value. The default is
|
|
``('center_x', 'center_y')`` so the drag would start/end in the
|
|
widget's center.
|
|
"""
|
|
if widget is None:
|
|
x, y = pos
|
|
tx, ty = x + dx, y + dy
|
|
else:
|
|
if pos is None:
|
|
w_x = getattr(widget, widget_loc[0])
|
|
w_y = getattr(widget, widget_loc[1])
|
|
x, y = widget.to_window(w_x, w_y)
|
|
tx, ty = widget.to_window(w_x + dx, w_y + dy)
|
|
else:
|
|
x, y = widget.to_window(*pos, initial=False)
|
|
tx, ty = widget.to_window(
|
|
pos[0] + dx, pos[1] + dy, initial=False)
|
|
|
|
if target_pos is not None:
|
|
if target_widget is None:
|
|
tx, ty = target_pos
|
|
else:
|
|
tx, ty = target_pos = target_widget.to_window(
|
|
*target_pos, initial=False)
|
|
elif target_widget is not None:
|
|
x_off, y_off = target_widget_offset
|
|
w_x = getattr(target_widget, target_widget_loc[0]) + x_off
|
|
w_y = getattr(target_widget, target_widget_loc[1]) + y_off
|
|
tx, ty = target_pos = target_widget.to_window(w_x, w_y)
|
|
else:
|
|
target_pos = tx, ty
|
|
|
|
touch = AsyncUnitTestTouch(x, y)
|
|
|
|
touch.touch_down()
|
|
await self.wait_clock_frames(1)
|
|
if long_press:
|
|
await self.async_sleep(long_press)
|
|
yield 'down', touch.pos
|
|
|
|
dx = (tx - x) / drag_n
|
|
dy = (ty - y) / drag_n
|
|
|
|
ts0 = time.perf_counter()
|
|
for i in range(drag_n):
|
|
await self.async_sleep(
|
|
max(0., duration - (time.perf_counter() - ts0)) / (drag_n - i))
|
|
|
|
touch.touch_move(x + (i + 1) * dx, y + (i + 1) * dy)
|
|
await self.wait_clock_frames(1)
|
|
yield 'move', touch.pos
|
|
|
|
if touch.pos != target_pos:
|
|
touch.touch_move(*target_pos)
|
|
await self.wait_clock_frames(1)
|
|
yield 'move', touch.pos
|
|
|
|
touch.touch_up()
|
|
await self.wait_clock_frames(1)
|
|
yield 'up', touch.pos
|
|
|
|
async def do_touch_drag_follow(
|
|
self, pos=None, widget=None,
|
|
widget_loc=('center_x', 'center_y'),
|
|
target_pos=None, target_widget=None, target_widget_offset=(0, 0),
|
|
target_widget_loc=('center_x', 'center_y'), long_press=0,
|
|
duration=.2, drag_n=5, max_n=25):
|
|
"""Very similar to :meth:`do_touch_drag`, except it follows the target
|
|
widget, even if the target widget moves as a result of the drag, the
|
|
drag will follow it until it's on the target widget.
|
|
|
|
`origin`: These parameters specify where the drag starts.
|
|
- If ``widget`` is None, it starts at ``pos`` (in window coordinates).
|
|
- If ``pos`` is None, it starts on the ``widget`` as specified by
|
|
``widget_loc``.
|
|
- If neither is None, it starts at ``pos``, but in the ``widget``'s
|
|
coordinate system (:meth:`~kivy.uix.widget.Widget.to_window` is used
|
|
on it).
|
|
|
|
`target`: These parameters specify where the drag ends.
|
|
- If ``target_pos`` is None, it ends on the ``target_widget`` as
|
|
specified by ``target_widget_loc``. ``target_widget_offset``, is an
|
|
additional ``(x, y)`` offset relative to ``target_widget_loc``.
|
|
- If ``target_pos`` is not None, it starts at ``target_pos``, but in
|
|
the ``target_widget``'s coordinate system
|
|
(:meth:`~kivy.uix.widget.Widget.to_window` is used on it).
|
|
|
|
When ``widget`` and/or ``target_widget`` are specified, ``widget_loc``
|
|
and ``target_widget_loc``, respectively, indicate where on the widget
|
|
the drag starts/ends. It is a a tuple with property names of the widget
|
|
to loop up to get the value. The default is
|
|
``('center_x', 'center_y')`` so the drag would start/end in the
|
|
widget's center.
|
|
"""
|
|
if widget is None:
|
|
x, y = pos
|
|
else:
|
|
if pos is None:
|
|
w_x = getattr(widget, widget_loc[0])
|
|
w_y = getattr(widget, widget_loc[1])
|
|
x, y = widget.to_window(w_x, w_y)
|
|
else:
|
|
x, y = widget.to_window(*pos, initial=False)
|
|
|
|
if target_widget is None:
|
|
raise ValueError('target_widget must be specified')
|
|
|
|
def get_target():
|
|
if target_pos is not None:
|
|
return target_widget.to_window(*target_pos, initial=False)
|
|
else:
|
|
x_off, y_off = target_widget_offset
|
|
wt_x = getattr(target_widget, target_widget_loc[0]) + x_off
|
|
wt_y = getattr(target_widget, target_widget_loc[1]) + y_off
|
|
return target_widget.to_window(wt_x, wt_y)
|
|
|
|
touch = AsyncUnitTestTouch(x, y)
|
|
|
|
touch.touch_down()
|
|
await self.wait_clock_frames(1)
|
|
if long_press:
|
|
await self.async_sleep(long_press)
|
|
yield 'down', touch.pos
|
|
|
|
ts0 = time.perf_counter()
|
|
tx, ty = get_target()
|
|
i = 0
|
|
while not (math.isclose(touch.x, tx) and math.isclose(touch.y, ty)):
|
|
if i >= max_n:
|
|
raise Exception(
|
|
'Exceeded the maximum number of iterations, '
|
|
'but {} != {}'.format(touch.pos, (tx, ty)))
|
|
|
|
rem_i = max(1, drag_n - i)
|
|
rem_t = max(0., duration - (time.perf_counter() - ts0)) / rem_i
|
|
i += 1
|
|
await self.async_sleep(rem_t)
|
|
|
|
x, y = touch.pos
|
|
touch.touch_move(x + (tx - x) / rem_i, y + (ty - y) / rem_i)
|
|
await self.wait_clock_frames(1)
|
|
yield 'move', touch.pos
|
|
tx, ty = get_target()
|
|
|
|
touch.touch_up()
|
|
await self.wait_clock_frames(1)
|
|
yield 'up', touch.pos
|
|
|
|
async def do_touch_drag_path(
|
|
self, path, axis_widget=None, long_press=0, duration=.2):
|
|
"""Drags the touch along the specified path.
|
|
|
|
:parameters:
|
|
|
|
`path`: list
|
|
A list of position tuples the touch will follow. The first
|
|
item is used for the touch down and the rest for the move.
|
|
`axis_widget`: a Widget
|
|
If None, the path coordinates is in window coordinates,
|
|
otherwise, we will first transform the path coordinates
|
|
to window coordinates using
|
|
:meth:`~kivy.uix.widget.Widget.to_window` of the specified
|
|
widget.
|
|
"""
|
|
if axis_widget is not None:
|
|
path = [axis_widget.to_window(*p, initial=False) for p in path]
|
|
x, y = path[0]
|
|
path = path[1:]
|
|
|
|
touch = AsyncUnitTestTouch(x, y)
|
|
|
|
touch.touch_down()
|
|
await self.wait_clock_frames(1)
|
|
if long_press:
|
|
await self.async_sleep(long_press)
|
|
yield 'down', touch.pos
|
|
|
|
ts0 = time.perf_counter()
|
|
n = len(path)
|
|
for i, (x2, y2) in enumerate(path):
|
|
await self.async_sleep(
|
|
max(0., duration - (time.perf_counter() - ts0)) / (n - i))
|
|
|
|
touch.touch_move(x2, y2)
|
|
await self.wait_clock_frames(1)
|
|
yield 'move', touch.pos
|
|
|
|
touch.touch_up()
|
|
await self.wait_clock_frames(1)
|
|
yield 'up', touch.pos
|
|
|
|
async def do_keyboard_key(
|
|
self, key, modifiers=(), duration=.05, num_press=1):
|
|
from kivy.core.window import Window
|
|
if key == ' ':
|
|
key = 'spacebar'
|
|
key_lower = key.lower()
|
|
key_code = Window._system_keyboard.string_to_keycode(key_lower)
|
|
|
|
known_modifiers = {'shift', 'alt', 'ctrl', 'meta'}
|
|
if set(modifiers) - known_modifiers:
|
|
raise ValueError('Unknown modifiers "{}"'.
|
|
format(set(modifiers) - known_modifiers))
|
|
|
|
special_keys = {
|
|
27: 'escape',
|
|
9: 'tab',
|
|
8: 'backspace',
|
|
13: 'enter',
|
|
127: 'del',
|
|
271: 'enter',
|
|
273: 'up',
|
|
274: 'down',
|
|
275: 'right',
|
|
276: 'left',
|
|
278: 'home',
|
|
279: 'end',
|
|
280: 'pgup',
|
|
281: 'pgdown',
|
|
300: 'numlock',
|
|
301: 'capslock',
|
|
145: 'screenlock',
|
|
}
|
|
|
|
text = None
|
|
try:
|
|
text = chr(key_code)
|
|
if key_lower != key:
|
|
text = key
|
|
except ValueError:
|
|
pass
|
|
|
|
dt = duration / num_press
|
|
for i in range(num_press):
|
|
await self.async_sleep(dt)
|
|
|
|
Window.dispatch('on_key_down', key_code, 0, text, modifiers)
|
|
if (key not in known_modifiers and
|
|
key_code not in special_keys and
|
|
not (known_modifiers & set(modifiers))):
|
|
Window.dispatch('on_textinput', text)
|
|
|
|
await self.wait_clock_frames(1)
|
|
yield 'down', (key, key_code, 0, text, modifiers)
|
|
|
|
Window.dispatch('on_key_up', key_code, 0)
|
|
await self.wait_clock_frames(1)
|
|
yield 'up', (key, key_code, 0, text, modifiers)
|