2023-09-10 05:22:53 +03:00
|
|
|
"""Record interactions and save as an automated test."""
|
|
|
|
|
2023-09-09 22:25:51 +03:00
|
|
|
import os
|
2023-09-10 04:27:23 +03:00
|
|
|
from typing import Any, Callable
|
2023-09-09 21:49:00 +03:00
|
|
|
from textual.css.query import NoMatches, TooManyMatches
|
|
|
|
from textual.dom import DOMNode
|
2023-09-10 04:27:23 +03:00
|
|
|
from textual.errors import NoWidget
|
2023-09-09 21:49:00 +03:00
|
|
|
from textual.events import Event, Key, MouseDown, MouseMove, MouseUp
|
2023-09-09 22:46:35 +03:00
|
|
|
from textual.geometry import Offset
|
2023-09-09 23:26:06 +03:00
|
|
|
from textual.pilot import Pilot
|
2023-09-09 21:49:00 +03:00
|
|
|
from textual.screen import Screen
|
|
|
|
from textual_paint.paint import PaintApp
|
|
|
|
|
2023-09-09 22:25:51 +03:00
|
|
|
def unique_file(path: str) -> str:
|
|
|
|
filename, extension = os.path.splitext(path)
|
|
|
|
counter = 1
|
2023-09-09 21:49:00 +03:00
|
|
|
|
2023-09-09 22:25:51 +03:00
|
|
|
while os.path.exists(path):
|
|
|
|
path = f"{filename}_{counter}{extension}"
|
|
|
|
counter += 1
|
2023-09-09 21:49:00 +03:00
|
|
|
|
2023-09-09 22:25:51 +03:00
|
|
|
return path
|
|
|
|
|
2023-09-09 23:47:35 +03:00
|
|
|
def indent(text: str, spaces: int) -> str:
|
|
|
|
return "\n".join(" " * spaces + line for line in text.splitlines())
|
|
|
|
|
2023-09-09 22:25:51 +03:00
|
|
|
OUTPUT_FILE = unique_file("tests/test_paint_something.py")
|
|
|
|
|
2023-09-09 22:46:35 +03:00
|
|
|
steps: list[tuple[Event, Offset, str, int|None]] = []
|
2023-09-10 04:46:03 +03:00
|
|
|
replaying: bool = False
|
2023-09-09 22:25:51 +03:00
|
|
|
|
|
|
|
def get_selector(target: DOMNode) -> tuple[str, int|None]:
|
2023-09-09 21:49:00 +03:00
|
|
|
"""Return a selector that can be used to find the widget."""
|
2023-09-09 22:25:51 +03:00
|
|
|
assert app is not None, "app should be set by now"
|
2023-09-09 21:49:00 +03:00
|
|
|
widget = target
|
|
|
|
if widget.id:
|
2023-09-09 22:25:51 +03:00
|
|
|
return f"#{widget.id}", None
|
2023-09-09 21:49:00 +03:00
|
|
|
selector = widget.css_identifier
|
|
|
|
while widget.parent and not isinstance(widget.parent, Screen):
|
|
|
|
widget = widget.parent
|
|
|
|
if widget.id:
|
|
|
|
selector = f"#{widget.id} {selector}"
|
|
|
|
break
|
|
|
|
else:
|
|
|
|
selector = f"{widget.css_identifier} {selector}"
|
|
|
|
try:
|
|
|
|
query_result = app.query_one(selector)
|
|
|
|
except TooManyMatches:
|
2023-09-10 04:46:03 +03:00
|
|
|
# FIXME: I think this can fail due to a race condition,
|
|
|
|
# when clicking a button that closes a dialog, removing the button from the DOM.
|
|
|
|
# Maybe intercept events differently, like by overriding post_message?
|
2023-09-10 04:48:52 +03:00
|
|
|
return selector, app.query(selector).nodes.index(target) # type: ignore
|
2023-09-09 22:25:51 +03:00
|
|
|
# smarter differentiators would be nice, like tooltip or text content,
|
|
|
|
# but at least with indices, you'll know when you changed the tab order
|
2023-09-09 21:49:00 +03:00
|
|
|
except NoMatches:
|
|
|
|
raise Exception(f"Selector {selector!r} didn't match the target widget ({target!r})")
|
|
|
|
if query_result is not target:
|
|
|
|
raise Exception(f"Selector {selector!r} matched a different widget than the target ({query_result!r} rather than {target!r})")
|
|
|
|
|
2023-09-09 22:25:51 +03:00
|
|
|
return selector, None
|
2023-09-09 21:49:00 +03:00
|
|
|
|
|
|
|
original_on_event = PaintApp.on_event
|
|
|
|
async def on_event(self: PaintApp, event: Event) -> None:
|
|
|
|
await original_on_event(self, event)
|
2023-09-10 04:46:03 +03:00
|
|
|
if replaying:
|
|
|
|
return
|
2023-09-09 21:49:00 +03:00
|
|
|
if isinstance(event, (MouseDown, MouseMove, MouseUp)):
|
2023-09-10 04:27:23 +03:00
|
|
|
try:
|
|
|
|
widget, _ = self.get_widget_at(*event.screen_offset)
|
|
|
|
except NoWidget:
|
|
|
|
return
|
2023-09-09 22:46:35 +03:00
|
|
|
offset = event.screen_offset - widget.region.offset
|
|
|
|
steps.append((event, offset, *get_selector(widget)))
|
2023-09-09 21:49:00 +03:00
|
|
|
elif isinstance(event, Key):
|
2023-09-10 04:46:03 +03:00
|
|
|
if event.key == "ctrl+z" and steps:
|
2023-09-09 21:49:00 +03:00
|
|
|
steps.pop()
|
2023-09-10 04:48:52 +03:00
|
|
|
run() # restart the app to replay up to this point
|
2023-09-09 21:49:00 +03:00
|
|
|
elif event.key == "ctrl+c":
|
|
|
|
save_replay()
|
2023-09-10 04:46:09 +03:00
|
|
|
else:
|
|
|
|
steps.append((event, Offset(), "", None))
|
2023-09-09 21:49:00 +03:00
|
|
|
|
|
|
|
app: PaintApp | None = None
|
2023-09-09 23:26:06 +03:00
|
|
|
next_after_exit: Callable[[], None] | None = None
|
|
|
|
|
2023-09-09 23:47:35 +03:00
|
|
|
async def async_exec(code: str, **kwargs: object) -> object:
|
|
|
|
# This dict will be used for passing variables to the `exec`ed code
|
|
|
|
# as well as retrieving the function defined by the code.
|
|
|
|
scope = kwargs
|
|
|
|
|
2023-09-09 23:26:06 +03:00
|
|
|
# Make an async function with the code and `exec` it
|
2023-09-09 23:47:35 +03:00
|
|
|
exec(f"async def async_exec_code():\n{indent(code, 4)}", scope)
|
2023-09-09 23:26:06 +03:00
|
|
|
|
2023-09-09 23:47:35 +03:00
|
|
|
# Get `async_exec_code` from the scope, call it and return the result
|
2023-09-10 04:48:52 +03:00
|
|
|
return await scope['async_exec_code']() # type: ignore
|
2023-09-09 21:49:00 +03:00
|
|
|
|
2023-09-10 04:27:23 +03:00
|
|
|
async def replay_steps(pilot: Pilot[Any]) -> None:
|
2023-09-10 04:46:03 +03:00
|
|
|
global app, replaying
|
2023-09-09 23:26:06 +03:00
|
|
|
assert app is not None, "app should be set by now"
|
2023-09-09 23:35:44 +03:00
|
|
|
if not steps:
|
|
|
|
return
|
2023-09-10 04:46:03 +03:00
|
|
|
replaying = True
|
2023-09-09 23:47:35 +03:00
|
|
|
await async_exec(get_replay_code(), pilot=pilot, Offset=Offset)
|
2023-09-10 04:46:03 +03:00
|
|
|
replaying = False
|
2023-09-09 23:26:06 +03:00
|
|
|
|
|
|
|
def run() -> None:
|
|
|
|
global app, next_after_exit
|
|
|
|
def startup_and_replay() -> None:
|
|
|
|
global app, next_after_exit
|
2023-09-10 04:48:52 +03:00
|
|
|
next_after_exit = None # important to allowing you to exit; don't keep launching the app
|
2023-09-09 23:26:06 +03:00
|
|
|
app = PaintApp()
|
|
|
|
app.on_event = on_event.__get__(app)
|
2023-09-10 04:27:23 +03:00
|
|
|
app.run(auto_pilot=replay_steps)
|
|
|
|
# run is blocking, so this will happen after the app exits
|
2023-09-09 23:26:06 +03:00
|
|
|
if next_after_exit:
|
|
|
|
next_after_exit()
|
2023-09-09 21:49:00 +03:00
|
|
|
if app is not None:
|
2023-09-10 04:27:23 +03:00
|
|
|
# exit can't be awaited, because it stops the whole event loop (eventually)
|
|
|
|
# but we need to wait for the event loop to stop before we can start a new app
|
2023-09-09 23:26:06 +03:00
|
|
|
next_after_exit = startup_and_replay
|
2023-09-09 21:49:00 +03:00
|
|
|
app.exit()
|
2023-09-09 23:26:06 +03:00
|
|
|
else:
|
|
|
|
startup_and_replay()
|
2023-09-09 21:49:00 +03:00
|
|
|
|
2023-09-09 23:26:06 +03:00
|
|
|
|
|
|
|
def get_replay_code() -> str:
|
2023-09-09 21:49:00 +03:00
|
|
|
steps_code = ""
|
2023-09-09 22:46:35 +03:00
|
|
|
for event, offset, selector, index in steps:
|
2023-09-09 21:49:00 +03:00
|
|
|
if isinstance(event, MouseDown):
|
2023-09-09 22:25:51 +03:00
|
|
|
if index is None:
|
2023-09-09 22:46:35 +03:00
|
|
|
steps_code += f"await pilot.click({selector!r}, offset=Offset({offset.x}, {offset.y}))\n"
|
2023-09-09 22:25:51 +03:00
|
|
|
else:
|
|
|
|
steps_code += f"widget = pilot.app.query({selector!r})[{index!r}]\n"
|
2023-09-09 22:46:35 +03:00
|
|
|
# can't pass a widget to pilot.click, only a selector, or None
|
|
|
|
steps_code += f"await pilot.click(offset=Offset({offset.x}, {offset.y}) + widget.region.offset)\n"
|
2023-09-09 23:26:06 +03:00
|
|
|
elif isinstance(event, MouseMove):
|
2023-09-10 04:27:23 +03:00
|
|
|
# TODO: generate code for drags (but not extraneous mouse movement)
|
2023-09-09 23:26:06 +03:00
|
|
|
pass
|
|
|
|
elif isinstance(event, MouseUp):
|
|
|
|
pass
|
|
|
|
elif isinstance(event, Key):
|
|
|
|
steps_code += f"await pilot.press({event.key!r})\n"
|
|
|
|
else:
|
|
|
|
raise Exception(f"Unexpected event type {type(event)}")
|
2023-09-10 04:46:03 +03:00
|
|
|
return steps_code or "pass"
|
2023-09-09 23:26:06 +03:00
|
|
|
|
2023-09-09 22:25:51 +03:00
|
|
|
|
2023-09-09 23:26:06 +03:00
|
|
|
def save_replay() -> None:
|
|
|
|
assert app is not None, "app should be set by now"
|
2023-09-09 21:49:00 +03:00
|
|
|
|
|
|
|
script = f"""\
|
|
|
|
from pathlib import Path, PurePath
|
|
|
|
from typing import Awaitable, Callable, Iterable, Protocol
|
|
|
|
|
|
|
|
import pytest
|
|
|
|
from textual.geometry import Offset
|
|
|
|
from textual.pilot import Pilot
|
|
|
|
from textual.widgets import Input
|
|
|
|
|
|
|
|
class SnapCompareType(Protocol):
|
|
|
|
\"\"\"Type of the function returned by the snap_compare fixture.\"\"\"
|
|
|
|
def __call__(
|
|
|
|
self,
|
|
|
|
app_path: str | PurePath,
|
|
|
|
press: Iterable[str] = (),
|
|
|
|
terminal_size: tuple[int, int] = (80, 24),
|
|
|
|
run_before: Callable[[Pilot], Awaitable[None] | None] | None = None, # type: ignore
|
|
|
|
) -> bool:
|
|
|
|
...
|
|
|
|
|
|
|
|
# Relative paths are treated as relative to this file, when using snap_compare.
|
|
|
|
PAINT = Path("../src/textual_paint/paint.py")
|
|
|
|
|
|
|
|
# Prevent flaky tests due to timing issues.
|
|
|
|
Input.cursor_blink = False # type: ignore
|
|
|
|
|
|
|
|
def test_paint_something(snap_compare: SnapCompareType):
|
|
|
|
async def test_paint_something_steps(pilot: Pilot[None]):
|
2023-09-09 23:26:06 +03:00
|
|
|
{indent(get_replay_code(), 8)}
|
2023-09-09 21:49:00 +03:00
|
|
|
|
2023-09-09 22:25:51 +03:00
|
|
|
assert snap_compare(PAINT, run_before=test_paint_something_steps, terminal_size=({app.size.width}, {app.size.height}))
|
2023-09-09 21:49:00 +03:00
|
|
|
"""
|
|
|
|
with open(OUTPUT_FILE, "w") as f:
|
|
|
|
f.write(script)
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
2023-09-09 23:26:06 +03:00
|
|
|
run()
|