diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000..29f8bcc --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,30 @@ +name: docs + +on: + push: + branches: + - main + - master + workflow_dispatch: + +permissions: + contents: write + +jobs: + docs: + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.14' + - run: python -m pip install --upgrade pip + - run: pip install -r docs/requirements.txt + - run: sphinx-build -b html -E -a ./docs ./docs/_build/html + - run: New-Item docs/_build/html/.nojekyll -ItemType File -Force + - uses: peaceiris/actions-gh-pages@v4 + with: + github_token: ${{ github.token }} + publish_branch: gh-pages + publish_dir: docs/_build/html + force_orphan: true diff --git a/README.md b/README.md index f37db71..bd26e7b 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,13 @@ - + -[![License - BSD 3-Clause](https://img.shields.io/pypi/l/appwindows.svg)](https://github.com/lexter0705/appwindows/blob/master/LICENSE.md) [![unit_tests](https://github.com/lexter0705/appwindows/actions/workflows/unit_tests.yml/badge.svg)](https://github.com/lexter0705/appwindows/actions/workflows/unit_tests.yml) +[![Documentation](https://img.shields.io/badge/docs-pages-green)](https://apparser-development.github.io/apparser/) +[![unit_tests](https://github.com/apparser-development/appwindows/actions/workflows/unit_tests.yml/badge.svg)](https://github.com/apparser-development/appwindows/actions/workflows/unit_tests.yml)
-[![PyPI Downloads](https://static.pepy.tech/personalized-badge/appwindows?period=total&units=INTERNATIONAL_SYSTEM&left_color=GRAY&right_color=GREEN&left_text=downloads)](https://pepy.tech/projects/appwindows) -[![Documentation](https://img.shields.io/badge/docs-gitbook-green)](https://apparser.gitbook.io/appwindows) -
-[![PyPi](https://img.shields.io/badge/PyPi-link-green)](https://pypi.org/project/appwindows/) -[![Github](https://img.shields.io/badge/github-repo-green)](https://github.com/lexter0705/appwindows) -[![Issues](https://img.shields.io/badge/github-issues-green)](https://github.com/lexter0705/appwindows/issues) +[![Github](https://img.shields.io/badge/github-repo-green)](https://github.com/apparser-development/apparser) +[![Issues](https://img.shields.io/badge/github-issues-green)](https://github.com/apparser-development/apparser/issues) # Apparser -The apparser library is designed for testing and managing computer programs. - +Apparser is a Python library designed for automating desktop applications and managing UI interfaces using artificial intelligence, such as OCR or object detection models. # Install ```bash pip install apparser @@ -31,14 +27,14 @@ algorithm = Algorithm([ WriteText("Hello World") # Write text ]) -app = App("cmd.exe") +app = App("notepad", window_title="Notepad") algorithm.perform(app.ui) ``` # Docs -All documentation here
-Link to PyPi +All documentation here
+Link to PyPi # For Developers 1) If something doesn't work - open issue. diff --git a/apparser.svg b/apparser.svg index 1666941..7dde869 100644 --- a/apparser.svg +++ b/apparser.svg @@ -1,12 +1,6 @@ - - - - - - - - - - - + + + + + diff --git a/apparser/core/__init__.py b/apparser/core/__init__.py index c2f593d..64afabc 100644 --- a/apparser/core/__init__.py +++ b/apparser/core/__init__.py @@ -1,4 +1,4 @@ from apparser.core.app import App -from apparser.core.ui import BaseUi, DesktopUi, CoordinatesUi, WindowUi +from apparser.core.ui import BaseUi, DesktopUi, CoordinatesUi, WindowUi, WindowByDisplayUi -__all__ = ["App", "BaseUi", "DesktopUi", "CoordinatesUi", "WindowUi"] +__all__ = ["App", "BaseUi", "DesktopUi", "CoordinatesUi", "WindowUi", "WindowByDisplayUi"] diff --git a/apparser/core/app.py b/apparser/core/app.py index d59275e..c853b3c 100644 --- a/apparser/core/app.py +++ b/apparser/core/app.py @@ -1,18 +1,18 @@ +import os import subprocess import time from appwindows import get_finder +from appwindows.exceptions import WindowDoesNotFoundException, WindowDoesNotValidException -from apparser.core.ui.window import WindowUi, BaseUi -from apparser.geometry import Size +from apparser.core.ui import WindowUi, BaseUi class App: """Manage an application process and its UI wrapper.""" def __init__(self, path_to_exe: str, - window_title: str, - window_size: Size = Size(900, 900), + window_title: str | None = None, timeout: float = 1): """Initialize an application controller. @@ -20,8 +20,6 @@ def __init__(self, path_to_exe: str, :type path_to_exe: str :param window_title: Title of the window to attach to. :type window_title: str - :param window_size: Initial size applied to the window. - :type window_size: Size :param timeout: Delay before the window lookup starts. :type timeout: float :raises TypeError: If any argument has an invalid type. @@ -29,12 +27,9 @@ def __init__(self, path_to_exe: str, if not isinstance(path_to_exe, str): raise TypeError('path_to_exe must be a string') - if not isinstance(window_title, str): + if window_title is not None and not isinstance(window_title, str): raise TypeError('window_title must be a string') - if not isinstance(window_size, Size): - raise TypeError('window_size must be a Size') - if not (isinstance(timeout, float) or isinstance(timeout, int)): raise TypeError('timeout must be a number') @@ -42,23 +37,53 @@ def __init__(self, path_to_exe: str, self.__process: subprocess.Popen | None = None self.__path = path_to_exe self.__timeout = timeout - self.__window_size: Size = window_size self.__window_title_name: str = window_title self.__ui: BaseUi | None = None self.start_app() + def __find_window_by_title(self): + try: + window = self.__window_finder.get_window_by_title(self.__window_title_name) + self.__ui = WindowUi(window) + except WindowDoesNotFoundException: + pass + + def __find_window_by_process_id(self, process_id: int): + try: + window = self.__window_finder.get_window_by_process_id(process_id) + self.__ui = WindowUi(window) + except WindowDoesNotFoundException: + pass + def start_app(self): - """Start the application process and bind its UI.""" + """Start the application process and bind its UI. + + :raises WindowDoesNotValidException: If the window is not found or the application cannot be opened. + """ + if self.__window_title_name is not None: + self.__find_window_by_title() + if self.__ui is not None: + return + window_processes = [i.get_process_id() for i in get_finder().get_all_windows()] self.__process = subprocess.Popen([self.__path]) time.sleep(self.__timeout) - window = self.__window_finder.get_window_by_title(self.__window_title_name) - self.__ui = WindowUi(window) - self.__ui.window.resize(self.__window_size) + self.__find_window_by_process_id(os.getpid()) + for i in get_finder().get_all_windows(): + if self.__ui is not None: + return + if i.get_process_id() not in window_processes: + self.__find_window_by_process_id(i.get_process_id()) + if self.__ui is not None: + return + self.__find_window_by_title() + if self.__ui is not None: + raise WindowDoesNotValidException() def stop_app(self): """Close the application window and stop the process.""" self.ui.window.close() - self.__process.kill() + if self.__process is not None: + self.__process.kill() @property def ui(self) -> BaseUi: diff --git a/apparser/core/ui/__init__.py b/apparser/core/ui/__init__.py index 81fa48f..e3c83d1 100644 --- a/apparser/core/ui/__init__.py +++ b/apparser/core/ui/__init__.py @@ -2,5 +2,7 @@ from apparser.core.ui.desktop import DesktopUi from apparser.core.ui.coordinates import CoordinatesUi from apparser.core.ui.window import WindowUi +from apparser.core.ui.window_by_display import WindowByDisplayUi -__all__ = ["BaseUi", "DesktopUi", "CoordinatesUi", "WindowUi"] + +__all__ = ["BaseUi", "DesktopUi", "CoordinatesUi", "WindowUi", "WindowByDisplayUi"] diff --git a/apparser/core/ui/coordinates.py b/apparser/core/ui/coordinates.py index a24e3b6..6d569a7 100644 --- a/apparser/core/ui/coordinates.py +++ b/apparser/core/ui/coordinates.py @@ -9,39 +9,54 @@ class CoordinatesUi(BaseUi): - """Represent a UI region inside another UI context.""" - - def __init__(self, - from_ui: BaseUi, - left_top_point: Point | RelativelyPoint, - size: Size): + """Represent a UI region defined by two points inside another UI context.""" + + def __init__( + self, + from_ui: BaseUi, + point_one: Point | RelativelyPoint, + point_two: Point | RelativelyPoint + ): """Initialize a nested coordinate-based UI context. :param from_ui: Source UI used as a parent context. :type from_ui: BaseUi - :param left_top_point: Top-left point of the nested region. - :type left_top_point: Point | RelativelyPoint - :param size: Size of the nested region. - :type size: Size + :param point_one: First point of the nested region. + :type point_one: Point | RelativelyPoint + :param point_two: Second point of the nested region or region size. + :type point_two: Point | RelativelyPoint | Size :raises TypeError: If any argument has an invalid type. """ if not isinstance(from_ui, BaseUi): - raise TypeError('from_ui must be Ui') + raise TypeError('from_ui must be BaseUi') - if not (isinstance(left_top_point, Point) or isinstance(left_top_point, RelativelyPoint)): - raise TypeError('left_top_point must be Point or RelativelyPoint') + if not isinstance(point_one, (Point, RelativelyPoint)): + raise TypeError('point1 must be Point or RelativelyPoint') - if not isinstance(size, Size): - raise TypeError('size must be Size') + elif not isinstance(point_two, (Point, RelativelyPoint)): + raise TypeError('point2 must be Point, RelativelyPoint') self.__from_ui = from_ui - self.__left_top_point = left_top_point - self.__size = size - - def __get_left_top_local_point(self) -> Point: - if isinstance(self.__left_top_point, RelativelyPoint): - return self.__from_ui.point_to_local(self.__from_ui.point_to_global(self.__left_top_point)) - return self.__left_top_point + self.__point_one = point_one + self.__point_two = point_two + + def __point_to_main_ui_local(self, point: Point | RelativelyPoint) -> Point: + if isinstance(point, RelativelyPoint): + return self.__from_ui.point_to_local(self.__from_ui.point_to_global(point)) + return point + + def __get_local_bounds(self) -> tuple[Point, Point]: + point1 = self.__point_to_main_ui_local(self.__point_one) + point2 = self.__point_to_main_ui_local(self.__point_two) + left_top_point = Point( + min(point1.x, point2.x), + min(point1.y, point2.y), + ) + right_bottom_point = Point( + max(point1.x, point2.x), + max(point1.y, point2.y), + ) + return left_top_point, right_bottom_point @singledispatchmethod def point_to_global(self, coordinates: Point | RelativelyPoint) -> Point: @@ -55,14 +70,17 @@ def point_to_global(self, coordinates: Point | RelativelyPoint) -> Point: raise NotImplementedError() @point_to_global.register(Point) - def _(self, coordinates: Point): - left_top_point = self.__get_left_top_local_point() + def _(self, coordinates: Point) -> Point: + left_top_point, _ = self.__get_local_bounds() return self.__from_ui.point_to_global(coordinates + left_top_point) @point_to_global.register(RelativelyPoint) - def _(self, coordinates: RelativelyPoint): - x = round(coordinates.x * self.__size.width) - y = round(coordinates.y * self.__size.height) + def _(self, coordinates: RelativelyPoint) -> Point: + left_top_point, right_bottom_point = self.__get_local_bounds() + width = abs(round(right_bottom_point.x - left_top_point.x)) + height = abs(round(right_bottom_point.y - left_top_point.y)) + x = round(coordinates.x * width) + y = round(coordinates.y * height) local_point = Point(x, y) return self.point_to_global(local_point) @@ -74,7 +92,7 @@ def point_to_local(self, coordinates: Point) -> Point: :return: Converted local point. :rtype: Point """ - left_top_point = self.__get_left_top_local_point() + left_top_point, _ = self.__get_local_bounds() return self.__from_ui.point_to_local(coordinates) - left_top_point def get_screenshot(self) -> numpy.ndarray: @@ -84,12 +102,11 @@ def get_screenshot(self) -> numpy.ndarray: :rtype: numpy.ndarray """ screenshot = self.__from_ui.get_screenshot() - left_top_point = self.__get_left_top_local_point() - right_bottom_point = left_top_point + Point(self.__size.width, self.__size.height) - - if isinstance(screenshot, numpy.ndarray): - return screenshot[left_top_point.y:right_bottom_point.y, left_top_point.x:right_bottom_point.x] - return screenshot.crop((left_top_point.x, left_top_point.y, right_bottom_point.x, right_bottom_point.y)) + left_top_point, right_bottom_point = self.__get_local_bounds() + return screenshot[ + left_top_point.y:right_bottom_point.y, + left_top_point.x:right_bottom_point.x, + ] @property def window(self) -> Window: diff --git a/apparser/core/ui/window_by_display.py b/apparser/core/ui/window_by_display.py new file mode 100644 index 0000000..dad741d --- /dev/null +++ b/apparser/core/ui/window_by_display.py @@ -0,0 +1,90 @@ +from functools import singledispatchmethod + +import numpy +from PIL import ImageGrab + +from appwindows import Window +from appwindows.geometry import Point, Size + +from apparser.core.ui.base import BaseUi +from apparser.geometry.relatively_point import RelativelyPoint + + +class WindowByDisplayUi(BaseUi): + """ + Represent a window as a display-captured UI context. + Unlike the WindowUi class, it retrieves the application's image based on its borders rather than from the graphical shell. + """ + + def __init__(self, window: Window) -> None: + """Initialize a display-captured window UI context. + + :param window: Window instance to wrap. + :type window: Window + :raises TypeError: If ``window`` has an invalid type. + """ + if not isinstance(window, Window): + raise TypeError('window must be Window') + + self.__window = window + + @singledispatchmethod + def point_to_global(self, coordinates: Point | RelativelyPoint) -> Point: + """Convert window coordinates to the global screen space. + + :param coordinates: Local or relative coordinates to convert. + :type coordinates: Point | RelativelyPoint + :return: Converted global point. + :rtype: Point + """ + raise NotImplementedError() + + @point_to_global.register(Point) + def _(self, coordinates: Point) -> Point: + return coordinates + self.__window.get_points().left_top + + @point_to_global.register(RelativelyPoint) + def _(self, coordinates: RelativelyPoint) -> Point: + size: Size = self.__window.get_size() + x = round(coordinates.x * size.width) + y = round(coordinates.y * size.height) + local_point = Point(x, y) + return self.point_to_global(local_point) + + def point_to_local(self, coordinates: Point) -> Point: + """Convert global coordinates to the window local space. + + :param coordinates: Global point to convert. + :type coordinates: Point + :return: Converted local point. + :rtype: Point + """ + return coordinates - self.__window.get_points().left_top + + def get_screenshot(self) -> numpy.ndarray: + """Capture a screenshot of the window from the display. + + :return: Window screenshot data captured by window bounds. + :rtype: numpy.ndarray + """ + left_top = self.__window.get_points().left_top + size: Size = self.__window.get_size() + screenshot = ImageGrab.grab( + bbox=( + left_top.x, + left_top.y, + left_top.x + size.width, + left_top.y + size.height, + ), + all_screens=True, + ) + return numpy.asarray(screenshot) + + @property + def window(self) -> Window: + """Return the wrapped window instance. + + :return: Wrapped window. + :rtype: Window + """ + return self.__window diff --git a/apparser/cv/__init__.py b/apparser/cv/__init__.py index 17605ca..6763031 100644 --- a/apparser/cv/__init__.py +++ b/apparser/cv/__init__.py @@ -1,4 +1,6 @@ +"""Public computer vision interfaces and default implementations.""" + from apparser.cv.handlers.default import DefaultHandlers from apparser.cv.processes.default import DefaultCvProcess from apparser.cv.readers.yolo import YoloReader -from apparser.cv.models import * \ No newline at end of file +from apparser.cv.models import * diff --git a/apparser/cv/events/__init__.py b/apparser/cv/events/__init__.py index ddfaf83..0f40278 100644 --- a/apparser/cv/events/__init__.py +++ b/apparser/cv/events/__init__.py @@ -5,4 +5,4 @@ from apparser.cv.events.undetected import UnDetected __all__ = ["CvEvent", "Moved", - "Detected", "Resized", "UnDetected"] \ No newline at end of file + "Detected", "Resized", "UnDetected"] diff --git a/apparser/cv/events/base.py b/apparser/cv/events/base.py index d89159d..3930b5e 100644 --- a/apparser/cv/events/base.py +++ b/apparser/cv/events/base.py @@ -2,6 +2,13 @@ class CvEvent(abc.ABC): + """Define the interface for computer vision change events.""" + @abc.abstractmethod def __str__(self) -> str: - raise NotImplementedError() \ No newline at end of file + """Return the human-readable event name. + + :return: Event name. + :rtype: str + """ + raise NotImplementedError() diff --git a/apparser/cv/events/detected.py b/apparser/cv/events/detected.py index c57a2f1..abfac33 100644 --- a/apparser/cv/events/detected.py +++ b/apparser/cv/events/detected.py @@ -2,5 +2,12 @@ class Detected(CvEvent): + """Represent a newly detected object.""" + def __str__(self) -> str: - return "Detected" \ No newline at end of file + """Return the event name. + + :return: Detected event name. + :rtype: str + """ + return "Detected" diff --git a/apparser/cv/events/moved.py b/apparser/cv/events/moved.py index c496714..0610de9 100644 --- a/apparser/cv/events/moved.py +++ b/apparser/cv/events/moved.py @@ -2,5 +2,12 @@ class Moved(CvEvent): + """Represent a detected object whose position changed.""" + def __str__(self) -> str: - return "Moved" \ No newline at end of file + """Return the event name. + + :return: Moved event name. + :rtype: str + """ + return "Moved" diff --git a/apparser/cv/events/resized.py b/apparser/cv/events/resized.py index 483e102..ad5e852 100644 --- a/apparser/cv/events/resized.py +++ b/apparser/cv/events/resized.py @@ -2,5 +2,12 @@ class Resized(CvEvent): + """Represent a detected object whose size changed.""" + def __str__(self) -> str: - return "Resized" \ No newline at end of file + """Return the event name. + + :return: Resized event name. + :rtype: str + """ + return "Resized" diff --git a/apparser/cv/events/undetected.py b/apparser/cv/events/undetected.py index c66b6ad..f446e53 100644 --- a/apparser/cv/events/undetected.py +++ b/apparser/cv/events/undetected.py @@ -2,5 +2,12 @@ class UnDetected(CvEvent): + """Represent a previously tracked object that disappeared.""" + def __str__(self) -> str: - return "UnDetected" \ No newline at end of file + """Return the event name. + + :return: Undetected event name. + :rtype: str + """ + return "UnDetected" diff --git a/apparser/cv/handlers/__init__.py b/apparser/cv/handlers/__init__.py index a0dd944..8c0a98d 100644 --- a/apparser/cv/handlers/__init__.py +++ b/apparser/cv/handlers/__init__.py @@ -2,4 +2,4 @@ from apparser.cv.handlers.default import DefaultHandlers -__all__ = ["CvHandlers", "DefaultHandlers"] \ No newline at end of file +__all__ = ["CvHandlers", "DefaultHandlers"] diff --git a/apparser/cv/handlers/base.py b/apparser/cv/handlers/base.py index e7242d7..9348b1c 100644 --- a/apparser/cv/handlers/base.py +++ b/apparser/cv/handlers/base.py @@ -6,10 +6,25 @@ class CvHandlers(abc.ABC): + """Define the interface for computer vision event handler registries.""" + @abc.abstractmethod def register_handler(self, event: Type[CvEvent]): + """Return a decorator that registers a handler for an event. + + :param event: Event type to subscribe to. + :type event: Type[CvEvent] + """ pass @abc.abstractmethod def call(self, event: Type[CvEvent], changed_data: CvChangeData, *args): + """Call handlers registered for the provided event. + + :param event: Event type to dispatch. + :type event: Type[CvEvent] + :param changed_data: Event payload with current and previous box data. + :type changed_data: CvChangeData + :param args: Additional context passed to handler functions. + """ pass diff --git a/apparser/cv/handlers/default.py b/apparser/cv/handlers/default.py index 25abcd5..27ce1f2 100644 --- a/apparser/cv/handlers/default.py +++ b/apparser/cv/handlers/default.py @@ -8,6 +8,14 @@ def _form_args(function: Callable, *args) -> dict[str, Any]: + """Build keyword arguments matching the annotated handler signature. + + :param function: Handler function to inspect. + :type function: Callable + :param args: Available arguments for the handler call. + :return: Keyword arguments matched by exact annotation type. + :rtype: dict[str, Any] + """ result = {} function_signature = inspect.signature(function) for arg in function_signature.parameters.values(): @@ -18,20 +26,47 @@ def _form_args(function: Callable, *args) -> dict[str, Any]: class DefaultHandlers(CvHandlers): + """Store and dispatch handlers for computer vision events.""" + def __init__(self): + """Initialize an empty handler registry.""" self.__events: list[CvHandler] = [] def register_handler(self, event: Type[CvEvent], *args, class_name: str = None): + """Create a decorator that registers a handler for an event. + + :param event: Event type to subscribe to. + :type event: Type[CvEvent] + :param args: Reserved positional arguments for compatibility. + :param class_name: Optional object class filter for the handler. + :type class_name: str | None + :raises TypeError: If ``event`` is the abstract base event type. + """ if event is CvEvent: - raise TypeError("event must be a apparser.cv.events.CvEvent") + raise TypeError("event must be a concrete CvEvent subclass") def decorator(function: Callable[[Optional[CvAllData], Optional[BaseUi], Optional[CvChangeData]], None]): + """Register the decorated function as an event handler. + + :param function: Handler function to store. + :type function: Callable[[Optional[CvAllData], Optional[BaseUi], Optional[CvChangeData]], None] + :return: Registered handler function. + :rtype: Callable[[Optional[CvAllData], Optional[BaseUi], Optional[CvChangeData]], None] + """ self.__events.append(CvHandler(event, function, class_name)) return function return decorator def call(self, event: Type[CvEvent], changed_data: CvChangeData, *args): + """Call every handler matching the event and class filter. + + :param event: Event type to dispatch. + :type event: Type[CvEvent] + :param changed_data: Event payload with current and previous box data. + :type changed_data: CvChangeData + :param args: Additional context forwarded to handlers. + """ for handler in self.__events: if handler.event is event and (handler.class_name is None or changed_data.box.class_name == handler.class_name): diff --git a/apparser/cv/models/data.py b/apparser/cv/models/data.py index abd7b8c..12552d2 100644 --- a/apparser/cv/models/data.py +++ b/apparser/cv/models/data.py @@ -7,6 +7,8 @@ @dataclass(frozen=True) class CvBox: + """Store a detected object bounding box and its UI context.""" + class_name: str track_id: int | None x: int @@ -17,6 +19,8 @@ class CvBox: @dataclass(frozen=True) class CvChangeData: + """Store an event together with current and previous box states.""" + event: Type[CvEvent] box: CvBox old_box: CvBox @@ -24,4 +28,6 @@ class CvChangeData: @dataclass(frozen=True) class CvAllData: + """Store all detected boxes for a single computer vision read.""" + boxes: list[CvBox] diff --git a/apparser/cv/models/handler.py b/apparser/cv/models/handler.py index 8bd46df..26132d0 100644 --- a/apparser/cv/models/handler.py +++ b/apparser/cv/models/handler.py @@ -8,6 +8,8 @@ @dataclass(frozen=True) class CvHandler: + """Store a handler registration for a computer vision event.""" + event: Type[CvEvent] function: Callable[[Optional[CvAllData], Optional[BaseUi], Optional[CvChangeData]], None] class_name: str | None = None diff --git a/apparser/cv/processes/base.py b/apparser/cv/processes/base.py index 242f1e2..fc911ae 100644 --- a/apparser/cv/processes/base.py +++ b/apparser/cv/processes/base.py @@ -5,14 +5,27 @@ class CvProcess(abc.ABC): + """Define the interface for computer vision processing loops.""" + @abc.abstractmethod def start(self, ui: BaseUi): + """Start processing computer vision data for a UI context. + + :param ui: UI instance used as the capture source. + :type ui: BaseUi + """ pass @abc.abstractmethod def stop(self): + """Stop the processing loop.""" pass @abc.abstractmethod def include_handlers(self, handler: CvHandlers): - pass \ No newline at end of file + """Add a handler registry to the processing loop. + + :param handler: Handler registry to include. + :type handler: CvHandlers + """ + pass diff --git a/apparser/cv/processes/default.py b/apparser/cv/processes/default.py index bec43d2..4df240a 100644 --- a/apparser/cv/processes/default.py +++ b/apparser/cv/processes/default.py @@ -1,3 +1,5 @@ +import time + from apparser.core import BaseUi from apparser.cv.handlers import CvHandlers @@ -7,8 +9,26 @@ class DefaultCvProcess(CvProcess): - def __init__(self, reader: CvReader, sleep_seconds: float = 3, - changes_checker: ChangesChecker = ChangesChecker()): + """Run a reader, detect changes, and dispatch matching handlers.""" + + def __init__( + self, + reader: CvReader, + sleep_seconds: float = 3, + changes_checker: ChangesChecker = None, + ): + """Initialize the default computer vision process. + + :param reader: Reader used to collect computer vision data. + :type reader: CvReader + :param sleep_seconds: Delay between read cycles in seconds. + :type sleep_seconds: float + :param changes_checker: Change detector used between read cycles. + :type changes_checker: ChangesChecker | None + """ + if changes_checker is None: + changes_checker = ChangesChecker() + self.__sleep_seconds = sleep_seconds self.__is_working = True self.__reader = reader @@ -16,6 +36,11 @@ def __init__(self, reader: CvReader, sleep_seconds: float = 3, self.__checker = changes_checker def start(self, ui: BaseUi): + """Start reading frames and dispatching detected changes. + + :param ui: UI instance used as the screenshot source. + :type ui: BaseUi + """ self.__is_working = True while self.__is_working: cv_data = self.__reader.read(ui) @@ -23,8 +48,17 @@ def start(self, ui: BaseUi): for handler in self.__handlers_list: handler.call(class_data.event, class_data, cv_data, ui) + if self.__is_working and self.__sleep_seconds > 0: + time.sleep(self.__sleep_seconds) + def stop(self): + """Request the processing loop to stop.""" self.__is_working = False def include_handlers(self, handler: CvHandlers): + """Attach a handler registry to the process. + + :param handler: Handler registry to append. + :type handler: CvHandlers + """ self.__handlers_list.append(handler) diff --git a/apparser/cv/readers/__init__.py b/apparser/cv/readers/__init__.py index 5609b14..e1bfe59 100644 --- a/apparser/cv/readers/__init__.py +++ b/apparser/cv/readers/__init__.py @@ -1,4 +1,4 @@ from apparser.cv.readers.base import CvReader from apparser.cv.readers.yolo import YoloReader -__all__ = ["CvReader", "YoloReader"] \ No newline at end of file +__all__ = ["CvReader", "YoloReader"] diff --git a/apparser/cv/readers/base.py b/apparser/cv/readers/base.py index 97d20c6..762803c 100644 --- a/apparser/cv/readers/base.py +++ b/apparser/cv/readers/base.py @@ -5,6 +5,15 @@ class CvReader(abc.ABC): + """Define the interface for computer vision data readers.""" + @abc.abstractmethod def read(self, ui: BaseUi) -> CvAllData: - pass \ No newline at end of file + """Read computer vision data from a UI context. + + :param ui: UI instance used as the screenshot source. + :type ui: BaseUi + :return: Parsed computer vision data. + :rtype: CvAllData + """ + pass diff --git a/apparser/cv/readers/yolo.py b/apparser/cv/readers/yolo.py index 16587a7..2be8a35 100644 --- a/apparser/cv/readers/yolo.py +++ b/apparser/cv/readers/yolo.py @@ -1,25 +1,41 @@ -from __future__ import annotations +import importlib +from apparser.core import BaseUi, CoordinatesUi from apparser.geometry import Point, Size -from ultralytics import YOLO -from apparser import CoordinatesUi -from apparser.core import BaseUi from apparser.cv.models import CvAllData, CvBox from apparser.cv.readers.base import CvReader class YoloReader(CvReader): + """Read detected objects from UI screenshots with YOLO tracking.""" + def __init__(self, model: object | str, persist: bool = True, **track_settings): + """Initialize a YOLO reader. + + :param model: YOLO model instance or path used to create one. + :type model: object | str + :param persist: Whether to preserve tracking identifiers between reads. + :type persist: bool + :param track_settings: Additional keyword arguments forwarded to ``track``. + """ if hasattr(model, "track"): self.__model = model else: - self.__model = YOLO(model=model) + ultralytics = importlib.import_module("ultralytics") + self.__model = ultralytics.YOLO(model=model) self.__persist = persist self.__track_settings = track_settings def read(self, ui: BaseUi) -> CvAllData: + """Read object detections from the provided UI screenshot. + + :param ui: UI instance used as the screenshot source. + :type ui: BaseUi + :return: Detected boxes with their local UI wrappers. + :rtype: CvAllData + """ results = self.__model.track( source=ui.get_screenshot(), persist=self.__persist, diff --git a/apparser/cv/utils/__init__.py b/apparser/cv/utils/__init__.py index 397821b..992758c 100644 --- a/apparser/cv/utils/__init__.py +++ b/apparser/cv/utils/__init__.py @@ -1,3 +1,3 @@ from apparser.cv.utils.changes_checker import ChangesChecker -__all__ = ['ChangesChecker'] \ No newline at end of file +__all__ = ['ChangesChecker'] diff --git a/apparser/cv/utils/changes_checker.py b/apparser/cv/utils/changes_checker.py index aa6dc6e..8f528a0 100644 --- a/apparser/cv/utils/changes_checker.py +++ b/apparser/cv/utils/changes_checker.py @@ -3,18 +3,46 @@ def _is_moved(box: CvBox, old_box: CvBox) -> bool: + """Check whether a box position changed. + + :param box: Current box state. + :type box: CvBox + :param old_box: Previous box state. + :type old_box: CvBox + :return: True if the box coordinates changed. + :rtype: bool + """ return abs(box.x - old_box.x) > 0 or abs(box.y - old_box.y) > 0 def _is_resized(box: CvBox, old_box: CvBox) -> bool: + """Check whether both box dimensions changed. + + :param box: Current box state. + :type box: CvBox + :param old_box: Previous box state. + :type old_box: CvBox + :return: True if width and height both changed. + :rtype: bool + """ return abs(box.width - old_box.width) > 0 and abs(box.height - old_box.height) > 0 class ChangesChecker: + """Compare consecutive reads and produce computer vision change events.""" + def __init__(self): + """Initialize the checker with an empty previous state.""" self.__old_data: CvAllData = CvAllData([]) def __get_old_box(self, box: CvBox) -> CvBox | None: + """Find the previous box state for the same tracking identifier. + + :param box: Current box state. + :type box: CvBox + :return: Matching box from the previous read, if available. + :rtype: CvBox | None + """ if box.track_id is None: return None needed_boxes: list[CvBox] = [i for i in self.__old_data.boxes if i.track_id == box.track_id] @@ -23,11 +51,25 @@ def __get_old_box(self, box: CvBox) -> CvBox | None: return needed_boxes[0] def __get_undetected(self, current_data: CvAllData) -> list[CvChangeData]: + """Build events for previously tracked boxes missing from the new read. + + :param current_data: Current computer vision data. + :type current_data: CvAllData + :return: Undetected events for disappeared tracked boxes. + :rtype: list[CvChangeData] + """ new_ids = [i.track_id for i in current_data.boxes if i.track_id is not None] return [CvChangeData(UnDetected, i, i) for i in self.__old_data.boxes if i.track_id not in new_ids and i.track_id is not None] def check(self, data: CvAllData) -> list[CvChangeData]: + """Compare current data with the previous read and return change events. + + :param data: Current computer vision data. + :type data: CvAllData + :return: Detected, moved, resized, and undetected events. + :rtype: list[CvChangeData] + """ result: list[CvChangeData] = self.__get_undetected(data) for box in data.boxes: old_box = self.__get_old_box(box) diff --git a/apparser/debuggers/__init__.py b/apparser/debuggers/__init__.py deleted file mode 100644 index be996de..0000000 --- a/apparser/debuggers/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -from apparser.debuggers.base import BaseDebugger -from apparser.debuggers.default import Debugger - -__all__ = ["BaseDebugger", "Debugger"] \ No newline at end of file diff --git a/apparser/exceptions/__init__.py b/apparser/exceptions/__init__.py index a06656f..cf774ec 100644 --- a/apparser/exceptions/__init__.py +++ b/apparser/exceptions/__init__.py @@ -3,6 +3,8 @@ from apparser.exceptions.debug import DebugException from apparser.exceptions.instruction_not_found import InstructionWithNameNotFoundException, InstructionNotFoundException, \ InstructionWithIdNotFoundException +from apparser.exceptions.timeout import TimeoutException __all__ = ["TextNotFoundException", "WindowActionWithDesktopException", "DebugException", - "InstructionNotFoundException", "InstructionWithNameNotFoundException", "InstructionWithIdNotFoundException"] + "InstructionNotFoundException", "InstructionWithNameNotFoundException", + "InstructionWithIdNotFoundException", "TimeoutException"] diff --git a/apparser/exceptions/timeout.py b/apparser/exceptions/timeout.py new file mode 100644 index 0000000..9d0570d --- /dev/null +++ b/apparser/exceptions/timeout.py @@ -0,0 +1,13 @@ +class TimeoutException(Exception): + def __init__(self, wait_time: float | int | None = None): + if wait_time is None: + super().__init__("Timeout error") + return + + if not isinstance(wait_time, (float, int)): + raise TypeError("wait_time must be a number") + + if wait_time < 0: + raise ValueError("wait_time must be >= 0") + + super().__init__(f"Timeout error. The wait lasted more than {wait_time} seconds.") diff --git a/apparser/instructions/__init__.py b/apparser/instructions/__init__.py index 34c4d07..a8cdefd 100644 --- a/apparser/instructions/__init__.py +++ b/apparser/instructions/__init__.py @@ -1,3 +1,4 @@ +from apparser.instructions.base import BaseInstruction from apparser.instructions.default import * from apparser.instructions.ui import * -from apparser.instructions.base import BaseInstruction +from apparser.instructions.ui.algorithms import * diff --git a/apparser/instructions/algorithms/__init__.py b/apparser/instructions/algorithms/__init__.py deleted file mode 100644 index 36f4f6a..0000000 --- a/apparser/instructions/algorithms/__init__.py +++ /dev/null @@ -1,16 +0,0 @@ -from apparser.instructions.algorithms.base import BaseAlgorithm -from apparser.instructions.algorithms.ai import AiAlgorithm -from apparser.instructions.algorithms.ui import Algorithm -from apparser.instructions.algorithms.ids import IdsAlgorithm -from apparser.instructions.algorithms.names import NamesAlgorithm -from apparser.instructions.algorithms.speak import SpeakAlgorithm -from apparser.instructions.algorithms.ocr import OCRAlgorithm - - -__all__ = ["BaseAlgorithm", - "AiAlgorithm", - "Algorithm", - "IdsAlgorithm", - "NamesAlgorithm", - "SpeakAlgorithm", - "OCRAlgorithm"] \ No newline at end of file diff --git a/apparser/instructions/algorithms/ai.py b/apparser/instructions/algorithms/ai.py deleted file mode 100644 index ac9b1b5..0000000 --- a/apparser/instructions/algorithms/ai.py +++ /dev/null @@ -1,84 +0,0 @@ -from apparser.core import BaseUi -from apparser.debuggers import BaseDebugger, Debugger -from apparser.instructions.algorithms.base import BaseAlgorithm -from apparser.instructions import BaseInstruction -from apparser.instructions.speak import SpeakInstruction -from apparser.instructions.ocr import OCRInstruction -from apparser.text_readers import BaseTextReader, EasyOcrReader, ScreensController -from apparser.speakers import BaseSpeaker, ChatTTSSpeaker - - -class AiAlgorithm(BaseAlgorithm): - """Run mixed instruction sequences with OCR and speech dependencies.""" - - def __init__(self, - instructions: list[BaseInstruction], - speaker: BaseSpeaker | None = None, - text_reader: BaseTextReader | None = None, - debugger: BaseDebugger | None = Debugger()): - """Initialize an algorithm for mixed instruction pipelines. - - :param instructions: Instructions to execute in order. - :type instructions: list[BaseInstruction] - :param speaker: Speaker used for speech instructions. - :type speaker: BaseSpeaker | None - :param text_reader: Text reader used for OCR instructions. - :type text_reader: BaseTextReader | None - :param debugger: Debugger used to wrap instruction execution. - :type debugger: BaseDebugger | None - :raises TypeError: If ``text_reader``, ``speaker`` or ``debugger`` has an invalid type. - """ - if speaker is None: - speaker = ChatTTSSpeaker() - - if text_reader is None: - text_reader = ScreensController(EasyOcrReader()) - - if not isinstance(text_reader, BaseTextReader): - raise TypeError("text_reader must be BaseTextReader") - - if not isinstance(speaker, BaseSpeaker): - raise TypeError("text_reader must be BaseTextReader") - - if debugger is not None and not isinstance(debugger, BaseDebugger): - raise TypeError("debugger must be BaseDebugger or None") - - self.__instructions = instructions - self.__text_reader = text_reader - self.__speaker = speaker - self.__debugger = debugger - - @property - def id(self) -> int: - return 1000 - - def perform(self, ui: BaseUi, *args, **kwargs): - if self.__debugger is not None: - self.__debugger.clear_contex() - - ui.window.to_foreground() - for instruction in self.__instructions: - if not (isinstance(instruction, BaseInstruction)): - raise TypeError(f"{instruction} must be Instruction or AiInstruction") - if isinstance(instruction, SpeakInstruction): - if self.__debugger is not None: - self.__debugger.try_perform(instruction, ui, self.__speaker) - else: - instruction.perform(ui, self.__speaker) - elif isinstance(instruction, OCRInstruction): - if self.__debugger is not None: - self.__debugger.try_perform(instruction, ui, self.__text_reader) - else: - instruction.perform(ui, self.__text_reader) - else: - instruction.perform(ui) - - def add_instruction(self, instruction: BaseInstruction): - if not (isinstance(instruction, BaseInstruction)): - raise TypeError(f"{instruction} must be Instruction or AiInstruction") - - self.__instructions.append(instruction) - - @property - def instructions(self) -> list[BaseInstruction]: - return self.__instructions diff --git a/apparser/instructions/algorithms/ui.py b/apparser/instructions/algorithms/ui.py deleted file mode 100644 index 121626b..0000000 --- a/apparser/instructions/algorithms/ui.py +++ /dev/null @@ -1,52 +0,0 @@ -from apparser.instructions.algorithms.base import BaseAlgorithm - -from apparser.core import BaseUi -from apparser.debuggers import BaseDebugger -from apparser.instructions import UiInstruction - - -class Algorithm(BaseAlgorithm): - """Run UI instructions sequentially for a single window context.""" - - def __init__(self, instructions: list[UiInstruction], - debugger: BaseDebugger | None): - """Initialize a UI instruction algorithm. - - :param instructions: UI instructions to execute in order. - :type instructions: list[UiInstruction] - :param debugger: Debugger used to wrap instruction execution. - :type debugger: BaseDebugger | None - :raises TypeError: If ``debugger`` has an invalid type. - """ - if debugger is not None and not isinstance(debugger, BaseDebugger): - raise TypeError("debugger must be BaseDebugger or None") - - self.__instructions = instructions - self.__debugger = debugger - - @property - def id(self) -> int: - return 1001 - - def perform(self, ui: BaseUi, *args, **kwargs): - ui.window.to_foreground() - if self.__debugger is not None: - self.__debugger.clear_contex() - - for instruction in self.__instructions: - if not isinstance(instruction, UiInstruction): - raise TypeError(f"{instruction} must be Instruction") - if self.__debugger is not None: - self.__debugger.try_perform(instruction, ui) - else: - instruction.perform(ui) - - def add_instruction(self, instruction: UiInstruction): - if not isinstance(instruction, UiInstruction): - raise TypeError(f"{instruction} must be Instruction") - - self.__instructions.append(instruction) - - @property - def instructions(self) -> list[UiInstruction]: - return self.__instructions diff --git a/apparser/instructions/algorithms/unique.py b/apparser/instructions/algorithms/unique.py deleted file mode 100644 index e69de29..0000000 diff --git a/apparser/instructions/debuggers/__init__.py b/apparser/instructions/debuggers/__init__.py new file mode 100644 index 0000000..3b07ddd --- /dev/null +++ b/apparser/instructions/debuggers/__init__.py @@ -0,0 +1,4 @@ +from apparser.instructions.debuggers.base import BaseDebugger +from apparser.instructions.debuggers.default import Debugger + +__all__ = ["BaseDebugger", "Debugger"] \ No newline at end of file diff --git a/apparser/debuggers/base.py b/apparser/instructions/debuggers/base.py similarity index 87% rename from apparser/debuggers/base.py rename to apparser/instructions/debuggers/base.py index 2364654..bdb13c7 100644 --- a/apparser/debuggers/base.py +++ b/apparser/instructions/debuggers/base.py @@ -1,13 +1,13 @@ import abc -from apparser.instructions import BaseInstruction +from apparser.instructions.base import BaseInstruction class BaseDebugger(abc.ABC): """Define the common interface for debugger implementations.""" @abc.abstractmethod - def clear_contex(self): + def clear_context(self): """Clear the stored debugging context.""" pass diff --git a/apparser/debuggers/default.py b/apparser/instructions/debuggers/default.py similarity index 91% rename from apparser/debuggers/default.py rename to apparser/instructions/debuggers/default.py index 0c6be4d..43797c0 100644 --- a/apparser/debuggers/default.py +++ b/apparser/instructions/debuggers/default.py @@ -1,7 +1,7 @@ -from apparser.debuggers.base import BaseDebugger - from apparser.exceptions import DebugException -from apparser.instructions import BaseInstruction + +from apparser.instructions.base import BaseInstruction +from apparser.instructions.debuggers.base import BaseDebugger class Debugger(BaseDebugger): @@ -39,6 +39,6 @@ def try_perform(self, instruction: BaseInstruction, *args, **kwargs): raise_text = f'{formed_log}\n{max_string_len * "-"}\n{e}' raise DebugException(raise_text) - def clear_contex(self): + def clear_context(self): """Clear the stored instruction log.""" self.__instructions = [] diff --git a/apparser/instructions/default/__init__.py b/apparser/instructions/default/__init__.py index 4e0c553..5118baf 100644 --- a/apparser/instructions/default/__init__.py +++ b/apparser/instructions/default/__init__.py @@ -1,7 +1,8 @@ -from apparser.instructions.default.click import MouseClick +from apparser.instructions.default.click import MouseClick, MouseDown, MouseUp from apparser.instructions.default.play_audio import PlayAudio from apparser.instructions.default.play_audio_file import PlayAudioFile -from apparser.instructions.default.press import PressKey, PressKeysCombination +from apparser.instructions.default.press import PressKey, PressKeysCombination, \ + PressKeyDown, PressKeyUp from apparser.instructions.default.say_audio import SayAudio from apparser.instructions.default.say_audio_file import SayAudioFile from apparser.instructions.default.sleep import Sleep @@ -15,4 +16,8 @@ "SayAudioFile", "Sleep", "MouseClick", - "WriteText"] + "WriteText", + "PressKeyDown", + "PressKeyUp", + "MouseUp", + "MouseDown"] diff --git a/apparser/instructions/default/click.py b/apparser/instructions/default/click.py index 8d77618..e0bc955 100644 --- a/apparser/instructions/default/click.py +++ b/apparser/instructions/default/click.py @@ -1,25 +1,21 @@ -import mouse +import pyautogui +from apparser.key_codes import RightClick, LeftClick, BaseKeyCode from apparser.instructions.base import BaseInstruction -from apparser.key_codes.mouse_keys import RightClick, LeftClick class MouseClick(BaseInstruction): """Click the selected mouse button.""" - def __init__(self, click_type: RightClick | LeftClick = LeftClick()): + def __init__(self, click_type: BaseKeyCode = LeftClick()): """Initialize a mouse click instruction. :param click_type: Mouse button to click. - :type click_type: RightClick | LeftClick - :raises TypeError: If ``click_type`` is neither :class:`RightClick` nor :class:`LeftClick`. + :type click_type: BaseKeyCode + :raises TypeError: If ``click_type`` is neither :class:`BaseKeyCode`. """ - if isinstance(click_type, RightClick): - self.__press_function = mouse.right_click - elif isinstance(click_type, LeftClick): - self.__press_function = mouse.click - else: - raise TypeError('click_type must be RightClick or LeftClick') + if not isinstance(click_type, BaseKeyCode): + raise TypeError('click_type must be BaseKeyCode') self.__click_type = click_type @@ -28,4 +24,50 @@ def id(self) -> int: return 1 def perform(self, *args, **kwargs): - self.__press_function() + pyautogui.click(button=str(self.__click_type)) + + +class MouseDown(BaseInstruction): + """Press the selected mouse button down.""" + + def __init__(self, click_type: BaseKeyCode = LeftClick()): + """Initialize a mouse down instruction. + + :param click_type: Mouse button to press down. + :type click_type: BaseKeyCode + :raises TypeError: If ``click_type`` is neither :class:`BaseKeyCode`. + """ + if not isinstance(click_type, BaseKeyCode): + raise TypeError('click_type must be BaseKeyCode') + + self.__click_type = click_type + + @property + def id(self) -> int: + return 13 + + def perform(self, *args, **kwargs): + pyautogui.mouseDown(button=str(self.__click_type)) + + +class MouseUp(BaseInstruction): + """Release the selected mouse button.""" + + def __init__(self, click_type: BaseKeyCode = LeftClick()): + """Initialize a mouse up instruction. + + :param click_type: Mouse button to release. + :type click_type: BaseKeyCode + :raises TypeError: If ``click_type`` is neither :class:`BaseKeyCode`. + """ + if not isinstance(click_type, BaseKeyCode): + raise TypeError('click_type must be BaseKeyCode') + + self.__click_type = click_type + + @property + def id(self) -> int: + return 12 + + def perform(self, *args, **kwargs): + pyautogui.mouseUp(button=str(self.__click_type)) diff --git a/apparser/instructions/default/press.py b/apparser/instructions/default/press.py index 6440595..8807ba3 100644 --- a/apparser/instructions/default/press.py +++ b/apparser/instructions/default/press.py @@ -1,7 +1,8 @@ -import keyboard +import pyautogui + +from apparser.key_codes import BaseKeyCode from apparser.instructions.base import BaseInstruction -from apparser.key_codes.base import BaseKeyCode class PressKey(BaseInstruction): @@ -15,7 +16,7 @@ def __init__(self, key_code: BaseKeyCode | str): :raises TypeError: If ``key_code`` is neither :class:`BaseKeyCode` nor :class:`str`. """ if not (isinstance(key_code, BaseKeyCode) or isinstance(key_code, str)): - raise TypeError('key_code must be KeyCode or str') + raise TypeError('key_code must be BaseKeyCode or str') self.__key_code = key_code @@ -24,7 +25,7 @@ def id(self) -> int: return 2 def perform(self, *args, **kwargs): - keyboard.send(str(self.__key_code)) + pyautogui.press(str(self.__key_code)) class PressKeysCombination(BaseInstruction): @@ -45,8 +46,54 @@ def id(self) -> int: def perform(self, *args, **kwargs): for key in self.__keys: if not (isinstance(key, BaseKeyCode) or isinstance(key, str)): - raise TypeError('key_code must be KeyCode or str') - keyboard.press(str(key)) + raise TypeError('key_code must be BaseKeyCode or str') + pyautogui.keyDown(str(key)) for key in self.__keys: - keyboard.release(str(key)) + pyautogui.keyUp(str(key)) + + +class PressKeyDown(BaseInstruction): + """Send a single keyboard key press down.""" + + def __init__(self, key_code: BaseKeyCode | str): + """Initialize a single-key press down instruction. + + :param key_code: Key code to press down. + :type key_code: BaseKeyCode | str + :raises TypeError: If ``key_code`` is neither :class:`BaseKeyCode` nor :class:`str`. + """ + if not (isinstance(key_code, BaseKeyCode) or isinstance(key_code, str)): + raise TypeError('key_code must be BaseKeyCode or str') + + self.__key_code = key_code + + @property + def id(self) -> int: + return 10 + + def perform(self, *args, **kwargs): + pyautogui.keyDown(str(self.__key_code)) + + +class PressKeyUp(BaseInstruction): + """Release a single keyboard key.""" + + def __init__(self, key_code: BaseKeyCode | str): + """Initialize a single-key release instruction. + + :param key_code: Key code to release. + :type key_code: BaseKeyCode | str + :raises TypeError: If ``key_code`` is neither :class:`BaseKeyCode` nor :class:`str`. + """ + if not (isinstance(key_code, BaseKeyCode) or isinstance(key_code, str)): + raise TypeError('key_code must be BaseKeyCode or str') + + self.__key_code = key_code + + @property + def id(self) -> int: + return 11 + + def perform(self, *args, **kwargs): + pyautogui.keyUp(str(self.__key_code)) diff --git a/apparser/instructions/default/say_audio.py b/apparser/instructions/default/say_audio.py index 77d7aef..6651da6 100644 --- a/apparser/instructions/default/say_audio.py +++ b/apparser/instructions/default/say_audio.py @@ -6,7 +6,7 @@ class SayAudio(BaseInstruction): - """Play raw audio through a microphone output device.""" + """Play raw audio through the selected voice output device.""" def __init__(self, audio: numpy.ndarray | list, @@ -14,13 +14,13 @@ def __init__(self, microphone_device: int | str | None = None, blocking: bool = True, **settings): - """Initialize a microphone-targeted audio playback instruction. + """Initialize a voice-device audio playback instruction. :param audio: Audio data to play. :type audio: numpy.ndarray | list :param sample_rate: Audio sample rate in hertz. :type sample_rate: int | float - :param microphone_device: Output device identifier for voice playback. + :param microphone_device: Output device identifier used for voice playback. :type microphone_device: int | str | None :param blocking: Whether playback should block execution. :type blocking: bool diff --git a/apparser/instructions/default/sleep.py b/apparser/instructions/default/sleep.py index 200c937..8f4ce1e 100644 --- a/apparser/instructions/default/sleep.py +++ b/apparser/instructions/default/sleep.py @@ -14,7 +14,7 @@ def __init__(self, sleep_time: float): :raises ValueError: If ``sleep_time`` is not greater than zero. """ if sleep_time <= 0: - raise ValueError("sleep_time must be >= 0") + raise ValueError("sleep_time must be > 0") self.sleep_time = sleep_time diff --git a/apparser/instructions/default/write_text.py b/apparser/instructions/default/write_text.py index 6a32cd5..8b40d45 100644 --- a/apparser/instructions/default/write_text.py +++ b/apparser/instructions/default/write_text.py @@ -1,4 +1,4 @@ -import keyboard +import pyautogui from apparser.instructions.base import BaseInstruction @@ -33,4 +33,4 @@ def id(self) -> int: return 4 def perform(self, *args, **kwargs): - keyboard.write(self.__text, self.__pause_time) + pyautogui.write(self.__text, interval=self.__pause_time) diff --git a/apparser/instructions/ocr/__init__.py b/apparser/instructions/ocr/__init__.py index 89c8aa3..57ba154 100644 --- a/apparser/instructions/ocr/__init__.py +++ b/apparser/instructions/ocr/__init__.py @@ -4,10 +4,13 @@ from apparser.instructions.ocr.plot_text import PlotAllText from apparser.instructions.ocr.print_all_text import PrintAllText from apparser.instructions.ocr.text_getter import GetText +from apparser.instructions.ocr.wait_text import WaitText + __all__ = ["PrintAllText", "ClickOnText", "GetText", "MoveToText", "OCRInstruction", - "PlotAllText"] \ No newline at end of file + "PlotAllText", + "WaitText"] \ No newline at end of file diff --git a/apparser/instructions/ocr/base.py b/apparser/instructions/ocr/base.py index 26113d7..ac67ac7 100644 --- a/apparser/instructions/ocr/base.py +++ b/apparser/instructions/ocr/base.py @@ -1,7 +1,9 @@ import abc from apparser.core import BaseUi + from apparser.text_readers import BaseTextReader + from apparser.instructions.base import BaseInstruction diff --git a/apparser/instructions/ocr/click_on_text.py b/apparser/instructions/ocr/click_on_text.py index 25cec01..8b8086d 100644 --- a/apparser/instructions/ocr/click_on_text.py +++ b/apparser/instructions/ocr/click_on_text.py @@ -1,9 +1,10 @@ from apparser.core import BaseUi from apparser.geometry import Point, RelativelyPoint -from apparser.instructions.default import MouseClick, Sleep from apparser.key_codes import RightClick, LeftClick + from apparser.text_readers import BaseTextReader +from apparser.instructions.default import MouseClick, Sleep from apparser.instructions.ocr.base import OCRInstruction from apparser.instructions.ocr.move_to_text import MoveToText from apparser.instructions.ocr.text_getter import GetText @@ -14,9 +15,9 @@ class ClickOnText(OCRInstruction): def __init__(self, text: str, click_type: RightClick | LeftClick = LeftClick(), - min_similarity: float = 0.9, + min_similarity: float = 0.8, offset: Point | RelativelyPoint = Point(0, 0), - text_getter=GetText(), + text_getter: GetText | None = None, sleep_time_before_move: float = 0.1): """Initialize a text click instruction. @@ -28,18 +29,22 @@ def __init__(self, text: str, :type min_similarity: float :param offset: Offset relative to the detected text center. :type offset: Point | RelativelyPoint - :param text_getter: Instruction used to extract text from the screen. - :type text_getter: GetText + :param text_getter: Instruction used to extract text from the screen. If None use GetText() + :type text_getter: GetText | None :param sleep_time_before_move: Delay before the click is performed. :type sleep_time_before_move: float """ + + if text_getter is None: + text_getter = GetText() + self.__mouse_mover = MoveToText(text, min_similarity, offset, text_getter) self.__click_type = click_type self.__sleep = Sleep(sleep_time_before_move) @property def id(self) -> int: - return 202 + return 2002 def perform(self, ui: BaseUi, ocr: BaseTextReader, *args, **kwargs): self.__mouse_mover.perform(ui, ocr) diff --git a/apparser/instructions/ocr/move_to_text.py b/apparser/instructions/ocr/move_to_text.py index 33cfe91..cb5e069 100644 --- a/apparser/instructions/ocr/move_to_text.py +++ b/apparser/instructions/ocr/move_to_text.py @@ -3,11 +3,13 @@ from apparser.core import BaseUi from apparser.exceptions import TextNotFoundException from apparser.geometry import Point, RelativelyPoint + +from apparser.text_readers import BaseTextReader, TextData + from apparser.instructions.ocr.base import OCRInstruction from apparser.instructions.ocr.text_getter import GetText from apparser.instructions.ui import MouseMove -from apparser.text_readers.base import BaseTextReader -from apparser.text_readers.models.text_data import TextData + class MoveToText(OCRInstruction): @@ -16,7 +18,7 @@ class MoveToText(OCRInstruction): def __init__(self, text: str, min_similarity: float = 0.9, offset: Point | RelativelyPoint = Point(0, 0), - text_getter=GetText()): + text_getter: GetText | None = None): """Initialize a text-targeted mouse movement instruction. :param text: Text to locate. @@ -25,9 +27,12 @@ def __init__(self, text: str, :type min_similarity: float :param offset: Offset relative to the detected text center. :type offset: Point | RelativelyPoint - :param text_getter: Instruction used to extract text from the screen. - :type text_getter: GetText + :param text_getter: Instruction used to extract text from the screen. If None use GetText() + :type text_getter: GetText | None """ + if text_getter is None: + text_getter = GetText() + self.__text = text self.__offset = offset self.__text_getter = text_getter @@ -35,12 +40,14 @@ def __init__(self, text: str, @property def id(self) -> int: - return 201 + return 2001 def find_text(self, texts: list[TextData]) -> tuple[TextData, float]: similar_ratings = [fuzz.token_sort_ratio(self.text, i.text) for i in texts] + if len(similar_ratings) < 1: + raise TextNotFoundException(self.__min_similarity) max_rating = max(similar_ratings) - return texts[similar_ratings.index(max_rating)], max_rating + return texts[similar_ratings.index(max_rating)], max_rating / 100 def __get_local_offset(self, ui: BaseUi) -> Point: if isinstance(self.__offset, RelativelyPoint): @@ -49,7 +56,7 @@ def __get_local_offset(self, ui: BaseUi) -> Point: def perform(self, ui: BaseUi, text_reader: BaseTextReader, *args, **kwargs): self.__text_getter.perform(ui, text_reader) - needed_data, rating = self.find_text(self.__text_getter.global_answer) + needed_data, rating = self.find_text(self.__text_getter.local_answer) if self.__min_similarity > rating: raise TextNotFoundException(self.__min_similarity) y_cords = list(set([i.y for i in needed_data.coordinates])) diff --git a/apparser/instructions/ocr/plot_text.py b/apparser/instructions/ocr/plot_text.py index 65876f6..b962645 100644 --- a/apparser/instructions/ocr/plot_text.py +++ b/apparser/instructions/ocr/plot_text.py @@ -2,9 +2,11 @@ from PIL import ImageDraw, Image -from apparser.text_readers.base import BaseTextReader -from apparser.text_readers.models.text_data import TextData from apparser.core import BaseUi +from apparser.geometry import Point + +from apparser.text_readers import BaseTextReader, TextData\ + from apparser.instructions.ocr.base import OCRInstruction from apparser.instructions.ocr.text_getter import GetText @@ -12,7 +14,8 @@ class _Painter: """Draw OCR results on top of an image.""" - def __init__(self, draw: ImageDraw.Draw, color: Tuple[int, int, int, int]): + def __init__(self, draw: ImageDraw.Draw, color: Tuple[int, int, int, int], + text_move: Point = Point(0, 20)): """Initialize a painter for OCR overlays. :param draw: Pillow drawing context. @@ -22,6 +25,7 @@ def __init__(self, draw: ImageDraw.Draw, color: Tuple[int, int, int, int]): """ self.__draw = draw self.__color = color + self.__text_move = text_move def draw(self, bboxes: list[TextData]): for data in bboxes: @@ -33,40 +37,45 @@ def __paint_lines(self, data: TextData): self.__draw.rectangle(shape, outline=self.__color, width=1) def __paint_cords(self, data: TextData): - y = data.coordinates[0].y + 10 + y = data.coordinates[0].y + self.__text_move.y if y < 0: - y = data.coordinates[2].y - 10 - x = data.coordinates[0].x - 50 - if x < 0: - x = data.coordinates[2].x + 50 + y = data.coordinates[2].y - self.__text_move.y + x = data.coordinates[0].x + self.__text_move.x + if y < 0: + x = data.coordinates[2].x - self.__text_move.x self.__draw.text((x, y), data.text, fill=self.__color) class PlotAllText(OCRInstruction): """Render detected text boxes on a screenshot.""" - def __init__(self, text_getter: GetText = GetText(), - color_rgba: tuple[int, int, int, int] = (255, 255, 255, 255)): + def __init__(self, text_getter: GetText | None = None, + color_rgba: tuple[int, int, int, int] = (255, 255, 255, 255), + text_move: Point = Point(0, 20)): """Initialize an OCR plotting instruction. - :param text_getter: Instruction used to extract text from the screen. - :type text_getter: GetText + :param text_getter: Instruction used to extract text from the screen. If None use GetText() + :type text_getter: GetText | None :param color_rgba: RGBA color used for the rendered overlays. :type color_rgba: tuple[int, int, int, int] """ + if text_getter is None: + text_getter = GetText() + self.__text_getter = text_getter self.__color = color_rgba + self.__text_move = text_move @property def id(self) -> int: - return 204 + return 2004 def perform(self, ui: BaseUi, text_reader: BaseTextReader, *args, **kwargs): self.__text_getter.perform(ui, text_reader) - texts = self.__text_getter.local_answer + texts = self.__text_getter.global_answer image = self.__text_getter.screenshot image = Image.fromarray(image) draw = ImageDraw.Draw(image) - painter = _Painter(draw, self.__color) + painter = _Painter(draw, self.__color, self.__text_move) painter.draw(texts) image.show() diff --git a/apparser/instructions/ocr/print_all_text.py b/apparser/instructions/ocr/print_all_text.py index e3d5611..5bc57a0 100644 --- a/apparser/instructions/ocr/print_all_text.py +++ b/apparser/instructions/ocr/print_all_text.py @@ -1,5 +1,7 @@ -from apparser.text_readers.base import BaseTextReader from apparser.core import BaseUi + +from apparser.text_readers import BaseTextReader + from apparser.instructions.ocr.base import OCRInstruction from apparser.instructions.ocr.text_getter import GetText @@ -7,21 +9,24 @@ class PrintAllText(OCRInstruction): """Print all detected text blocks and their coordinates.""" - def __init__(self, text_getter: GetText = GetText()): + def __init__(self, text_getter: GetText | None = None): """Initialize an OCR text printing instruction. - :param text_getter: Instruction used to extract text from the screen. - :type text_getter: GetText + :param text_getter: Instruction used to extract text from the screen. If None use GetText() + :type text_getter: GetText | None """ + if text_getter is None: + text_getter = GetText() + self.__text_getter = text_getter @property def id(self) -> int: - return 203 + return 2003 def perform(self, ui: BaseUi, text_reader: BaseTextReader, *args, **kwargs): self.__text_getter.perform(ui, text_reader) - for i in self.__text_getter.global_answer: + for i in self.__text_getter.local_answer: points_stroke = "" for j in i.coordinates: points_stroke += str(j) + " " diff --git a/apparser/instructions/ocr/text_getter.py b/apparser/instructions/ocr/text_getter.py index 49a7f82..9fe0d9e 100644 --- a/apparser/instructions/ocr/text_getter.py +++ b/apparser/instructions/ocr/text_getter.py @@ -3,9 +3,11 @@ from apparser.core import BaseUi from apparser.geometry import Point, RelativelyPoint -from apparser.instructions.ocr.base import OCRInstruction + from apparser.text_readers import BaseTextReader, TextData +from apparser.instructions.ocr.base import OCRInstruction + class GetText(OCRInstruction): """Read text from a selected screen region.""" @@ -26,14 +28,14 @@ def __init__(self, self.__left_top_point = left_top_point self.__right_bottom_point = right_bottom_point self.__reload_every_try = reload_every_try - self.__answer = [] self.__local_answer = [] + self.__global_answer = [] self.__left_top_point_global = left_top_point self.__screenshot = None @property def id(self) -> int: - return 200 + return 2000 def __text_coordinates_to_local(self, text: TextData) -> TextData: new_coordinates = [] @@ -48,28 +50,43 @@ def __texts_coordinates_to_local(self, texts: list[TextData]) -> list[TextData]: return returned_data def perform(self, ui: BaseUi, text_reader: BaseTextReader, *args, **kwargs): - if len(self.__answer) != 0 and not self.__reload_every_try: + if len(self.__local_answer) != 0 and not self.__reload_every_try: return right_bottom_point = ui.point_to_local(ui.point_to_global(self.__right_bottom_point)) self.__left_top_point_global = ui.point_to_local(ui.point_to_global(self.__left_top_point)) screen = Image.fromarray(ui.get_screenshot()) screen = screen.crop((self.__left_top_point_global.x, self.__left_top_point_global.y, right_bottom_point.x, right_bottom_point.y)) - self.__screenshot = screen screen = numpy.array(screen) + self.__screenshot = screen ai_answer = text_reader.read_image(screen) - self.__local_answer = ai_answer.copy() + self.__global_answer = ai_answer.copy() ai_answer = self.__texts_coordinates_to_local(ai_answer) - self.__answer = ai_answer - - @property - def global_answer(self) -> list[TextData]: - return self.__answer + self.__local_answer = ai_answer @property def local_answer(self) -> list[TextData]: + """Return the texts coordinates in local Ui object of the last perform. + + :return: Texts coordinates in local Ui object. + :rtype: list[TextData] + """ return self.__local_answer + @property + def global_answer(self) -> list[TextData]: + """Return the global texts coordinates of the last perform. + + :return: Global texts coordinates. + :rtype: list[TextData] + """ + return self.__global_answer + @property def screenshot(self) -> numpy.ndarray: + """Return the screenshot of the last perform. + + :return: Ui screenshot + :rtype: numpy.ndarray + """ return self.__screenshot diff --git a/apparser/instructions/ocr/wait_text.py b/apparser/instructions/ocr/wait_text.py new file mode 100644 index 0000000..6974f1b --- /dev/null +++ b/apparser/instructions/ocr/wait_text.py @@ -0,0 +1,69 @@ +import time + +from thefuzz import fuzz + +from apparser.core import BaseUi +from apparser.exceptions import TimeoutException + +from apparser.text_readers import BaseTextReader, TextData + +from apparser.instructions.ocr.text_getter import GetText +from apparser.instructions.ocr.base import OCRInstruction + + +class WaitText(OCRInstruction): + """Wait until the target text appears.""" + + def __init__(self, text: str, + min_similarity: float = 0.9, + text_getter: GetText | None = None, + interval: float | int = 1, + expire_time: float | None = 600): + """Initialize a text waiting instruction. + + :param text: Text to wait for. + :type text: str + :param min_similarity: Minimum similarity score required for a match. + :type min_similarity: float + :param text_getter: Instruction used to extract text from the screen. If None, use GetText(). + :type text_getter: GetText | None + :param interval: Interval between text checks in seconds. + :type interval: int | float + :param expire_time: Maximum waiting time in seconds. If None, wait without a timeout. + :type expire_time: float | None + """ + if text_getter is None: + text_getter = GetText() + + self.__text = text + self.__min_similarity = min_similarity + self.__text_getter = text_getter + self.__interval = interval + self.__expire_time = expire_time + + @property + def id(self) -> int: + return 2005 + + def __is_needed_text(self, texts: list[TextData]) -> bool: + for i in texts: + if fuzz.token_sort_ratio(self.__text, i.text) / 100 >= self.__min_similarity: + return True + return False + + def perform(self, ui: BaseUi, text_reader: BaseTextReader, *args, **kwargs): + start_time = time.time() + + while True: + self.__text_getter.perform(ui, text_reader) + if self.__is_needed_text(self.__text_getter.local_answer): + return + + if self.__expire_time is not None and time.time() - start_time >= self.__expire_time: + raise TimeoutException(self.__expire_time) + + time.sleep(self.__interval) + + @property + def text(self) -> str: + return self.__text diff --git a/apparser/instructions/speak/base.py b/apparser/instructions/speak/base.py index a1af97b2..090d8a5 100644 --- a/apparser/instructions/speak/base.py +++ b/apparser/instructions/speak/base.py @@ -1,7 +1,9 @@ import abc from apparser.core import BaseUi + from apparser.speakers import BaseSpeaker + from apparser.instructions.base import BaseInstruction @@ -19,11 +21,9 @@ def id(self) -> int: pass @abc.abstractmethod - def perform(self, ui: BaseUi, speaker: BaseSpeaker, *args, **kwargs) -> BaseUi: + def perform(self, speaker: BaseSpeaker, *args, **kwargs) -> BaseUi: """Execute the instruction with a speaker backend. - :param ui: UI instance used during execution. - :type ui: BaseUi :param speaker: Speaker used to synthesize or play speech. :type speaker: BaseSpeaker :param args: Additional positional arguments for the execution flow. diff --git a/apparser/instructions/speak/play_text.py b/apparser/instructions/speak/play_text.py index 9e13c52..9d0a1f6 100644 --- a/apparser/instructions/speak/play_text.py +++ b/apparser/instructions/speak/play_text.py @@ -1,8 +1,7 @@ -from apparser.core import BaseUi -from apparser.instructions.default import PlayAudio from apparser.speakers import BaseSpeaker from apparser.instructions.speak.base import SpeakInstruction +from apparser.instructions.default import PlayAudio class PlayTextAudio(SpeakInstruction): @@ -10,17 +9,17 @@ class PlayTextAudio(SpeakInstruction): def __init__(self, text: str, - sample_rate: int | float = 48000, + sample_rate: int | float | None = None, **settings): """Initialize a speech playback instruction. :param text: Text to synthesize and play. :type text: str :param sample_rate: Audio sample rate in hertz. - :type sample_rate: int | float + :type sample_rate: int | float | None :param settings: Additional audio playback settings. :type settings: dict[str, object] - :raises TypeError: If ``text`` has an invalid type. + :raises TypeError: If ``text`` or ``sample_rate`` has an invalid type. :raises ValueError: If ``text`` is empty. """ if not isinstance(text, str): @@ -29,18 +28,25 @@ def __init__(self, if len(text) < 1: raise ValueError("text cannot be empty") + if sample_rate is not None and not isinstance(sample_rate, (int, float)): + raise TypeError("sample_rate must be a number or None") + self.__text = text self.__sample_rate = sample_rate self.__settings = settings @property def id(self) -> int: - return 300 + return 3000 - def perform(self, ui: BaseUi, speaker: BaseSpeaker, *args, **kwargs): - audio = speaker.speak(self.__text) + def perform(self, speaker: BaseSpeaker, *args, **kwargs): + audio, audio_sample_rate = speaker.speak(self.__text) PlayAudio( audio=audio, - sample_rate=self.__sample_rate, + sample_rate=( + audio_sample_rate + if self.__sample_rate is None + else self.__sample_rate + ), **self.__settings, ).perform(*args, **kwargs) diff --git a/apparser/instructions/speak/say_text.py b/apparser/instructions/speak/say_text.py index 1c4c693..60b004f 100644 --- a/apparser/instructions/speak/say_text.py +++ b/apparser/instructions/speak/say_text.py @@ -1,7 +1,6 @@ -from apparser.core import BaseUi -from apparser.instructions.default import SayAudio from apparser.speakers import BaseSpeaker +from apparser.instructions.default import SayAudio from apparser.instructions.speak.base import SpeakInstruction @@ -10,17 +9,17 @@ class SayTextAudio(SpeakInstruction): def __init__(self, text: str, - sample_rate: int | float = 48000, + sample_rate: int | float | None = None, **settings): """Initialize a microphone-targeted speech instruction. :param text: Text to synthesize and play. :type text: str :param sample_rate: Audio sample rate in hertz. - :type sample_rate: int | float + :type sample_rate: int | float | None :param settings: Additional audio playback settings. :type settings: dict[str, object] - :raises TypeError: If ``text`` has an invalid type. + :raises TypeError: If ``text`` or ``sample_rate`` has an invalid type. :raises ValueError: If ``text`` is empty. """ if not isinstance(text, str): @@ -29,18 +28,25 @@ def __init__(self, if len(text) < 1: raise ValueError("text cannot be empty") + if sample_rate is not None and not isinstance(sample_rate, (int, float)): + raise TypeError("sample_rate must be a number or None") + self.__text = text self.__sample_rate = sample_rate self.__settings = settings @property def id(self) -> int: - return 301 + return 3001 - def perform(self, ui: BaseUi, speaker: BaseSpeaker, *args, **kwargs): - audio = speaker.speak(self.__text) + def perform(self, speaker: BaseSpeaker, *args, **kwargs): + audio, audio_sample_rate = speaker.speak(self.__text) SayAudio( audio=audio, - sample_rate=self.__sample_rate, + sample_rate=( + audio_sample_rate + if self.__sample_rate is None + else self.__sample_rate + ), **self.__settings, ).perform(*args, **kwargs) diff --git a/apparser/instructions/ui/__init__.py b/apparser/instructions/ui/__init__.py index d5c8d19..a8e0008 100644 --- a/apparser/instructions/ui/__init__.py +++ b/apparser/instructions/ui/__init__.py @@ -5,6 +5,7 @@ from apparser.instructions.ui.resize_window import WindowResize from apparser.instructions.ui.to_window import WindowToForeground, WindowToBackground + __all__ = ["MouseMove", "MouseClickTo", "UiInstruction", diff --git a/apparser/instructions/ui/algorithms/__init__.py b/apparser/instructions/ui/algorithms/__init__.py new file mode 100644 index 0000000..071cab3 --- /dev/null +++ b/apparser/instructions/ui/algorithms/__init__.py @@ -0,0 +1,16 @@ +from apparser.instructions.ui.algorithms.base import BaseAlgorithm +from apparser.instructions.ui.algorithms.algorithm import Algorithm +from apparser.instructions.ui.algorithms.ids import IdsAlgorithm +from apparser.instructions.ui.algorithms.names import NamesAlgorithm +from apparser.instructions.ui.algorithms.speak import SpeakAlgorithm +from apparser.instructions.ui.algorithms.ocr import OCRAlgorithm +from apparser.instructions.ui.algorithms.unique import UniqueAlgorithm + + +__all__ = ["BaseAlgorithm", + "Algorithm", + "IdsAlgorithm", + "NamesAlgorithm", + "SpeakAlgorithm", + "OCRAlgorithm", + "UniqueAlgorithm"] \ No newline at end of file diff --git a/apparser/instructions/ui/algorithms/algorithm.py b/apparser/instructions/ui/algorithms/algorithm.py new file mode 100644 index 0000000..1436bd4 --- /dev/null +++ b/apparser/instructions/ui/algorithms/algorithm.py @@ -0,0 +1,60 @@ +from apparser.core import BaseUi + +from apparser.instructions.base import BaseInstruction +from apparser.instructions.ui.algorithms.base import BaseAlgorithm +from apparser.instructions.debuggers import BaseDebugger +from apparser.instructions.debuggers import Debugger + + +class Algorithm(BaseAlgorithm): + """Run UI instructions sequentially for a single window context.""" + + def __init__(self, instructions: list[BaseInstruction], + debugger: BaseDebugger | bool = True): + """Initialize a UI instruction algorithm. + + :param instructions: UI instructions or default instructions to execute in order. + :type instructions: list[BaseInstruction] + :param debugger: Debugger used to wrap instruction execution. If True, use Debugger. If False do not wrap instruction execution. + :type debugger: BaseDebugger | bool + :raises TypeError: If ``debugger`` has an invalid type. + """ + if not isinstance(debugger, BaseDebugger) and not isinstance(debugger, bool): + raise TypeError(f"debugger must be a bool or BaseDebugger") + + if debugger == True: + debugger = Debugger() + + elif debugger == False: + debugger = None + + self.__instructions = instructions + self.__debugger = debugger + + @property + def id(self) -> int: + return 1500 + + def perform(self, ui: BaseUi, *args, **kwargs): + ui.window.to_foreground() + if self.__debugger is not None: + self.__debugger.clear_context() + + for instruction in self.__instructions: + if not isinstance(instruction, BaseInstruction) or instruction.id > 1999: + raise TypeError(f"{instruction} must be BaseInstruction or UiInstruction") + + if self.__debugger is not None: + self.__debugger.try_perform(instruction, ui) + else: + instruction.perform(ui) + + def add_instruction(self, instruction: BaseInstruction): + if not isinstance(instruction, BaseInstruction) or instruction.id > 1999: + raise TypeError(f"{instruction} must be BaseInstruction or UiInstruction") + + self.__instructions.append(instruction) + + @property + def instructions(self) -> list[BaseInstruction]: + return self.__instructions diff --git a/apparser/instructions/algorithms/base.py b/apparser/instructions/ui/algorithms/base.py similarity index 92% rename from apparser/instructions/algorithms/base.py rename to apparser/instructions/ui/algorithms/base.py index 839b759..7a5e90c 100644 --- a/apparser/instructions/algorithms/base.py +++ b/apparser/instructions/ui/algorithms/base.py @@ -1,9 +1,11 @@ import abc from apparser.core import BaseUi -from apparser.instructions.base import BaseInstruction -class BaseAlgorithm(BaseInstruction): +from apparser.instructions.ui.base import UiInstruction + + +class BaseAlgorithm(UiInstruction): """Define the base contract for instruction algorithms.""" @property diff --git a/apparser/instructions/algorithms/ids.py b/apparser/instructions/ui/algorithms/ids.py similarity index 54% rename from apparser/instructions/algorithms/ids.py rename to apparser/instructions/ui/algorithms/ids.py index 2d2d8bc..9e16676 100644 --- a/apparser/instructions/algorithms/ids.py +++ b/apparser/instructions/ui/algorithms/ids.py @@ -1,8 +1,11 @@ +import inspect from typing import Any from apparser.core import BaseUi -from apparser.debuggers import BaseDebugger, Debugger -from apparser.instructions.algorithms.base import BaseAlgorithm + +from apparser.instructions import BaseInstruction +from apparser.instructions.debuggers import BaseDebugger, Debugger +from apparser.instructions.ui.algorithms.base import BaseAlgorithm from apparser.instructions.utils import get_instruction_by_id @@ -23,39 +26,69 @@ def _check_instruction(instruction: tuple[int, list[Any]]) -> tuple[int, list[An class IdsAlgorithm(BaseAlgorithm): """Resolve and execute instructions by their numeric identifiers.""" - def __init__(self, instructions: list[tuple[int, list[Any]]], debugger: BaseDebugger | None = Debugger()): + def __init__(self, + instructions: list[tuple[int, list[Any]]], + attributes: list[Any], + debugger: BaseDebugger | bool = True): """Initialize an identifier-based instruction algorithm. :param instructions: Sequence of instruction identifiers with their arguments. :type instructions: list[tuple[int, list[Any]]] - :param debugger: Debugger used to wrap instruction execution. - :type debugger: BaseDebugger | None + :param attributes: Attribute values matched to instruction parameters by type. + :type attributes: list[Any] + :param debugger: Debugger used to wrap instruction execution. If True, use Debugger. If False do not wrap instruction execution. + :type debugger: BaseDebugger | bool :raises TypeError: If ``debugger`` has an invalid type. """ - if debugger is not None and not isinstance(debugger, BaseDebugger): - raise TypeError("debugger must be BaseDebugger or None") - + + if not isinstance(debugger, BaseDebugger) and not isinstance(debugger, bool): + raise TypeError(f"debugger must be a bool or BaseDebugger") + + if debugger == True: + debugger = Debugger() + + elif debugger == False: + debugger = None + + attributes.reverse() + self.__debugger = debugger self.__instructions = instructions + self.__attributes = attributes @property def id(self) -> int: - return 1002 + return 1501 + + def __form_args(self, instruction: BaseInstruction) -> dict[str, Any]: + result = {} + function_signature = inspect.signature(instruction.perform) + for arg in function_signature.parameters.values(): + for a in self.__attributes: + if arg.annotation is type(a): + result[arg.name] = a + return result def perform(self, ui: BaseUi, *args, **kwargs): ui.window.to_foreground() + + if self.__debugger is not None: + self.__debugger.clear_context() + for instruction_data in self.__instructions: instruction_id, instruction_args = _check_instruction(instruction_data) instruction = get_instruction_by_id(instruction_id) if instruction is None: raise ValueError(f"instruction with id {instruction_id} not found") - + instruction = instruction(*instruction_args) + perform_kwargs = self.__form_args(instruction) + if self.__debugger is not None: - self.__debugger.try_perform(instruction, ui, *args, **kwargs) + self.__debugger.try_perform(instruction, **perform_kwargs) else: - instruction.perform(ui, *args, **kwargs) + instruction.perform(ui, **perform_kwargs) def add_instruction(self, instruction: tuple[int, list[Any]]): _check_instruction(instruction) diff --git a/apparser/instructions/algorithms/names.py b/apparser/instructions/ui/algorithms/names.py similarity index 54% rename from apparser/instructions/algorithms/names.py rename to apparser/instructions/ui/algorithms/names.py index d74d6bf..67f362f 100644 --- a/apparser/instructions/algorithms/names.py +++ b/apparser/instructions/ui/algorithms/names.py @@ -1,8 +1,11 @@ +import inspect from typing import Any from apparser.core import BaseUi -from apparser.debuggers import BaseDebugger, Debugger -from apparser.instructions.algorithms.base import BaseAlgorithm + +from apparser.instructions import BaseInstruction +from apparser.instructions.debuggers import BaseDebugger, Debugger +from apparser.instructions.ui.algorithms.base import BaseAlgorithm from apparser.instructions.utils import get_instruction_by_name @@ -23,39 +26,69 @@ def _check_instruction(instruction: tuple[str, list[Any]]) -> tuple[str, list[An class NamesAlgorithm(BaseAlgorithm): """Resolve and execute instructions by their registered names.""" - def __init__(self, instructions: list[tuple[str, list[Any]]], debugger: BaseDebugger | None = Debugger()): + def __init__(self, + instructions: list[tuple[str, list[Any]]], + attributes: list[Any], + debugger: BaseDebugger | bool = True): """Initialize a name-based instruction algorithm. :param instructions: Sequence of instruction names with their arguments. :type instructions: list[tuple[str, list[Any]]] - :param debugger: Debugger used to wrap instruction execution. - :type debugger: BaseDebugger | None + :param attributes: Attribute values matched to instruction parameters by type. + :type attributes: list[Any] + :param debugger: Debugger used to wrap instruction execution. If True, use Debugger. If False do not wrap instruction execution. + :type debugger: BaseDebugger | bool :raises TypeError: If ``debugger`` has an invalid type. """ - if debugger is not None and not isinstance(debugger, BaseDebugger): - raise TypeError("debugger must be BaseDebugger or None") - + if not isinstance(debugger, BaseDebugger) and not isinstance(debugger, bool): + raise TypeError(f"debugger must be a bool or BaseDebugger") + + if debugger == True: + debugger = Debugger() + + elif debugger == False: + debugger = None + + attributes.reverse() + self.__debugger = debugger self.__instructions = instructions + self.__attributes = attributes @property def id(self) -> int: - return 1003 + return 1502 + + def __form_args(self, instruction: BaseInstruction) -> dict[str, Any]: + result = {} + function_signature = inspect.signature(instruction.perform) + for arg in function_signature.parameters.values(): + for a in self.__attributes: + if arg.annotation is type(a): + result[arg.name] = a + return result def perform(self, ui: BaseUi, *args, **kwargs): ui.window.to_foreground() + + if self.__debugger is not None: + self.__debugger.clear_context() + for instruction_data in self.__instructions: instruction_name, instruction_args = _check_instruction(instruction_data) instruction_type = get_instruction_by_name(instruction_name) if instruction_type is None: raise ValueError(f"instruction with name {instruction_name} not found") - + instruction = instruction_type(*instruction_args) + + perform_kwargs = self.__form_args(instruction) + if self.__debugger is not None: - self.__debugger.try_perform(instruction, ui, *args, **kwargs) + self.__debugger.try_perform(instruction, **perform_kwargs) else: - instruction.perform(ui, *args, **kwargs) + instruction.perform(ui, **perform_kwargs) def add_instruction(self, instruction: tuple[str, list[Any]]): _check_instruction(instruction) diff --git a/apparser/instructions/algorithms/ocr.py b/apparser/instructions/ui/algorithms/ocr.py similarity index 68% rename from apparser/instructions/algorithms/ocr.py rename to apparser/instructions/ui/algorithms/ocr.py index 4863f42..55d373f 100644 --- a/apparser/instructions/algorithms/ocr.py +++ b/apparser/instructions/ui/algorithms/ocr.py @@ -1,9 +1,11 @@ from apparser.core import BaseUi -from apparser.debuggers import BaseDebugger, Debugger -from apparser.instructions.algorithms.base import BaseAlgorithm -from apparser.instructions import BaseInstruction + from apparser.text_readers import BaseTextReader, EasyOcrReader, ScreensController +from apparser.instructions.debuggers import BaseDebugger, Debugger +from apparser.instructions.ui.algorithms.base import BaseAlgorithm +from apparser.instructions.base import BaseInstruction + class OCRAlgorithm(BaseAlgorithm): """Run instruction sequences that depend on OCR data.""" @@ -11,15 +13,15 @@ class OCRAlgorithm(BaseAlgorithm): def __init__(self, instructions: list[BaseInstruction], text_reader: BaseTextReader | None = None, - debugger: BaseDebugger | None = Debugger()): + debugger: BaseDebugger | bool = True): """Initialize an OCR-oriented instruction algorithm. :param instructions: Instructions to execute in order. :type instructions: list[BaseInstruction] :param text_reader: Text reader used during execution. :type text_reader: BaseTextReader | None - :param debugger: Debugger used to wrap instruction execution. - :type debugger: BaseDebugger | None + :param debugger: Debugger used to wrap instruction execution. If True, use Debugger. If False do not wrap instruction execution. + :type debugger: BaseDebugger | bool :raises TypeError: If ``text_reader`` or ``debugger`` has an invalid type. """ if text_reader is None: @@ -28,8 +30,14 @@ def __init__(self, if not isinstance(text_reader, BaseTextReader): raise TypeError("text_reader must be BaseTextReader") - if debugger is not None and not isinstance(debugger, BaseDebugger): - raise TypeError("debugger must be BaseDebugger or None") + if not isinstance(debugger, BaseDebugger) and not isinstance(debugger, bool): + raise TypeError(f"debugger must be a bool or BaseDebugger") + + if debugger == True: + debugger = Debugger() + + elif debugger == False: + debugger = None self.__instructions = instructions self.__text_reader = text_reader @@ -37,16 +45,16 @@ def __init__(self, @property def id(self) -> int: - return 1005 + return 1503 def perform(self, ui: BaseUi, *args, **kwargs): if self.__debugger is not None: - self.__debugger.clear_contex() + self.__debugger.clear_context() ui.window.to_foreground() for instruction in self.__instructions: if not (isinstance(instruction, BaseInstruction)): - raise TypeError(f"{instruction} must be Instruction or AiInstruction") + raise TypeError(f"{instruction} must be BaseInstruction") if self.__debugger is not None: self.__debugger.try_perform(instruction, ui, self.__text_reader) @@ -55,7 +63,7 @@ def perform(self, ui: BaseUi, *args, **kwargs): def add_instruction(self, instruction: BaseInstruction): if not (isinstance(instruction, BaseInstruction)): - raise TypeError(f"{instruction} must be Instruction or AiInstruction") + raise TypeError(f"{instruction} must be BaseInstruction") self.__instructions.append(instruction) diff --git a/apparser/instructions/algorithms/speak.py b/apparser/instructions/ui/algorithms/speak.py similarity index 54% rename from apparser/instructions/algorithms/speak.py rename to apparser/instructions/ui/algorithms/speak.py index c485bf5..44ba0ea 100644 --- a/apparser/instructions/algorithms/speak.py +++ b/apparser/instructions/ui/algorithms/speak.py @@ -1,9 +1,12 @@ from apparser.core import BaseUi -from apparser.debuggers import BaseDebugger, Debugger -from apparser.instructions.algorithms.base import BaseAlgorithm -from apparser.instructions import BaseInstruction + from apparser.speakers import BaseSpeaker, ChatTTSSpeaker +from apparser.instructions.debuggers import BaseDebugger, Debugger +from apparser.instructions.ui.algorithms.base import BaseAlgorithm +from apparser.instructions.base import BaseInstruction +from apparser.instructions.speak.base import SpeakInstruction + class SpeakAlgorithm(BaseAlgorithm): """Run instruction sequences that depend on a speaker backend.""" @@ -11,15 +14,15 @@ class SpeakAlgorithm(BaseAlgorithm): def __init__(self, instructions: list[BaseInstruction], speaker: BaseSpeaker | None = None, - debugger: BaseDebugger | None = Debugger()): + debugger: BaseDebugger | bool = True): """Initialize a speech-oriented instruction algorithm. :param instructions: Instructions to execute in order. :type instructions: list[BaseInstruction] :param speaker: Speaker used during execution. :type speaker: BaseSpeaker | None - :param debugger: Debugger used to wrap instruction execution. - :type debugger: BaseDebugger | None + :param debugger: Debugger used to wrap instruction execution. If True, use Debugger. If False do not wrap instruction execution. + :type debugger: BaseDebugger | bool :raises TypeError: If ``speaker`` or ``debugger`` has an invalid type. """ if speaker is None: @@ -27,35 +30,47 @@ def __init__(self, if not isinstance(speaker, BaseSpeaker): raise TypeError("speaker must be BaseSpeaker") - - if debugger is not None and not isinstance(debugger, BaseDebugger): - raise TypeError("debugger must be BaseDebugger or None") + + if not isinstance(debugger, BaseDebugger) and not isinstance(debugger, bool): + raise TypeError(f"debugger must be a bool or BaseDebugger") + + if debugger == True: + debugger = Debugger() + + elif debugger == False: + debugger = None self.__instructions = instructions self.__speaker = speaker self.__debugger = debugger + def __perform(self, instruction: BaseInstruction, *args): + if self.__debugger is not None: + self.__debugger.try_perform(instruction, *args) + else: + instruction.perform(*args) + @property def id(self) -> int: - return 1004 + return 1504 def perform(self, ui: BaseUi, *args, **kwargs): if self.__debugger is not None: - self.__debugger.clear_contex() + self.__debugger.clear_context() ui.window.to_foreground() for instruction in self.__instructions: if not (isinstance(instruction, BaseInstruction)): - raise TypeError(f"{instruction} must be Instruction or AiInstruction") - - if self.__debugger is not None: - self.__debugger.try_perform(instruction, ui, self.__speaker) - else: - instruction.perform(ui, self.__speaker) + raise TypeError(f"{instruction} must be BaseInstruction") + + if isinstance(instruction, SpeakInstruction): + self.__perform(instruction, self.__speaker) + elif isinstance(instruction, BaseInstruction): + self.__perform(instruction, ui) def add_instruction(self, instruction: BaseInstruction): if not (isinstance(instruction, BaseInstruction)): - raise TypeError(f"{instruction} must be Instruction or AiInstruction") + raise TypeError(f"{instruction} must be BaseInstruction") self.__instructions.append(instruction) diff --git a/apparser/instructions/ui/algorithms/unique.py b/apparser/instructions/ui/algorithms/unique.py new file mode 100644 index 0000000..0562925 --- /dev/null +++ b/apparser/instructions/ui/algorithms/unique.py @@ -0,0 +1,80 @@ +from typing import Any +import inspect + +from apparser.core import BaseUi + +from apparser.instructions.debuggers import BaseDebugger, Debugger +from apparser.instructions.ui.algorithms.base import BaseAlgorithm +from apparser.instructions.base import BaseInstruction + + +class UniqueAlgorithm(BaseAlgorithm): + """Run instructions with arguments resolved from unique attribute types.""" + + def __init__(self, + instructions: list[BaseInstruction], + attributes: list[Any], + debugger: BaseDebugger | bool = True): + """Initialize an algorithm that injects attributes into instructions. + + :param instructions: Instructions to execute in order. + :type instructions: list[BaseInstruction] + :param attributes: Attribute values matched to instruction parameters by type. + :type attributes: list[Any] + :param debugger: Debugger used to wrap instruction execution. If True, use Debugger. If False do not wrap instruction execution. + :type debugger: BaseDebugger | bool + :raises TypeError: If ``debugger`` has an invalid type. + """ + if not isinstance(debugger, BaseDebugger) and not isinstance(debugger, bool): + raise TypeError(f"debugger must be a bool or BaseDebugger") + + if debugger == True: + debugger = Debugger() + + elif debugger == False: + debugger = None + + attributes.reverse() + + self.__instructions = instructions + self.__attributes = attributes + self.__debugger = debugger + + def __form_args(self, instruction: BaseInstruction) -> dict[str, Any]: + result = {} + function_signature = inspect.signature(instruction.perform) + for arg in function_signature.parameters.values(): + for a in self.__attributes: + if arg.annotation is type(a): + result[arg.name] = a + return result + + @property + def id(self) -> int: + return 1505 + + def perform(self, ui: BaseUi, *args, **kwargs): + if self.__debugger is not None: + self.__debugger.clear_context() + + ui.window.to_foreground() + for instruction in self.__instructions: + if not (isinstance(instruction, BaseInstruction)): + raise TypeError(f"{instruction} must be BaseInstruction") + + instruction_kwargs = self.__form_args(instruction) + + if self.__debugger is not None: + self.__debugger.try_perform(instruction, ui, **instruction_kwargs) + else: + instruction.perform(ui, **instruction_kwargs) + + def add_instruction(self, instruction: BaseInstruction): + if not (isinstance(instruction, BaseInstruction)): + raise TypeError(f"{instruction} must be BaseInstruction") + + self.__instructions.append(instruction) + + @property + def instructions(self) -> list[BaseInstruction]: + return self.__instructions diff --git a/apparser/instructions/ui/click.py b/apparser/instructions/ui/click.py index 830bd09..6356cb1 100644 --- a/apparser/instructions/ui/click.py +++ b/apparser/instructions/ui/click.py @@ -1,11 +1,12 @@ from apparser.core import BaseUi from apparser.geometry import Point, RelativelyPoint +from apparser.key_codes import RightClick, LeftClick + +from apparser.movers import DefaultMover, BaseMover + from apparser.instructions.ui.base import UiInstruction from apparser.instructions.default import MouseClick from apparser.instructions.ui.mouse_move import MouseMove -from apparser.key_codes.mouse_keys import RightClick, LeftClick -from apparser.movers import DefaultMover -from apparser.movers.base import BaseMover class MouseClickTo(UiInstruction): @@ -33,7 +34,7 @@ def __init__(self, coordinates: Point | RelativelyPoint, @property def id(self) -> int: - return 105 + return 1005 def perform(self, ui: BaseUi, *args, **kwargs): self.__move.perform(ui) diff --git a/apparser/instructions/ui/mouse_move.py b/apparser/instructions/ui/mouse_move.py index 81d1728..f77e20a 100644 --- a/apparser/instructions/ui/mouse_move.py +++ b/apparser/instructions/ui/mouse_move.py @@ -1,8 +1,9 @@ from apparser.core import BaseUi from apparser.geometry import Point, RelativelyPoint + +from apparser.movers import DefaultMover, BaseMover + from apparser.instructions.ui.base import UiInstruction -from apparser.movers import DefaultMover -from apparser.movers.base import BaseMover class MouseMove(UiInstruction): @@ -19,18 +20,18 @@ def __init__(self, :type mover: BaseMover :raises TypeError: If ``coordinates`` or ``mover`` has an invalid type. """ - if not (isinstance(coordinates, Point) or isinstance(coordinates, RelativelyPoint)): + if not (isinstance(coordinates, Point) or isinstance(coordinates, RelativelyPoint)): raise TypeError('coordinates must be Point or RelativelyPoint') if not isinstance(mover, BaseMover): - raise TypeError('mover must be Mover') + raise TypeError('mover must be BaseMover') self.__mover = mover self.__coordinates = coordinates @property def id(self) -> int: - return 104 + return 1004 def perform(self, ui: BaseUi, *args, **kwargs): coordinates = ui.point_to_global(self.__coordinates) diff --git a/apparser/instructions/ui/move_window.py b/apparser/instructions/ui/move_window.py index 56b2a24..43b264a 100644 --- a/apparser/instructions/ui/move_window.py +++ b/apparser/instructions/ui/move_window.py @@ -1,5 +1,6 @@ from apparser.core import BaseUi from apparser.geometry import Point + from apparser.instructions.ui.base import UiInstruction @@ -20,7 +21,7 @@ def __init__(self, position: Point): @property def id(self) -> int: - return 102 + return 1002 def perform(self, ui: BaseUi, *args, **kwargs): ui.window.move(self.__position) diff --git a/apparser/instructions/ui/resize_window.py b/apparser/instructions/ui/resize_window.py index 8584e05..42ebf9c 100644 --- a/apparser/instructions/ui/resize_window.py +++ b/apparser/instructions/ui/resize_window.py @@ -1,5 +1,6 @@ from apparser.core import BaseUi from apparser.geometry import Size + from apparser.instructions.ui.base import UiInstruction @@ -20,7 +21,7 @@ def __init__(self, size: Size): @property def id(self) -> int: - return 103 + return 1003 def perform(self, ui: BaseUi, *args, **kwargs): ui.window.resize(self.__size) diff --git a/apparser/instructions/ui/to_window.py b/apparser/instructions/ui/to_window.py index 9d712d7..18733a6 100644 --- a/apparser/instructions/ui/to_window.py +++ b/apparser/instructions/ui/to_window.py @@ -1,4 +1,5 @@ from apparser.core import BaseUi + from apparser.instructions.ui.base import UiInstruction @@ -7,7 +8,7 @@ class WindowToBackground(UiInstruction): @property def id(self) -> int: - return 101 + return 1001 def perform(self, ui: BaseUi, *args, **kwargs): ui.window.to_background() @@ -18,7 +19,7 @@ class WindowToForeground(UiInstruction): @property def id(self) -> int: - return 100 + return 1000 def perform(self, ui: BaseUi, *args, **kwargs): ui.window.to_foreground() diff --git a/apparser/instructions/utils/__init__.py b/apparser/instructions/utils/__init__.py index 24ec8ea..52da736 100644 --- a/apparser/instructions/utils/__init__.py +++ b/apparser/instructions/utils/__init__.py @@ -1,5 +1,7 @@ from apparser.instructions.utils.get_by_id import get_instruction_by_id from apparser.instructions.utils.get_by_name import get_instruction_by_name +from apparser.instructions.utils.get_all_instructions import get_all_instructions __all__ = ["get_instruction_by_id", - "get_instruction_by_name"] + "get_instruction_by_name", + "get_all_instructions"] diff --git a/apparser/instructions/utils/_get_all_instructions.py b/apparser/instructions/utils/get_all_instructions.py similarity index 92% rename from apparser/instructions/utils/_get_all_instructions.py rename to apparser/instructions/utils/get_all_instructions.py index 92e7f5d..f82b6ac 100644 --- a/apparser/instructions/utils/_get_all_instructions.py +++ b/apparser/instructions/utils/get_all_instructions.py @@ -4,7 +4,7 @@ from apparser.instructions.base import BaseInstruction -def _get_all_instructions() -> list[type[BaseInstruction]]: +def get_all_instructions() -> list[type[BaseInstruction]]: """Collect all concrete instruction classes from instruction modules. :return: Collected concrete instruction classes. diff --git a/apparser/instructions/utils/get_by_id.py b/apparser/instructions/utils/get_by_id.py index 9adf338..749770d 100644 --- a/apparser/instructions/utils/get_by_id.py +++ b/apparser/instructions/utils/get_by_id.py @@ -1,4 +1,4 @@ -from apparser.instructions.utils._get_all_instructions import _get_all_instructions +from apparser.instructions.utils.get_all_instructions import get_all_instructions from apparser.exceptions import InstructionWithIdNotFoundException @@ -14,12 +14,12 @@ def get_instruction_by_id(instruction_id: int): :raises InstructionWithIdNotFoundException: If no matching instruction is found. """ if not isinstance(instruction_id, int): - raise TypeError("id must be an integer") + raise TypeError("instruction_id must be an integer") if instruction_id < 0: - raise ValueError("id must be >= 0") + raise ValueError("instruction_id must be >= 0") - for instruction in _get_all_instructions(): + for instruction in get_all_instructions(): if instruction.id.fget(None) == instruction_id: return instruction diff --git a/apparser/instructions/utils/get_by_name.py b/apparser/instructions/utils/get_by_name.py index 7a84a55..0fb39fd 100644 --- a/apparser/instructions/utils/get_by_name.py +++ b/apparser/instructions/utils/get_by_name.py @@ -1,4 +1,4 @@ -from apparser.instructions.utils._get_all_instructions import _get_all_instructions +from apparser.instructions.utils.get_all_instructions import get_all_instructions from apparser.exceptions import InstructionWithNameNotFoundException @@ -14,12 +14,12 @@ def get_instruction_by_name(instruction_name: str): :raises InstructionWithNameNotFoundException: If no matching instruction is found. """ if not isinstance(instruction_name, str): - raise TypeError("id must be an str") + raise TypeError("instruction_name must be a string") if len(instruction_name) <= 0: - raise ValueError("name is empty") + raise ValueError("instruction_name cannot be empty") - for instruction in _get_all_instructions(): + for instruction in get_all_instructions(): if instruction.__name__ == instruction_name: return instruction diff --git a/apparser/key_codes/__init__.py b/apparser/key_codes/__init__.py index 0c00a45..60f7746 100644 --- a/apparser/key_codes/__init__.py +++ b/apparser/key_codes/__init__.py @@ -1,8 +1,8 @@ -from apparser.key_codes.key_code import KeyboardKeyCode +from apparser.key_codes.base import BaseKeyCode from apparser.key_codes.keyboard_keys import Enter, Control, Alt, Delete from apparser.key_codes.mouse_keys import RightClick, LeftClick -__all__ = ["KeyboardKeyCode", +__all__ = ["BaseKeyCode", "Enter", "Control", "RightClick", diff --git a/apparser/key_codes/key_code.py b/apparser/key_codes/key_code.py deleted file mode 100644 index 9c66cbe..0000000 --- a/apparser/key_codes/key_code.py +++ /dev/null @@ -1,25 +0,0 @@ -from apparser.key_codes.base import BaseKeyCode - - -class KeyboardKeyCode(BaseKeyCode): - """Store a custom keyboard key code.""" - - def __init__(self, key: str): - """Initialize a custom keyboard key code. - - :param key: String representation of the key code. - :type key: str - """ - self.__key = key - self.__check_keys() - - def __check_keys(self): - pass - - def __str__(self) -> str: - """Return the stored key code string. - - :return: Stored key code. - :rtype: str - """ - return self.__key diff --git a/apparser/movers/__init__.py b/apparser/movers/__init__.py index 484469c..eba3b7c 100644 --- a/apparser/movers/__init__.py +++ b/apparser/movers/__init__.py @@ -1,5 +1,7 @@ from apparser.movers.default import DefaultMover from apparser.movers.math_antirobot import AntiRobotMover +from apparser.movers.base import BaseMover __all__ = ["DefaultMover", - "AntiRobotMover"] + "AntiRobotMover", + "BaseMover"] diff --git a/apparser/movers/default.py b/apparser/movers/default.py index d300dad..3c65f10 100644 --- a/apparser/movers/default.py +++ b/apparser/movers/default.py @@ -1,4 +1,4 @@ -import mouse +import pyautogui from apparser.geometry import Point from apparser.movers.base import BaseMover @@ -8,27 +8,20 @@ class DefaultMover(BaseMover): """Move the cursor directly by using the mouse backend.""" def __init__(self, - duration: float = 0, - absolute: bool = True): + duration: float = 0): """Initialize a direct mouse mover. :param duration: Cursor movement duration. :type duration: float - :param absolute: Whether coordinates are absolute. - :type absolute: bool :raises TypeError: If any argument has an invalid type. :raises ValueError: If ``duration`` is negative. """ if not (isinstance(duration, float) or isinstance(duration, int)): raise TypeError("Duration must be a number") - if not isinstance(absolute, bool): - raise TypeError("Absolute must be a boolean") - if duration < 0: - raise ValueError("Duration must be a >= 0") + raise ValueError("Duration must be >= 0") - self.__absolute = absolute self.__duration = duration def move(self, position: Point): @@ -37,6 +30,5 @@ def move(self, position: Point): :param position: Target cursor position. :type position: Point """ - mouse.move(position.x, position.y, - absolute=self.__absolute, + pyautogui.moveTo(position.x, position.y, duration=self.__duration) diff --git a/apparser/movers/math_antirobot.py b/apparser/movers/math_antirobot.py index c33ce29..5ec0afe 100644 --- a/apparser/movers/math_antirobot.py +++ b/apparser/movers/math_antirobot.py @@ -1,10 +1,10 @@ import random from typing import Generator, Callable -import mouse -from appwindows.geometry import Point +import pyautogui + +from apparser.geometry import distance, Point -from apparser.geometry import distance from apparser.movers.base import BaseMover @@ -44,7 +44,7 @@ def __init__(self, min_time: float = 0.1, max_time: float = 2, min_shift: float raise ValueError('min_time must be less than max_time') if min_time < 0: - raise ValueError('min_time must be greater than 0') + raise ValueError('min_time must be >= 0') self.__min_time = min_time self.__max_time = max_time @@ -100,7 +100,7 @@ class AntiRobotMover(BaseMover): """Move the cursor by using generated multi-step paths.""" def __init__(self, - move_generator: Callable[[Point, Point], Generator[tuple[Point, float], None, None]] = DefaultMoveGenerator(0.05, 0.1)): + move_generator: Callable[[Point, Point], Generator[tuple[Point, float], None, None]] = DefaultMoveGenerator(0.3, 0.6)): """Initialize a mover that follows generated paths. :param move_generator: Callable that yields cursor positions and durations. @@ -118,6 +118,6 @@ def move(self, position: Point): if not isinstance(position, Point): raise TypeError('position must be Point') - current_position = Point(*mouse.get_position()) + current_position = Point(*pyautogui.position()) for i, t in self.__move_generator(current_position, position): - mouse.move(i.x, i.y, duration=t) + pyautogui.moveTo(i.x, i.y, duration=t) diff --git a/apparser/speakers/__init__.py b/apparser/speakers/__init__.py index 693d771..6375c5e 100644 --- a/apparser/speakers/__init__.py +++ b/apparser/speakers/__init__.py @@ -1,5 +1,5 @@ from apparser.speakers.base import BaseSpeaker -from apparser.speakers.chattts import ChatTTSSpeaker +from apparser.speakers.chat_tts import ChatTTSSpeaker from apparser.speakers.torch import TorchSpeaker __all__ = ["BaseSpeaker", "TorchSpeaker", "ChatTTSSpeaker"] diff --git a/apparser/speakers/base.py b/apparser/speakers/base.py index 1c0c876..7d72364 100644 --- a/apparser/speakers/base.py +++ b/apparser/speakers/base.py @@ -7,12 +7,12 @@ class BaseSpeaker(abc.ABC): """Define the common interface for speech synthesis backends.""" @abc.abstractmethod - def speak(self, text: str) -> numpy.ndarray: + def speak(self, text: str) -> tuple[numpy.ndarray, int]: """Convert text into audio data. :param text: Text to synthesize. :type text: str - :return: Generated audio samples. - :rtype: numpy.ndarray + :return: Generated audio samples and bitrate. + :rtype: tuple[numpy.ndarray, int] """ pass diff --git a/apparser/speakers/chattts.py b/apparser/speakers/chat_tts.py similarity index 56% rename from apparser/speakers/chattts.py rename to apparser/speakers/chat_tts.py index 42d2183..2379f17 100644 --- a/apparser/speakers/chattts.py +++ b/apparser/speakers/chat_tts.py @@ -6,20 +6,21 @@ class ChatTTSSpeaker(BaseSpeaker): - """Generate speech by using ChatTTS.""" - - def __init__(self, - speaker: str | None = None, - source: str = "local", - force_redownload: bool = False, - compile: bool = False, - custom_path: str | None = None, - device: str | object | None = None, - coef: str | None = None, - use_flash_attn: bool = False, - use_vllm: bool = False, - experimental: bool = False, - enable_cache: bool = True): + def __init__( + self, + speaker: str | None = None, + source: str = "huggingface", + force_redownload: bool = False, + compile: bool = False, + custom_path: str | None = None, + device: str | object | None = None, + coef: str | None = None, + use_flash_attn: bool = False, + use_vllm: bool = False, + experimental: bool = False, + enable_cache: bool = True, + sample_rate: int = 24000, + ) -> None: """Initialize a ChatTTS speaker backend. :param speaker: Speaker embedding value used for synthesis. @@ -44,37 +45,61 @@ def __init__(self, :type experimental: bool :param enable_cache: Whether ChatTTS cache should be enabled. :type enable_cache: bool + :param sample_rate: Output bitrate for generated audio. + :type sample_rate: int """ self.__chattts = importlib.import_module("ChatTTS") self.__torch = importlib.import_module("torch") self.__chat = self.__chattts.Chat() - if isinstance(device, str): - device = self.__torch.device(device) - self.__chat.load( + self.__sample_rate = sample_rate + chat_device = self.__get_device(device) + loaded = self.__chat.load( source=source, force_redownload=force_redownload, compile=compile, custom_path=custom_path, - device=device, + device=chat_device, coef=coef, use_flash_attn=use_flash_attn, use_vllm=use_vllm, experimental=experimental, enable_cache=enable_cache, ) - self.__speaker = speaker - if self.__speaker is None: - self.__speaker = self.__chat.sample_random_speaker() + if loaded is False: + raise RuntimeError("ChatTTS model loading failed.") + self.__speaker = self.__resolve_speaker(speaker) + + def __get_device(self, device: str | object | None) -> object | None: + if isinstance(device, str): + return self.__torch.device(device) + return device - def speak(self, text: str, **settings) -> numpy.ndarray: + def __resolve_speaker(self, speaker: str | None) -> str | None: + if speaker is not None: + return speaker + sample_random_speaker = getattr( + self.__chat, + "sample_random_speaker", + None, + ) + if not callable(sample_random_speaker): + return None + try: + return sample_random_speaker() + except AttributeError as error: + raise RuntimeError( + "ChatTTS speaker initialization failed." + ) from error + + def speak(self, text: str, **settings: object) -> tuple[numpy.ndarray, int]: """Convert text into audio data. :param text: Text to synthesize. :type text: str :param settings: Additional ChatTTS inference settings. :type settings: dict[str, object] - :return: Generated audio samples. - :rtype: numpy.ndarray + :return: Generated audio samples and bitrate. + :rtype: tuple[numpy.ndarray, int] """ speaker = settings.pop("speaker", self.__speaker) params_infer_code = settings.pop("params_infer_code", None) @@ -90,7 +115,10 @@ def speak(self, text: str, **settings) -> numpy.ndarray: **settings, ) if len(audio) == 0: - return numpy.array([], dtype=numpy.float32) + return numpy.array([], dtype=numpy.float32), self.__sample_rate if len(audio) == 1: - return numpy.asarray(audio[0]) - return numpy.concatenate([numpy.asarray(i) for i in audio]) + return numpy.asarray(audio[0]), self.__sample_rate + return ( + numpy.concatenate([numpy.asarray(item) for item in audio]), + self.__sample_rate, + ) diff --git a/apparser/speakers/torch.py b/apparser/speakers/torch.py index 5d965c2..d83b922 100644 --- a/apparser/speakers/torch.py +++ b/apparser/speakers/torch.py @@ -8,18 +8,20 @@ class TorchSpeaker(BaseSpeaker): """Generate speech by using a Torch-backed Silero model.""" - def __init__(self, - language: str = "ru", - speaker_model: str = "v5_ru", - speaker: str = "xenia", - sample_rate: int = 48000, - device: str | object = "cpu", - repo_or_dir: str = "snakers4/silero-models", - model: str = "silero_tts", - source: str = "github", - trust_repo: bool | str | None = None, - skip_validation: bool | None = None, - **settings): + def __init__( + self, + language: str = "ru", + speaker_model: str = "v5_5_ru", + speaker: str = "xenia", + sample_rate: int = 48000, + device: str | object = "cpu", + repo_or_dir: str = "snakers4/silero-models", + model: str = "silero_tts", + source: str = "github", + trust_repo: bool | str | None = None, + skip_validation: bool | None = None, + **settings: object, + ) -> None: """Initialize a Torch speaker backend. :param language: Language code for the loaded model. @@ -28,7 +30,7 @@ def __init__(self, :type speaker_model: str :param speaker: Speaker name used for synthesis. :type speaker: str - :param sample_rate: Output sample rate. + :param sample_rate: Output bitrate. :type sample_rate: int :param device: Torch device used for inference. :type device: str | object @@ -48,7 +50,7 @@ def __init__(self, self.__speaker = speaker self.__sample_rate = sample_rate self.__torch = importlib.import_module("torch") - hub_settings = { + hub_settings: dict[str, object] = { "repo_or_dir": repo_or_dir, "model": model, "language": language, @@ -66,15 +68,15 @@ def __init__(self, self.__device = self.__torch.device(device) self.__model.to(self.__device) - def speak(self, text: str, **settings) -> numpy.ndarray: + def speak(self, text: str, **settings: object) -> tuple[numpy.ndarray, int]: """Convert text into audio data. :param text: Text to synthesize. :type text: str :param settings: Additional synthesis settings. :type settings: dict[str, object] - :return: Generated audio samples. - :rtype: numpy.ndarray + :return: Generated audio samples and bitrate. + :rtype: tuple[numpy.ndarray, int] """ audio = self.__model.apply_tts( text=text, @@ -82,4 +84,4 @@ def speak(self, text: str, **settings) -> numpy.ndarray: sample_rate=self.__sample_rate, **settings, ) - return audio.detach().cpu().numpy() + return audio.detach().cpu().numpy(), self.__sample_rate diff --git a/apparser/text_readers/__init__.py b/apparser/text_readers/__init__.py index 1de3cf6..54e8e89 100644 --- a/apparser/text_readers/__init__.py +++ b/apparser/text_readers/__init__.py @@ -2,13 +2,13 @@ from apparser.text_readers.screens_controller import ScreensController from apparser.text_readers.models.text_data import TextData from apparser.text_readers.easy_ocr import EasyOcrReader -from apparser.text_readers.paddle_ocr import PaddleTextReader +from apparser.text_readers.paddle import PaddleTextReader from apparser.text_readers.white_black_reader import WhiteBlackReader __all__ = ["EasyOcrReader", - "PaddleTextReader", "ScreensController", "BaseTextReader", "WhiteBlackReader", + "PaddleTextReader", "TextData"] diff --git a/apparser/text_readers/paddle.py b/apparser/text_readers/paddle.py new file mode 100644 index 0000000..a87eb23 --- /dev/null +++ b/apparser/text_readers/paddle.py @@ -0,0 +1,126 @@ +import importlib +from typing import Any +import numpy + +from apparser.geometry import Point +from apparser.text_readers.base import BaseTextReader +from apparser.text_readers.models.text_data import TextData + + +def _build_box_points( + left: int, + top: int, + right: int, + bottom: int, +) -> list[Point]: + return [ + Point(left, top), + Point(right, top), + Point(right, bottom), + Point(left, bottom), + ] + + +def _parse_points_geometry(geometry: Any) -> list[Point]: + array = numpy.asarray(geometry) + + if array.ndim == 1 and array.size == 4: + left, top, right, bottom = array[:4] + return _build_box_points( + int(left), + int(top), + int(right), + int(bottom), + ) + + if array.ndim == 1 and array.size >= 8 and array.size % 2 == 0: + array = array.reshape(-1, 2) + + if array.ndim >= 2 and array.shape[-1] >= 2: + x_coordinates = array[..., 0].reshape(-1) + y_coordinates = array[..., 1].reshape(-1) + + if len(x_coordinates) == 0 or len(y_coordinates) == 0: + return [] + + return _build_box_points( + int(x_coordinates.min()), + int(y_coordinates.min()), + int(x_coordinates.max()), + int(y_coordinates.max()), + ) + + return [] + + +def _parse_predict_result(predicted: list[Any]) -> list[TextData]: + returned: list[TextData] = [] + + for item in predicted: + if hasattr(item, "res"): + item = item.res + + if not isinstance(item, dict): + continue + + texts = item.get("rec_texts") + polygons = item.get("rec_polys") + + if polygons is None: + polygons = item.get("dt_polys") + + boxes = item.get("rec_boxes") + geometries = polygons if polygons is not None else boxes + + if texts is None or geometries is None: + continue + + for index in range(min(len(texts), len(geometries))): + points = _parse_points_geometry(geometries[index]) + if len(points) < 4: + continue + returned.append(TextData(texts[index], points)) + + return returned + + +def _build_default_settings( + settings: dict[str, Any], + enable_mkldnn: bool, +) -> dict[str, Any]: + default_settings = { + "enable_mkldnn": enable_mkldnn, + "use_doc_orientation_classify": False, + "use_doc_unwarping": False, + "use_textline_orientation": False, + } + default_settings.update(settings) + return default_settings + + +class PaddleTextReader(BaseTextReader): + def __init__( + self, + lang: str = "en", + enable_mkldnn: bool = False, + **settings: Any, + ) -> None: + self.__lang = lang + self.__enable_mkldnn = enable_mkldnn + self.__settings = _build_default_settings( + settings, + enable_mkldnn, + ) + self.__reader = self.__create_reader(self.__settings) + + def read_image( + self, + image: numpy.ndarray, + **settings: Any, + ) -> list[TextData]: + predicted = self.__reader.predict(image, **settings) + return _parse_predict_result(predicted) + + def __create_reader(self, settings: dict[str, Any]) -> Any: + paddleocr = importlib.import_module("paddleocr") + return paddleocr.PaddleOCR(lang=self.__lang, **settings) diff --git a/apparser/text_readers/paddle_ocr.py b/apparser/text_readers/paddle_ocr.py deleted file mode 100644 index e1dcfe5..0000000 --- a/apparser/text_readers/paddle_ocr.py +++ /dev/null @@ -1,102 +0,0 @@ -import importlib - -import numpy - -from apparser.text_readers.base import BaseTextReader -from apparser.text_readers.models.text_data import TextData - -from apparser.geometry import Point - - -def _parse_predict_result(predicted) -> list[TextData]: - """Convert PaddleOCR ``predict`` results into text data objects. - - :param predicted: Raw PaddleOCR prediction output. - :return: Parsed text data objects. - :rtype: list[TextData] - """ - returned = [] - for i in predicted: - if hasattr(i, "res"): - i = i.res - if not isinstance(i, dict): - continue - texts = i.get("rec_texts") - polygons = i.get("rec_polys") - if polygons is None: - polygons = i.get("dt_polys") - if texts is None or polygons is None: - continue - for j in range(min(len(texts), len(polygons))): - points = [Point(int(k[0]), int(k[1])) for k in polygons[j]] - text_data = TextData(texts[j], points) - returned.append(text_data) - return returned - - -def _is_ocr_line(data: object) -> bool: - """Check whether a value matches the expected OCR line shape. - - :param data: Value to validate. - :type data: object - :return: ``True`` when the value matches an OCR line structure. - :rtype: bool - """ - return (isinstance(data, (list, tuple)) - and len(data) >= 2 - and isinstance(data[0], (list, tuple, numpy.ndarray)) - and isinstance(data[1], (list, tuple)) - and len(data[1]) >= 1) - - -def _parse_ocr_result(predicted) -> list[TextData]: - """Convert PaddleOCR ``ocr`` results into text data objects. - - :param predicted: Raw PaddleOCR OCR output. - :return: Parsed text data objects. - :rtype: list[TextData] - """ - returned = [] - if (len(predicted) == 1 - and isinstance(predicted[0], list) - and len(predicted[0]) > 0 - and _is_ocr_line(predicted[0][0])): - predicted = predicted[0] - for i in predicted: - if i is None or not _is_ocr_line(i): - continue - points = [Point(int(j[0]), int(j[1])) for j in i[0]] - text_data = TextData(i[1][0], points) - returned.append(text_data) - return returned - - -class PaddleTextReader(BaseTextReader): - """Read text from images by using PaddleOCR.""" - - def __init__(self, lang: str = "en", **settings): - """Initialize a PaddleOCR-backed text reader. - - :param lang: OCR language code. - :type lang: str - :param settings: Additional PaddleOCR settings. - :type settings: dict[str, object] - """ - paddleocr = importlib.import_module("paddleocr") - self.__reader = paddleocr.PaddleOCR(lang=lang, **settings) - - def read_image(self, image: numpy.ndarray, **settings) -> list[TextData]: - """Read text data from an image. - - :param image: Image data to process. - :type image: numpy.ndarray - :param settings: Additional PaddleOCR read settings. - :type settings: dict[str, object] - :return: Detected text data. - :rtype: list[TextData] - """ - if hasattr(self.__reader, "predict"): - predicted = self.__reader.predict(image, **settings) - return _parse_predict_result(predicted) - predicted = self.__reader.ocr(image, **settings) - return _parse_ocr_result(predicted) diff --git a/docs/_static/apparser.svg b/docs/_static/apparser.svg new file mode 100644 index 0000000..7dde869 --- /dev/null +++ b/docs/_static/apparser.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/docs/_static/custom.css b/docs/_static/custom.css new file mode 100644 index 0000000..c5623e9 --- /dev/null +++ b/docs/_static/custom.css @@ -0,0 +1,12 @@ +.sy-head-brand { + display: inline-flex; + align-items: center; + gap: 0.75rem; +} + +.sy-head-brand::after { + content: "apparser"; + color: var(--sy-c-heading); + font-weight: 700; + line-height: 1; +} \ No newline at end of file diff --git a/docs/_static/hello_world.gif b/docs/_static/hello_world.gif new file mode 100644 index 0000000..ac67d5b Binary files /dev/null and b/docs/_static/hello_world.gif differ diff --git a/docs/_static/ocr.gif b/docs/_static/ocr.gif new file mode 100644 index 0000000..41100f5 Binary files /dev/null and b/docs/_static/ocr.gif differ diff --git a/docs/api/core/WindowByDisplayUi.rst b/docs/api/core/WindowByDisplayUi.rst new file mode 100644 index 0000000..89a4098 --- /dev/null +++ b/docs/api/core/WindowByDisplayUi.rst @@ -0,0 +1,10 @@ +WindowByDisplayUi +======================= + +.. currentmodule:: apparser.core + +.. autoclass:: WindowByDisplayUi + :members: + :undoc-members: + :show-inheritance: + :member-order: bysource diff --git a/docs/api/core/index.rst b/docs/api/core/index.rst index be7a1d8..0e6bfaa 100644 --- a/docs/api/core/index.rst +++ b/docs/api/core/index.rst @@ -3,9 +3,11 @@ core .. toctree:: :maxdepth: 1 + :titlesonly: App BaseUi DesktopUi CoordinatesUi WindowUi + WindowByDisplayUi \ No newline at end of file diff --git a/docs/api/cv/events/CvEvent.rst b/docs/api/cv/events/CvEvent.rst deleted file mode 100644 index 27b4223..0000000 --- a/docs/api/cv/events/CvEvent.rst +++ /dev/null @@ -1,10 +0,0 @@ -CvEvent -======= - -.. currentmodule:: apparser.cv.events - -.. autoclass:: CvEvent - :members: - :undoc-members: - :show-inheritance: - :member-order: bysource diff --git a/docs/api/cv/events/Detected.rst b/docs/api/cv/events/Detected.rst deleted file mode 100644 index 956341c..0000000 --- a/docs/api/cv/events/Detected.rst +++ /dev/null @@ -1,10 +0,0 @@ -Detected -======== - -.. currentmodule:: apparser.cv.events - -.. autoclass:: Detected - :members: - :undoc-members: - :show-inheritance: - :member-order: bysource diff --git a/docs/api/cv/events/Moved.rst b/docs/api/cv/events/Moved.rst deleted file mode 100644 index 9695b3d..0000000 --- a/docs/api/cv/events/Moved.rst +++ /dev/null @@ -1,10 +0,0 @@ -Moved -===== - -.. currentmodule:: apparser.cv.events - -.. autoclass:: Moved - :members: - :undoc-members: - :show-inheritance: - :member-order: bysource diff --git a/docs/api/cv/events/Resized.rst b/docs/api/cv/events/Resized.rst deleted file mode 100644 index 446f0fd..0000000 --- a/docs/api/cv/events/Resized.rst +++ /dev/null @@ -1,10 +0,0 @@ -Resized -======= - -.. currentmodule:: apparser.cv.events - -.. autoclass:: Resized - :members: - :undoc-members: - :show-inheritance: - :member-order: bysource diff --git a/docs/api/cv/events/index.rst b/docs/api/cv/events/index.rst index 631bf76..3a73949 100644 --- a/docs/api/cv/events/index.rst +++ b/docs/api/cv/events/index.rst @@ -1,14 +1,35 @@ events ================== -API ---- -.. toctree:: - :maxdepth: 1 +.. currentmodule:: apparser.cv.events - CvEvent - Moved - Detected - Resized - UnDetected +.. autoclass:: CvEvent + :members: + :undoc-members: + :show-inheritance: + :member-order: bysource + +.. autoclass:: Detected + :members: + :undoc-members: + :show-inheritance: + :member-order: bysource + +.. autoclass:: UnDetected + :members: + :undoc-members: + :show-inheritance: + :member-order: bysource + +.. autoclass:: Moved + :members: + :undoc-members: + :show-inheritance: + :member-order: bysource + +.. autoclass:: Resized + :members: + :undoc-members: + :show-inheritance: + :member-order: bysource diff --git a/docs/api/cv/handlers/index.rst b/docs/api/cv/handlers/index.rst index da596ba..f8c95e9 100644 --- a/docs/api/cv/handlers/index.rst +++ b/docs/api/cv/handlers/index.rst @@ -6,6 +6,7 @@ API .. toctree:: :maxdepth: 1 + :titlesonly: CvHandlers DefaultHandlers diff --git a/docs/api/cv/index.rst b/docs/api/cv/index.rst index 1c1925d..016c008 100644 --- a/docs/api/cv/index.rst +++ b/docs/api/cv/index.rst @@ -3,10 +3,11 @@ cv .. toctree:: :maxdepth: 1 + :titlesonly: - events/index handlers/index models/index processes/index readers/index utils/index + events/index diff --git a/docs/api/cv/models/CvAllData.rst b/docs/api/cv/models/CvAllData.rst index c26be4d..e03dc69 100644 --- a/docs/api/cv/models/CvAllData.rst +++ b/docs/api/cv/models/CvAllData.rst @@ -4,7 +4,6 @@ CvAllData .. currentmodule:: apparser.cv.models .. autoclass:: CvAllData - :no-index: :members: :undoc-members: :show-inheritance: diff --git a/docs/api/cv/models/CvBox.rst b/docs/api/cv/models/CvBox.rst index b3b5ccc..9a1bbc9 100644 --- a/docs/api/cv/models/CvBox.rst +++ b/docs/api/cv/models/CvBox.rst @@ -4,7 +4,6 @@ CvBox .. currentmodule:: apparser.cv.models .. autoclass:: CvBox - :no-index: :members: :undoc-members: :show-inheritance: diff --git a/docs/api/cv/models/CvChangeData.rst b/docs/api/cv/models/CvChangeData.rst index 2feffa5..aecab49 100644 --- a/docs/api/cv/models/CvChangeData.rst +++ b/docs/api/cv/models/CvChangeData.rst @@ -4,7 +4,6 @@ CvChangeData .. currentmodule:: apparser.cv.models .. autoclass:: CvChangeData - :no-index: :members: :undoc-members: :show-inheritance: diff --git a/docs/api/cv/models/index.rst b/docs/api/cv/models/index.rst index 3924256..6ae2cbb 100644 --- a/docs/api/cv/models/index.rst +++ b/docs/api/cv/models/index.rst @@ -6,6 +6,7 @@ API .. toctree:: :maxdepth: 1 + :titlesonly: CvAllData CvChangeData diff --git a/docs/api/cv/processes/index.rst b/docs/api/cv/processes/index.rst index 14948a8..f2dd7a4 100644 --- a/docs/api/cv/processes/index.rst +++ b/docs/api/cv/processes/index.rst @@ -6,6 +6,7 @@ API .. toctree:: :maxdepth: 1 + :titlesonly: CvProcess DefaultCvProcess diff --git a/docs/api/cv/readers/index.rst b/docs/api/cv/readers/index.rst index 0ab1c8f..9b4b578 100644 --- a/docs/api/cv/readers/index.rst +++ b/docs/api/cv/readers/index.rst @@ -6,6 +6,7 @@ API .. toctree:: :maxdepth: 1 + :titlesonly: CvReader YoloReader diff --git a/docs/api/cv/utils/index.rst b/docs/api/cv/utils/index.rst index d86a429..ed46f15 100644 --- a/docs/api/cv/utils/index.rst +++ b/docs/api/cv/utils/index.rst @@ -6,5 +6,6 @@ API .. toctree:: :maxdepth: 1 + :titlesonly: ChangesChecker diff --git a/docs/api/exceptions/DebugException.rst b/docs/api/exceptions/DebugException.rst deleted file mode 100644 index 2ed49f3..0000000 --- a/docs/api/exceptions/DebugException.rst +++ /dev/null @@ -1,10 +0,0 @@ -DebugException -============== - -.. currentmodule:: apparser.exceptions - -.. autoclass:: DebugException - :members: - :undoc-members: - :show-inheritance: - :member-order: bysource diff --git a/docs/api/exceptions/InstructionNotFoundException.rst b/docs/api/exceptions/InstructionNotFoundException.rst deleted file mode 100644 index 2945b4d..0000000 --- a/docs/api/exceptions/InstructionNotFoundException.rst +++ /dev/null @@ -1,10 +0,0 @@ -InstructionNotFoundException -============================ - -.. currentmodule:: apparser.exceptions - -.. autoclass:: InstructionNotFoundException - :members: - :undoc-members: - :show-inheritance: - :member-order: bysource diff --git a/docs/api/exceptions/InstructionWithIdNotFoundException.rst b/docs/api/exceptions/InstructionWithIdNotFoundException.rst deleted file mode 100644 index 5242129..0000000 --- a/docs/api/exceptions/InstructionWithIdNotFoundException.rst +++ /dev/null @@ -1,10 +0,0 @@ -InstructionWithIdNotFoundException -================================== - -.. currentmodule:: apparser.exceptions - -.. autoclass:: InstructionWithIdNotFoundException - :members: - :undoc-members: - :show-inheritance: - :member-order: bysource diff --git a/docs/api/exceptions/InstructionWithNameNotFoundException.rst b/docs/api/exceptions/InstructionWithNameNotFoundException.rst deleted file mode 100644 index 4a2ec18..0000000 --- a/docs/api/exceptions/InstructionWithNameNotFoundException.rst +++ /dev/null @@ -1,10 +0,0 @@ -InstructionWithNameNotFoundException -==================================== - -.. currentmodule:: apparser.exceptions - -.. autoclass:: InstructionWithNameNotFoundException - :members: - :undoc-members: - :show-inheritance: - :member-order: bysource diff --git a/docs/api/exceptions/TextNotFoundException.rst b/docs/api/exceptions/TextNotFoundException.rst deleted file mode 100644 index 9aead4d..0000000 --- a/docs/api/exceptions/TextNotFoundException.rst +++ /dev/null @@ -1,10 +0,0 @@ -TextNotFoundException -===================== - -.. currentmodule:: apparser.exceptions - -.. autoclass:: TextNotFoundException - :members: - :undoc-members: - :show-inheritance: - :member-order: bysource diff --git a/docs/api/exceptions/WindowActionWithDesktopException.rst b/docs/api/exceptions/WindowActionWithDesktopException.rst deleted file mode 100644 index 5941f9e..0000000 --- a/docs/api/exceptions/WindowActionWithDesktopException.rst +++ /dev/null @@ -1,10 +0,0 @@ -WindowActionWithDesktopException -================================ - -.. currentmodule:: apparser.exceptions - -.. autoclass:: WindowActionWithDesktopException - :members: - :undoc-members: - :show-inheritance: - :member-order: bysource diff --git a/docs/api/exceptions/index.rst b/docs/api/exceptions/index.rst index 23071e2..843ffbf 100644 --- a/docs/api/exceptions/index.rst +++ b/docs/api/exceptions/index.rst @@ -2,12 +2,40 @@ exceptions =================== -.. toctree:: - :maxdepth: 1 - - TextNotFoundException - WindowActionWithDesktopException - DebugException - InstructionNotFoundException - InstructionWithNameNotFoundException - InstructionWithIdNotFoundException +.. currentmodule:: apparser.exceptions + +.. autoclass:: DebugException + :members: + :undoc-members: + :show-inheritance: + :member-order: bysource + +.. autoclass:: InstructionNotFoundException + :members: + :undoc-members: + :show-inheritance: + :member-order: bysource + +.. autoclass:: InstructionWithIdNotFoundException + :members: + :undoc-members: + :show-inheritance: + :member-order: bysource + +.. autoclass:: InstructionWithNameNotFoundException + :members: + :undoc-members: + :show-inheritance: + :member-order: bysource + +.. autoclass:: TextNotFoundException + :members: + :undoc-members: + :show-inheritance: + :member-order: bysource + +.. autoclass:: WindowActionWithDesktopException + :members: + :undoc-members: + :show-inheritance: + :member-order: bysource diff --git a/docs/api/geometry/index.rst b/docs/api/geometry/index.rst index acae0f2..eeb5a34 100644 --- a/docs/api/geometry/index.rst +++ b/docs/api/geometry/index.rst @@ -3,6 +3,7 @@ geometry .. toctree:: :maxdepth: 1 + :titlesonly: Point Size diff --git a/docs/api/instructions/algorithms/Algorithm.rst b/docs/api/instructions/Algorithm.rst similarity index 71% rename from docs/api/instructions/algorithms/Algorithm.rst rename to docs/api/instructions/Algorithm.rst index 13a83c5..ca3e1ff 100644 --- a/docs/api/instructions/algorithms/Algorithm.rst +++ b/docs/api/instructions/Algorithm.rst @@ -1,7 +1,7 @@ Algorithm ========= -.. currentmodule:: apparser.instructions.algorithms +.. currentmodule:: apparser.instructions .. autoclass:: Algorithm :members: diff --git a/docs/api/instructions/algorithms/BaseAlgorithm.rst b/docs/api/instructions/BaseAlgorithm.rst similarity index 72% rename from docs/api/instructions/algorithms/BaseAlgorithm.rst rename to docs/api/instructions/BaseAlgorithm.rst index 8b56a96..854cdb8 100644 --- a/docs/api/instructions/algorithms/BaseAlgorithm.rst +++ b/docs/api/instructions/BaseAlgorithm.rst @@ -1,7 +1,7 @@ BaseAlgorithm ============= -.. currentmodule:: apparser.instructions.algorithms +.. currentmodule:: apparser.instructions .. autoclass:: BaseAlgorithm :members: diff --git a/docs/api/instructions/algorithms/IdsAlgorithm.rst b/docs/api/instructions/IdsAlgorithm.rst similarity index 72% rename from docs/api/instructions/algorithms/IdsAlgorithm.rst rename to docs/api/instructions/IdsAlgorithm.rst index 42e3e3c..7ff33fb 100644 --- a/docs/api/instructions/algorithms/IdsAlgorithm.rst +++ b/docs/api/instructions/IdsAlgorithm.rst @@ -1,7 +1,7 @@ IdsAlgorithm ============ -.. currentmodule:: apparser.instructions.algorithms +.. currentmodule:: apparser.instructions .. autoclass:: IdsAlgorithm :members: diff --git a/docs/api/key_codes/RightClick.rst b/docs/api/instructions/MouseDown.rst similarity index 55% rename from docs/api/key_codes/RightClick.rst rename to docs/api/instructions/MouseDown.rst index 60dec49..d45dc45 100644 --- a/docs/api/key_codes/RightClick.rst +++ b/docs/api/instructions/MouseDown.rst @@ -1,9 +1,9 @@ -RightClick +MouseDown ========== -.. currentmodule:: apparser.key_codes +.. currentmodule:: apparser.instructions -.. autoclass:: RightClick +.. autoclass:: MouseDown :members: :undoc-members: :show-inheritance: diff --git a/docs/api/cv/events/UnDetected.rst b/docs/api/instructions/MouseUp.rst similarity index 55% rename from docs/api/cv/events/UnDetected.rst rename to docs/api/instructions/MouseUp.rst index 11fa5f9..a72fa62 100644 --- a/docs/api/cv/events/UnDetected.rst +++ b/docs/api/instructions/MouseUp.rst @@ -1,9 +1,9 @@ -UnDetected +MouseUp ========== -.. currentmodule:: apparser.cv.events +.. currentmodule:: apparser.instructions -.. autoclass:: UnDetected +.. autoclass:: MouseUp :members: :undoc-members: :show-inheritance: diff --git a/docs/api/instructions/algorithms/NamesAlgorithm.rst b/docs/api/instructions/NamesAlgorithm.rst similarity index 73% rename from docs/api/instructions/algorithms/NamesAlgorithm.rst rename to docs/api/instructions/NamesAlgorithm.rst index 92bbfed..b763b1e 100644 --- a/docs/api/instructions/algorithms/NamesAlgorithm.rst +++ b/docs/api/instructions/NamesAlgorithm.rst @@ -1,7 +1,7 @@ NamesAlgorithm ============== -.. currentmodule:: apparser.instructions.algorithms +.. currentmodule:: apparser.instructions .. autoclass:: NamesAlgorithm :members: diff --git a/docs/api/instructions/algorithms/OCRAlgorithm.rst b/docs/api/instructions/OCRAlgorithm.rst similarity index 72% rename from docs/api/instructions/algorithms/OCRAlgorithm.rst rename to docs/api/instructions/OCRAlgorithm.rst index 357c2a9..044c132 100644 --- a/docs/api/instructions/algorithms/OCRAlgorithm.rst +++ b/docs/api/instructions/OCRAlgorithm.rst @@ -1,7 +1,7 @@ OCRAlgorithm ============ -.. currentmodule:: apparser.instructions.algorithms +.. currentmodule:: apparser.instructions .. autoclass:: OCRAlgorithm :members: diff --git a/docs/api/instructions/PressKeyDown.rst b/docs/api/instructions/PressKeyDown.rst new file mode 100644 index 0000000..f3d53b5 --- /dev/null +++ b/docs/api/instructions/PressKeyDown.rst @@ -0,0 +1,10 @@ +PressKeyDown +============ + +.. currentmodule:: apparser.instructions + +.. autoclass:: PressKeyDown + :members: + :undoc-members: + :show-inheritance: + :member-order: bysource diff --git a/docs/api/instructions/algorithms/AiAlgorithm.rst b/docs/api/instructions/PressKeyUp.rst similarity index 51% rename from docs/api/instructions/algorithms/AiAlgorithm.rst rename to docs/api/instructions/PressKeyUp.rst index a21adcc..600f9f5 100644 --- a/docs/api/instructions/algorithms/AiAlgorithm.rst +++ b/docs/api/instructions/PressKeyUp.rst @@ -1,9 +1,9 @@ -AiAlgorithm +PressKeyUp =========== -.. currentmodule:: apparser.instructions.algorithms +.. currentmodule:: apparser.instructions -.. autoclass:: AiAlgorithm +.. autoclass:: PressKeyUp :members: :undoc-members: :show-inheritance: diff --git a/docs/api/instructions/algorithms/SpeakAlgorithm.rst b/docs/api/instructions/SpeakAlgorithm.rst similarity index 73% rename from docs/api/instructions/algorithms/SpeakAlgorithm.rst rename to docs/api/instructions/SpeakAlgorithm.rst index 352dccc..1dd2356 100644 --- a/docs/api/instructions/algorithms/SpeakAlgorithm.rst +++ b/docs/api/instructions/SpeakAlgorithm.rst @@ -1,7 +1,7 @@ SpeakAlgorithm ============== -.. currentmodule:: apparser.instructions.algorithms +.. currentmodule:: apparser.instructions .. autoclass:: SpeakAlgorithm :members: diff --git a/docs/api/instructions/UniqueAlgorithm.rst b/docs/api/instructions/UniqueAlgorithm.rst new file mode 100644 index 0000000..edb6081 --- /dev/null +++ b/docs/api/instructions/UniqueAlgorithm.rst @@ -0,0 +1,10 @@ +UniqueAlgorithm +=================== + +.. currentmodule:: apparser.instructions + +.. autoclass:: UniqueAlgorithm + :members: + :undoc-members: + :show-inheritance: + :member-order: bysource diff --git a/docs/api/instructions/algorithms/index.rst b/docs/api/instructions/algorithms/index.rst deleted file mode 100644 index 15c6166..0000000 --- a/docs/api/instructions/algorithms/index.rst +++ /dev/null @@ -1,14 +0,0 @@ -algorithms -================================ - - -.. toctree:: - :maxdepth: 1 - - BaseAlgorithm - AiAlgorithm - Algorithm - IdsAlgorithm - NamesAlgorithm - SpeakAlgorithm - OCRAlgorithm diff --git a/docs/api/debuggers/BaseDebugger.rst b/docs/api/instructions/debuggers/BaseDebugger.rst similarity index 72% rename from docs/api/debuggers/BaseDebugger.rst rename to docs/api/instructions/debuggers/BaseDebugger.rst index 4e36042..2e8b205 100644 --- a/docs/api/debuggers/BaseDebugger.rst +++ b/docs/api/instructions/debuggers/BaseDebugger.rst @@ -1,7 +1,7 @@ BaseDebugger ============ -.. currentmodule:: apparser.debuggers +.. currentmodule:: apparser.instructions.debuggers .. autoclass:: BaseDebugger :members: diff --git a/docs/api/debuggers/Debugger.rst b/docs/api/instructions/debuggers/Debugger.rst similarity index 71% rename from docs/api/debuggers/Debugger.rst rename to docs/api/instructions/debuggers/Debugger.rst index 332d6e6..1b3e545 100644 --- a/docs/api/debuggers/Debugger.rst +++ b/docs/api/instructions/debuggers/Debugger.rst @@ -1,7 +1,7 @@ Debugger ======== -.. currentmodule:: apparser.debuggers +.. currentmodule:: apparser.instructions.debuggers .. autoclass:: Debugger :members: diff --git a/docs/api/debuggers/index.rst b/docs/api/instructions/debuggers/index.rst similarity index 84% rename from docs/api/debuggers/index.rst rename to docs/api/instructions/debuggers/index.rst index c9e456f..751bd45 100644 --- a/docs/api/debuggers/index.rst +++ b/docs/api/instructions/debuggers/index.rst @@ -3,6 +3,7 @@ debuggers .. toctree:: :maxdepth: 1 + :titlesonly: BaseDebugger Debugger diff --git a/docs/api/instructions/index.rst b/docs/api/instructions/index.rst index 8706ed2..6f2f279 100644 --- a/docs/api/instructions/index.rst +++ b/docs/api/instructions/index.rst @@ -1,24 +1,28 @@ instructions ===================== -Children +Modules -------- .. toctree:: :maxdepth: 2 + :titlesonly: - algorithms/index + debuggers/index ocr/index speak/index utils/index -API ---- +Instructions +------------ .. toctree:: :maxdepth: 1 + :titlesonly: PressKey + PressKeyUp + PressKeyDown PressKeysCombination PlayAudio PlayAudioFile @@ -26,12 +30,29 @@ API SayAudioFile Sleep MouseClick + MouseUp + MouseDown WriteText MouseMove MouseClickTo - UiInstruction WindowMove WindowResize WindowToForeground WindowToBackground + UiInstruction BaseInstruction + +Algorithms +------------ + +.. toctree:: + :maxdepth: 1 + :titlesonly: + + UniqueAlgorithm + Algorithm + IdsAlgorithm + NamesAlgorithm + SpeakAlgorithm + OCRAlgorithm + BaseAlgorithm diff --git a/docs/api/instructions/ocr/WaitText.rst b/docs/api/instructions/ocr/WaitText.rst new file mode 100644 index 0000000..699034b --- /dev/null +++ b/docs/api/instructions/ocr/WaitText.rst @@ -0,0 +1,10 @@ +WaitText +========== + +.. currentmodule:: apparser.instructions.ocr + +.. autoclass:: WaitText + :members: + :undoc-members: + :show-inheritance: + :member-order: bysource diff --git a/docs/api/instructions/ocr/index.rst b/docs/api/instructions/ocr/index.rst index d0c08a1..f3e3f71 100644 --- a/docs/api/instructions/ocr/index.rst +++ b/docs/api/instructions/ocr/index.rst @@ -3,6 +3,7 @@ ocr .. toctree:: :maxdepth: 1 + :titlesonly: PrintAllText ClickOnText @@ -10,3 +11,4 @@ ocr MoveToText OCRInstruction PlotAllText + WaitText \ No newline at end of file diff --git a/docs/api/instructions/speak/index.rst b/docs/api/instructions/speak/index.rst index a505032..46b0f97 100644 --- a/docs/api/instructions/speak/index.rst +++ b/docs/api/instructions/speak/index.rst @@ -4,6 +4,7 @@ speak .. toctree:: :maxdepth: 1 + :titlesonly: SpeakInstruction PlayTextAudio diff --git a/docs/api/instructions/utils/index.rst b/docs/api/instructions/utils/index.rst index c6e4cea..7ee8152 100644 --- a/docs/api/instructions/utils/index.rst +++ b/docs/api/instructions/utils/index.rst @@ -4,6 +4,7 @@ utils .. toctree:: :maxdepth: 1 + :titlesonly: get_instruction_by_id get_instruction_by_name diff --git a/docs/api/key_codes/Alt.rst b/docs/api/key_codes/Alt.rst deleted file mode 100644 index ab2e660..0000000 --- a/docs/api/key_codes/Alt.rst +++ /dev/null @@ -1,10 +0,0 @@ -Alt -=== - -.. currentmodule:: apparser.key_codes - -.. autoclass:: Alt - :members: - :undoc-members: - :show-inheritance: - :member-order: bysource diff --git a/docs/api/key_codes/Control.rst b/docs/api/key_codes/Control.rst deleted file mode 100644 index 5bb45a8..0000000 --- a/docs/api/key_codes/Control.rst +++ /dev/null @@ -1,10 +0,0 @@ -Control -======= - -.. currentmodule:: apparser.key_codes - -.. autoclass:: Control - :members: - :undoc-members: - :show-inheritance: - :member-order: bysource diff --git a/docs/api/key_codes/Delete.rst b/docs/api/key_codes/Delete.rst deleted file mode 100644 index 82029fc..0000000 --- a/docs/api/key_codes/Delete.rst +++ /dev/null @@ -1,10 +0,0 @@ -Delete -====== - -.. currentmodule:: apparser.key_codes - -.. autoclass:: Delete - :members: - :undoc-members: - :show-inheritance: - :member-order: bysource diff --git a/docs/api/key_codes/Enter.rst b/docs/api/key_codes/Enter.rst deleted file mode 100644 index 989184b..0000000 --- a/docs/api/key_codes/Enter.rst +++ /dev/null @@ -1,10 +0,0 @@ -Enter -===== - -.. currentmodule:: apparser.key_codes - -.. autoclass:: Enter - :members: - :undoc-members: - :show-inheritance: - :member-order: bysource diff --git a/docs/api/key_codes/KeyboardKeyCode.rst b/docs/api/key_codes/KeyboardKeyCode.rst deleted file mode 100644 index d369ffd..0000000 --- a/docs/api/key_codes/KeyboardKeyCode.rst +++ /dev/null @@ -1,10 +0,0 @@ -KeyboardKeyCode -=============== - -.. currentmodule:: apparser.key_codes - -.. autoclass:: KeyboardKeyCode - :members: - :undoc-members: - :show-inheritance: - :member-order: bysource diff --git a/docs/api/key_codes/index.rst b/docs/api/key_codes/index.rst index 51b63b9..06b0fe7 100644 --- a/docs/api/key_codes/index.rst +++ b/docs/api/key_codes/index.rst @@ -1,13 +1,49 @@ key_codes ================== -.. toctree:: - :maxdepth: 1 - - KeyboardKeyCode - Enter - Control - RightClick - LeftClick - Alt - Delete +.. currentmodule:: apparser.key_codes + +.. autoclass:: BaseKeyCode + :members: + :undoc-members: + :show-inheritance: + :member-order: bysource + +.. autoclass:: Alt + :members: + :undoc-members: + :show-inheritance: + :member-order: bysource + +.. autoclass:: Enter + :members: + :undoc-members: + :show-inheritance: + :member-order: bysource + + +.. autoclass:: Control + :members: + :undoc-members: + :show-inheritance: + :member-order: bysource + +.. autoclass:: Delete + :members: + :undoc-members: + :show-inheritance: + :member-order: bysource + +.. autoclass:: LeftClick + :members: + :undoc-members: + :show-inheritance: + :member-order: bysource + +.. autoclass:: RightClick + :members: + :undoc-members: + :show-inheritance: + :member-order: bysource + + diff --git a/docs/api/modules.rst b/docs/api/modules.rst deleted file mode 100644 index 145e8d8..0000000 --- a/docs/api/modules.rst +++ /dev/null @@ -1,16 +0,0 @@ -apparser -============= - -.. toctree:: - :maxdepth: 1 - - core/index - cv/index - debuggers/index - exceptions/index - geometry/index - instructions/index - key_codes/index - movers/index - speakers/index - text_readers/index diff --git a/docs/api/key_codes/LeftClick.rst b/docs/api/movers/BaseMover.rst similarity index 55% rename from docs/api/key_codes/LeftClick.rst rename to docs/api/movers/BaseMover.rst index 2a7df27..2463dcf 100644 --- a/docs/api/key_codes/LeftClick.rst +++ b/docs/api/movers/BaseMover.rst @@ -1,9 +1,9 @@ -LeftClick +BaseMover ========= -.. currentmodule:: apparser.key_codes +.. currentmodule:: apparser.movers.base -.. autoclass:: LeftClick +.. autoclass:: BaseMover :members: :undoc-members: :show-inheritance: diff --git a/docs/api/movers/index.rst b/docs/api/movers/index.rst index cfcca59..1de4774 100644 --- a/docs/api/movers/index.rst +++ b/docs/api/movers/index.rst @@ -3,6 +3,8 @@ movers .. toctree:: :maxdepth: 1 + :titlesonly: + BaseMover DefaultMover AntiRobotMover diff --git a/docs/api/speakers/index.rst b/docs/api/speakers/index.rst index f40b3c3..385dcfa 100644 --- a/docs/api/speakers/index.rst +++ b/docs/api/speakers/index.rst @@ -3,6 +3,7 @@ speakers .. toctree:: :maxdepth: 1 + :titlesonly: BaseSpeaker TorchSpeaker diff --git a/docs/api/text_readers/index.rst b/docs/api/text_readers/index.rst index 3772359..187174e 100644 --- a/docs/api/text_readers/index.rst +++ b/docs/api/text_readers/index.rst @@ -3,10 +3,11 @@ text_readers .. toctree:: :maxdepth: 1 + :titlesonly: + BaseTextReader EasyOcrReader PaddleTextReader ScreensController - BaseTextReader WhiteBlackReader TextData diff --git a/docs/conf.py b/docs/conf.py index a5d58ac..b337750 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -1,16 +1,19 @@ -from pathlib import Path import sys import tomllib import types +from pathlib import Path +from typing import Any -DOCS = Path(__file__).resolve().parent -ROOT = DOCS.parent +DOCS: Path = Path(__file__).resolve().parent +ROOT: Path = DOCS.parent sys.path.insert(0, str(ROOT)) -project_data = tomllib.loads((ROOT / "pyproject.toml").read_text(encoding="utf-8"))["project"] +project_data: dict[str, Any] = tomllib.loads( + (ROOT / "pyproject.toml").read_text(encoding="utf-8"), +)["project"] -def _install_ultralytics_stub(): +def _install_ultralytics_stub() -> types.ModuleType: try: import ultralytics return ultralytics @@ -18,7 +21,7 @@ def _install_ultralytics_stub(): ultralytics = types.ModuleType("ultralytics") class YOLO: - def __init__(self, *args, **kwargs): + def __init__(self, *args: Any, **kwargs: Any) -> None: self.args = args self.kwargs = kwargs @@ -34,23 +37,43 @@ def __init__(self, *args, **kwargs): release = project_data["version"] version = release -extensions = [ +extensions: list[str] = [ "sphinx.ext.autodoc", "sphinx.ext.autosummary", "sphinx.ext.viewcode", ] -templates_path = [] -exclude_patterns = ["_build", "_build*", "__pycache__", "Thumbs.db", ".DS_Store"] -suppress_warnings = ["ref.python"] +templates_path: list[str] = [] +html_static_path: list[str] = ["_static"] +exclude_patterns: list[str] = [ + "_build", + "_build*", + "__pycache__", + "Thumbs.db", + ".DS_Store", +] +suppress_warnings: list[str] = ["ref.python"] +autodoc_mock_imports: list[str] = ["pyautogui"] +nitpick_ignore: list[tuple[str, str]] = [ + ("py:class", "abc.ABC"), + ("py:class", "appwindows.appwindows.Window"), + ("py:class", "numpy.ndarray"), + ("py:class", "pybind11_builtins.pybind11_object"), + ("py:class", "tuple[int"), + ("py:class", "Window"), + ("py:exc", "WindowDoesNotValidException"), +] -autosummary_generate = True -autodoc_default_options = { +autosummary_generate: bool = True +autodoc_default_options: dict[str, bool | str] = { "members": True, "undoc-members": True, "show-inheritance": True, "member-order": "bysource", } -add_module_names = False -html_theme = 'sphinxawesome_theme' +add_module_names: bool = False + +html_theme: str = "shibuya" +html_logo: str = "_static/apparser.svg" +html_css_files: list[str] = ["custom.css"] diff --git a/docs/examples/cv.rst b/docs/examples/cv.rst deleted file mode 100644 index e69de29..0000000 diff --git a/docs/examples/index.rst b/docs/examples/index.rst deleted file mode 100644 index e69de29..0000000 diff --git a/docs/examples/ocr.rst b/docs/examples/ocr.rst new file mode 100644 index 0000000..1d4d71b --- /dev/null +++ b/docs/examples/ocr.rst @@ -0,0 +1,58 @@ +Text Recognition +================== + +Open new tab and write "Hello World!" in Notepad. + +Install +-------- + +Install the OCR extra before using OCRAlgorithm. + +.. code-block:: bash + + pip install "apparser[ocr]" + +Code +-------- + +.. code-block:: python + + from apparser import App, WindowByDisplayUi + from apparser.geometry import Point, Size, RelativelyPoint + from apparser.instructions import Algorithm, WindowMove, WindowResize, Sleep, OCRAlgorithm, MouseClick, MouseClickTo, \ + WriteText + from apparser.instructions.ocr import ClickOnText, MoveToText + + configure_algorithm = Algorithm([ + WindowMove(Point(0, 0)), + WindowResize(Size(300, 300)), + Sleep(0.1), + ]) + + new_tab_algorithm = OCRAlgorithm([ + Sleep(0.1), + ClickOnText("File", min_similarity=0.8), + Sleep(1), + MoveToText("New tab", min_similarity=0.8), + MouseClick(), + MouseClick(), + Sleep(0.1), + ]) + + hello_world_algorithm = Algorithm([ + MouseClickTo(RelativelyPoint(0.5, 0.5)), + WriteText("Hello World!", pause_time=0.2), + ]) + + app = App("Notepad", window_title="Notepad") + + ui = WindowByDisplayUi(app.ui.window) + + while True: + hello_world_algorithm.perform(ui) + new_tab_algorithm.perform(ui) + +Video +-------- + +.. image:: ../_static/ocr.gif \ No newline at end of file diff --git a/docs/examples/quickstart.rst b/docs/examples/quickstart.rst new file mode 100644 index 0000000..44432e8 --- /dev/null +++ b/docs/examples/quickstart.rst @@ -0,0 +1,32 @@ +Quick Start +============ + +Open Notepad and write "Hello World!" + +Code +-------- + +.. code-block:: python + + from apparser import App + from apparser.geometry import Point, RelativelyPoint, Size + from apparser.instructions import (MouseClickTo, WindowMove, WindowResize, + WriteText, Algorithm) + + algorithm = Algorithm([ + WindowMove(Point(50, 50)), + WindowResize(Size(900, 700)), + MouseClickTo(RelativelyPoint(0.5, 0.5)), + WriteText("Hello world!"), + ]) + + app = App("notepad.exe", "Notepad") + + algorithm.perform(app.ui) + + app.stop_app() + +Video +-------- + +.. image:: ../_static/hello_world.gif \ No newline at end of file diff --git a/docs/index.rst b/docs/index.rst index 097cd25..16ea236 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -1,11 +1,79 @@ -apparser +Apparser ======== -Sphinx documentation for the ``apparser`` library. +.. image:: _static/apparser.svg + :width: 40% + +.. raw:: html + +

+ PyPi + Github + Issues +

+ +Apparser is a Python library designed for automating desktop applications and managing UI interfaces using artificial intelligence, such as OCR or object detection models. + +Link to `PyPi `__ + +Link to `GitHub `__ + +Donation +=========== +If you'd like to financially support the developers for their work: + +.. raw:: html + +

+ Donation link +

+ + +Contribution +=============== + +1. If something doesn't work - open issue. +2. If you want something fixed - open issue. +3. If you can help with the library - email. + +apparser.development@gmail.com + +Any help in development is welcome!) + + + +.. toctree:: + :caption: Info + :hidden: + :maxdepth: 1 + :titlesonly: + + info/about + info/install + info/package_map + info/instructions_ids + +.. toctree:: + :caption: Examples + :hidden: + :maxdepth: 1 + :titlesonly: + + examples/quickstart + examples/ocr .. toctree:: - :maxdepth: 4 :caption: Apparser + :hidden: + :maxdepth: 1 + :titlesonly: - overview - api/modules + api/core/index + api/cv/index + api/geometry/index + api/instructions/index + api/movers/index + api/speakers/index + api/text_readers/index + api/key_codes/index + api/exceptions/index diff --git a/docs/info/about.rst b/docs/info/about.rst new file mode 100644 index 0000000..436b0d1 --- /dev/null +++ b/docs/info/about.rst @@ -0,0 +1,42 @@ +Apparser +======== + +.. image:: ../_static/apparser.svg + :width: 40% + +.. raw:: html + +

+ PyPi + Github + Issues +

+ +Apparser is a Python library designed for automating desktop applications and managing UI interfaces using artificial intelligence, such as OCR or object detection models. + +Link to `PyPi `__ + +Link to `GitHub `__ + +Donation +---------- + +If you'd like to financially support the developers for their work: + +.. raw:: html + +

+ Donation link +

+ + +Contribution +-------------- + +1. If something doesn't work - open issue. +2. If you want something fixed - open issue. +3. If you can help with the library - email. + +apparser.development@gmail.com + +Any help in development is welcome!) diff --git a/docs/info/install.rst b/docs/info/install.rst new file mode 100644 index 0000000..b92c594 --- /dev/null +++ b/docs/info/install.rst @@ -0,0 +1,16 @@ +Install +======= + +.. code-block:: bash + + pip install apparser + + +Optional features + +.. code-block:: bash + + pip install "apparser[cv]" + pip install "apparser[ocr]" + pip install "apparser[speak]" + pip install "apparser[all]" diff --git a/docs/info/instructions_ids.rst b/docs/info/instructions_ids.rst new file mode 100644 index 0000000..d06207e --- /dev/null +++ b/docs/info/instructions_ids.rst @@ -0,0 +1,151 @@ +Instructions Info +======================= + +In ``apparser.instructions``, every concrete instruction inherits from +``BaseInstruction`` and must define the ``id`` property. This numeric +identifier is used for instruction lookup by number and for scenarios where an +instruction is described as ``(instruction_id, instruction_args)``. + +How id lookup works +----------------------- + +* The base contract is defined in + ``apparser.instructions.base.BaseInstruction``: the ``id`` property is + required and must return an ``int``. +* ``get_instruction_by_id()`` accepts only an integer and requires + ``instruction_id >= 0``. +* Lookup is performed by exact ``id`` match without creating an instruction + instance. +* If no match is found, ``InstructionWithIdNotFoundException`` is raised. +* ``id`` uniqueness is not validated separately. Based on the current + implementation, if duplicates appear, the first matching class will be + returned. + +Lookup is performed through ``get_all_instructions()``, which scans the +``default``, ``ocr``, ``speak``, and ``ui`` packages and keeps only concrete, +non-abstract instruction classes. + +.. important:: + + Algorithm instructions currently not included in + ``get_instruction_by_id()``. + +Current numbering layout +------------------------ + +In the current codebase, identifiers are effectively grouped as follows: + +* ``1-999`` - base instructions from ``apparser.instructions.default`` +* ``1000-1499`` - UI instructions from ``apparser.instructions.ui`` +* ``1500-1999`` - algorithms from ``apparser.instructions.ui.algorithms`` +* ``2000-2999`` - OCR instructions from ``apparser.instructions.ocr`` +* ``3000-3999`` - speech instructions from ``apparser.instructions.speak`` + +Numbering does not need to be continuous: the code only requires a non-negative +``id`` and no conflicts between classes that should be resolved by numeric +identifier. + +Instructions available through get_instruction_by_id() +---------------------------------------------------------- + +.. list-table:: + :header-rows: 1 + + * - ``id`` + - Class + - Purpose + * - ``1`` + - ``MouseClick`` + - Perform a mouse click + * - ``2`` + - ``PressKey`` + - Press a single key + * - ``3`` + - ``PressKeysCombination`` + - Press a key combination + * - ``4`` + - ``WriteText`` + - Type text from the keyboard + * - ``5`` + - ``PlayAudio`` + - Play audio through an output device + * - ``6`` + - ``SayAudio`` + - Play audio through a device used as a microphone target + * - ``7`` + - ``PlayAudioFile`` + - Play an audio file + * - ``8`` + - ``SayAudioFile`` + - Play an audio file through a device used as a microphone target + * - ``9`` + - ``Sleep`` + - Pause execution for a fixed amount of time + * - ``1000`` + - ``WindowToForeground`` + - Bring the window to the foreground + * - ``1001`` + - ``WindowToBackground`` + - Send the window to the background + * - ``1002`` + - ``WindowMove`` + - Move the window + * - ``1003`` + - ``WindowResize`` + - Resize the window + * - ``1004`` + - ``MouseMove`` + - Move the mouse cursor + * - ``1005`` + - ``MouseClickTo`` + - Move the cursor to a point and click + * - ``2000`` + - ``GetText`` + - Read text from a screen region + * - ``2001`` + - ``MoveToText`` + - Move the cursor to matched text + * - ``2002`` + - ``ClickOnText`` + - Find text and click it + * - ``2003`` + - ``PrintAllText`` + - Print all detected text blocks + * - ``2004`` + - ``PlotAllText`` + - Draw detected text on top of a screenshot + * - ``3000`` + - ``PlayTextAudio`` + - Synthesize text and play it as regular audio + * - ``3001`` + - ``SayTextAudio`` + - Synthesize text and play it through a device used as a microphone + target + +Classes with their own id but not resolved by get_instruction_by_id() +------------------------------------------------------------------------------ + +.. list-table:: + :header-rows: 1 + + * - ``id`` + - Class + - Purpose + * - ``1500`` + - ``Algorithm`` + - Sequentially execute regular and UI instructions + * - ``1501`` + - ``IdsAlgorithm`` + - Execute instructions described by numeric ``id`` values and arguments + * - ``1502`` + - ``NamesAlgorithm`` + - Execute instructions described by class names and arguments + * - ``1503`` + - ``OCRAlgorithm`` + - Execute instructions with a provided ``text_reader`` + * - ``1504`` + - ``SpeakAlgorithm`` + - Execute instructions with a provided ``speaker`` + * - ``1505`` + - ``UniqueAlgorithm`` + - Inject dependencies into instructions by argument type \ No newline at end of file diff --git a/docs/overview.rst b/docs/info/package_map.rst similarity index 55% rename from docs/overview.rst rename to docs/info/package_map.rst index 41d69b3..aab915d 100644 --- a/docs/overview.rst +++ b/docs/info/package_map.rst @@ -1,25 +1,3 @@ -Overview -======== - -``apparser`` is a Python library for testing and managing computer programs. - -Install -======= - -.. code-block:: bash - - pip install apparser - -Optional features -================= - -.. code-block:: bash - - pip install "apparser[cv]" - pip install "apparser[ocr]" - pip install "apparser[speak]" - pip install "apparser[all]" - Package map =========== @@ -47,5 +25,5 @@ Package map ``apparser.cv`` Computer vision readers, events, handlers and processes. -``apparser.debuggers`` and ``apparser.exceptions`` - Debugging helpers and library exceptions. +``apparser.exceptions`` + Library exceptions. \ No newline at end of file diff --git a/docs/requirements.txt b/docs/requirements.txt new file mode 100644 index 0000000..3f07068 --- /dev/null +++ b/docs/requirements.txt @@ -0,0 +1,9 @@ +sphinx>=9.1.0 +shibuya +appwindows >= 1.3.1, +keyboard >= 0.13.2, +mouse >= 0.7.1, +numpy >= 1.20, +pillow >= 11.0, +screeninfo >= 0.8, +thefuzz >= 0.20.0 diff --git a/pyproject.toml b/pyproject.toml index c2bfa0f..80b3efc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,8 +1,7 @@ [project] dependencies = [ "appwindows >= 1.3.0", - "keyboard >= 0.13.2", - "mouse >= 0.7.1", + "PyAutoGui >= 0.9.0", "numpy >= 1.20", "pillow >= 11.0", "screeninfo >= 0.8", @@ -13,7 +12,7 @@ version = "1.0.0" authors = [ { name = "Terochkin A.S", email = "apparser.development@gmail.com" }, ] -description = "The apparser library is designed for managing computer programs." +description = "Apparser is a Python library designed for automating desktop applications and managing UI interfaces using artificial intelligence, such as OCR or object detection models. " readme = "README.md" requires-python = ">=3.10" classifiers = [ @@ -22,13 +21,35 @@ classifiers = [ "Operating System :: Microsoft :: Windows :: Windows 10", "Operating System :: Microsoft :: Windows :: Windows 11", "Operating System :: POSIX :: Linux", + "Operating System :: MacOS", "Environment :: X11 Applications", - "Environment :: Win32 (MS Windows)" + "Environment :: Win32 (MS Windows)", + "Environment :: MacOS X :: Aqua", +] +keywords = [ + "automation", + "desktop-automation", + "gui-automation", + "ui-automation", + "computer-vision", + "object-detection", + "ocr", + "ui", + "cv", + "yolo", + "text-recognition", + "screen-recognition", + "window-management", + "mouse-automation", + "keyboard-automation", + "speech-synthesis", + "tts", + "app-control" ] - [project.urls] -Repository = "https://github.com/lexter0705/apparser" -Issues = "https://github.com/lexter0705/apparser/issues" +Docs = "https://apparser-development.github.io/apparser/" +Repository = "https://github.com/apparser-development/apparser" +Issues = "https://github.com/apparser-development/apparser/issues" [project.optional-dependencies] cv = [ @@ -36,8 +57,9 @@ cv = [ ] ocr = [ - "easyocr >= 1.7.2", - "paddleocr" + "easyocr >= 1.7.2, < 2.0", + "paddleocr >= 3.5.0", + "paddlepaddle >= 3.3.1" ] speak = [ diff --git a/requirements.txt b/requirements.txt index aca83c7..002d2b4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,9 +1,6 @@ -appwindows>=1.2.0 -keyboard>=0.13.2 -mouse>=0.7.1 -numpy>=1.20 -pillow>=11.0 -easyocr>=1.7.2 -thefuzz>=0.20.0 -ultralytics -screeninfo +appwindows >= 1.3.1, +numpy >= 1.20, +pillow >= 11.0, +PyAutoGui >= 0.9.0 +screeninfo >= 0.8, +thefuzz >= 0.20.0 diff --git a/tests/__init__.py b/tests/__init__.py index 114a59d..e69de29 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1 +0,0 @@ -"""Project test suite.""" diff --git a/tests/apparser/__init__.py b/tests/apparser/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/tests/apparser/__init__.py @@ -0,0 +1 @@ + diff --git a/tests/apparser/core/__init__.py b/tests/apparser/core/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/tests/apparser/core/__init__.py @@ -0,0 +1 @@ + diff --git a/tests/apparser/core/test_app.py b/tests/apparser/core/test_app.py new file mode 100644 index 0000000..c18dc7e --- /dev/null +++ b/tests/apparser/core/test_app.py @@ -0,0 +1,78 @@ +from __future__ import annotations + +from typing import Any + +import pytest +from appwindows.geometry import Size + +from apparser.core.app import App +from tests.utils import FakeWindow + + +class FakeProcess: + def __init__(self) -> None: + self.kill_calls = 0 + + def kill(self) -> None: + self.kill_calls += 1 + + +class FakeWindowUi: + def __init__(self, window: FakeWindow) -> None: + self.window = window + + +class FakeFinder: + def __init__(self, window: FakeWindow) -> None: + self.window = window + self.calls: list[str] = [] + + def get_window_by_title(self, title: str) -> FakeWindow: + self.calls.append(title) + return self.window + + def get_all_windows(self) -> list[FakeWindow]: + return [self.window] + + def get_window_by_process_id(self, process_id: int) -> FakeWindow: + return self.window + + +@pytest.mark.parametrize( + ("path_to_exe", "window_title", "window_size", "timeout"), + [ + (1, "title", Size(1, 1), 1), + ("path", 2, Size(1, 1), 1), + ("path", "title", object(), 1), + ("path", "title", Size(1, 1), "1"), + ], +) +def test_app_validates_init_arguments( + path_to_exe: Any, + window_title: Any, + window_size: Any, + timeout: Any, +) -> None: + with pytest.raises(TypeError): + App(path_to_exe, window_title, window_size, timeout) + + +def test_app_starts_and_stops(monkeypatch: pytest.MonkeyPatch) -> None: + process = FakeProcess() + window = FakeWindow() + finder = FakeFinder(window) + sleep_calls: list[float] = [] + + monkeypatch.setattr("apparser.core.app.get_finder", lambda: finder) + monkeypatch.setattr("apparser.core.app.subprocess.Popen", lambda args: process) + monkeypatch.setattr("apparser.core.app.time.sleep", lambda value: sleep_calls.append(value)) + monkeypatch.setattr("apparser.core.app.WindowUi", FakeWindowUi) + + app = App("app.exe", timeout=0.2) + + assert sleep_calls == [0.2] + + app.stop_app() + + assert app.ui.window.close_calls == 1 + assert process.kill_calls == 1 diff --git a/tests/apparser/core/ui/__init__.py b/tests/apparser/core/ui/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/tests/apparser/core/ui/__init__.py @@ -0,0 +1 @@ + diff --git a/tests/apparser/core/ui/test_base.py b/tests/apparser/core/ui/test_base.py new file mode 100644 index 0000000..3368fd9 --- /dev/null +++ b/tests/apparser/core/ui/test_base.py @@ -0,0 +1,10 @@ +from __future__ import annotations + +import pytest + +from apparser.core.ui.base import BaseUi + + +def test_base_ui_is_abstract() -> None: + with pytest.raises(TypeError): + BaseUi() diff --git a/tests/apparser/core/ui/test_coordinates.py b/tests/apparser/core/ui/test_coordinates.py new file mode 100644 index 0000000..bb604da --- /dev/null +++ b/tests/apparser/core/ui/test_coordinates.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +import numpy +import pytest +from appwindows.geometry import Point, Size + +from apparser.core.ui.coordinates import CoordinatesUi +from apparser.geometry import RelativelyPoint +from tests.utils import FakeUi, FakeWindow + + +def test_coordinates_ui_validates_arguments() -> None: + with pytest.raises(TypeError): + CoordinatesUi(object(), Point(0, 0), Point(1, 1)) + + with pytest.raises(TypeError): + CoordinatesUi(FakeUi(), object(), Point(1, 1)) + + with pytest.raises(TypeError): + CoordinatesUi(FakeUi(), Point(0, 0), object()) + + +def test_coordinates_ui_converts_points() -> None: + parent_ui = FakeUi(offset=Point(10, 20), relative_size=Size(100, 100)) + ui = CoordinatesUi(parent_ui, Point(5, 6), Point(45, 86)) + + assert ui.point_to_global(Point(1, 2)) == Point(16, 28) + assert ui.point_to_global(RelativelyPoint(0.5, 0.25)) == Point(35, 46) + assert ui.point_to_local(Point(16, 28)) == Point(1, 2) + + +def test_coordinates_ui_supports_relative_origin() -> None: + parent_ui = FakeUi(offset=Point(10, 20), relative_size=Size(100, 100)) + ui = CoordinatesUi( + parent_ui, + RelativelyPoint(0.1, 0.2), + RelativelyPoint(0.2, 0.3), + ) + + assert ui.point_to_global(Point(1, 1)) == Point(21, 41) + + +def test_coordinates_ui_crops_numpy_screenshot() -> None: + screenshot = numpy.arange(100, dtype=numpy.uint8).reshape(10, 10) + parent_ui = FakeUi(screenshot=screenshot) + ui = CoordinatesUi(parent_ui, Point(2, 3), Point(6, 5)) + + result = ui.get_screenshot() + + assert numpy.array_equal(result, screenshot[3:5, 2:6]) + + +def test_coordinates_ui_normalizes_bounds() -> None: + screenshot = numpy.arange(100, dtype=numpy.uint8).reshape(10, 10) + parent_ui = FakeUi(screenshot=screenshot, window=FakeWindow()) + ui = CoordinatesUi(parent_ui, Point(6, 5), Point(2, 3)) + + result = ui.get_screenshot() + + assert numpy.array_equal(result, screenshot[3:5, 2:6]) + assert ui.window is parent_ui.window diff --git a/tests/apparser/core/ui/test_desktop.py b/tests/apparser/core/ui/test_desktop.py new file mode 100644 index 0000000..c6569f9 --- /dev/null +++ b/tests/apparser/core/ui/test_desktop.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +import numpy +import pytest +from PIL import Image +from appwindows.geometry import Point + +from apparser.core.ui.desktop import DesktopUi +from apparser.exceptions import WindowActionWithDesktopException +from apparser.geometry import RelativelyPoint +from tests.utils import screeninfo_stub + + +def test_desktop_ui_converts_points(monkeypatch: pytest.MonkeyPatch) -> None: + screeninfo_stub.monitors = [ + type("Monitor", (), {"width": 200, "height": 100})(), + ] + ui = DesktopUi(display_id=0) + + assert ui.point_to_global(Point(1, 2)) == Point(1, 2) + assert ui.point_to_global(RelativelyPoint(0.5, 0.25)) == Point(100, 25) + assert ui.point_to_local(Point(7, 8)) == Point(7, 8) + + +def test_desktop_ui_returns_numpy_screenshot(monkeypatch: pytest.MonkeyPatch) -> None: + image = Image.fromarray(numpy.ones((2, 2, 3), dtype=numpy.uint8)) + monkeypatch.setattr("apparser.core.ui.desktop.ImageGrab.grab", lambda: image) + ui = DesktopUi() + + result = ui.get_screenshot() + + assert isinstance(result, numpy.ndarray) + assert result.shape == (2, 2, 3) + + +def test_desktop_ui_has_no_window() -> None: + ui = DesktopUi() + + with pytest.raises(WindowActionWithDesktopException): + _ = ui.window diff --git a/tests/apparser/core/ui/test_window.py b/tests/apparser/core/ui/test_window.py new file mode 100644 index 0000000..a96470a --- /dev/null +++ b/tests/apparser/core/ui/test_window.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +import numpy +import pytest +from appwindows.geometry import Point, Size + +from apparser.core.ui.window import WindowUi +from apparser.geometry import RelativelyPoint +from tests.utils import FakeWindow + + +def test_window_ui_rejects_invalid_window_type() -> None: + with pytest.raises(TypeError): + WindowUi(object()) + + +def test_window_ui_converts_points(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr("apparser.core.ui.window.Window", FakeWindow) + window = FakeWindow(left_top=Point(10, 20), size=Size(50, 80)) + ui = WindowUi(window) + + assert ui.point_to_global(Point(1, 2)) == Point(11, 22) + assert ui.point_to_global(RelativelyPoint(0.5, 0.25)) == Point(35, 40) + assert ui.point_to_local(Point(11, 22)) == Point(1, 2) + + +def test_window_ui_returns_window_screenshot(monkeypatch: pytest.MonkeyPatch) -> None: + screenshot = numpy.ones((2, 2, 3), dtype=numpy.uint8) + monkeypatch.setattr("apparser.core.ui.window.Window", FakeWindow) + window = FakeWindow(screenshot=screenshot) + ui = WindowUi(window) + + assert numpy.array_equal(ui.get_screenshot(), screenshot) + assert ui.window is window diff --git a/tests/apparser/core/ui/test_window_by_display.py b/tests/apparser/core/ui/test_window_by_display.py new file mode 100644 index 0000000..dccbfa1 --- /dev/null +++ b/tests/apparser/core/ui/test_window_by_display.py @@ -0,0 +1,64 @@ +from __future__ import annotations + +import numpy +import pytest +from PIL import Image +from appwindows.geometry import Point, Size + +from apparser.core.ui.window_by_display import WindowByDisplayUi +from apparser.geometry import RelativelyPoint +from tests.utils import FakeWindow + + +def test_window_by_display_ui_rejects_invalid_window_type() -> None: + with pytest.raises(TypeError): + WindowByDisplayUi(object()) + + +def test_window_by_display_ui_converts_points( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr( + "apparser.core.ui.window_by_display.Window", + FakeWindow, + ) + window = FakeWindow(left_top=Point(10, 20), size=Size(50, 80)) + ui = WindowByDisplayUi(window) + + assert ui.point_to_global(Point(1, 2)) == Point(11, 22) + assert ui.point_to_global(RelativelyPoint(0.5, 0.25)) == Point(35, 40) + assert ui.point_to_local(Point(11, 22)) == Point(1, 2) + + +def test_window_by_display_ui_returns_display_screenshot( + monkeypatch: pytest.MonkeyPatch, +) -> None: + image = Image.fromarray(numpy.ones((2, 2, 3), dtype=numpy.uint8)) + grab_calls: list[dict[str, object]] = [] + + def grab( + bbox: tuple[int, int, int, int], + all_screens: bool, + ) -> Image.Image: + grab_calls.append({"bbox": bbox, "all_screens": all_screens}) + return image + + monkeypatch.setattr( + "apparser.core.ui.window_by_display.Window", + FakeWindow, + ) + monkeypatch.setattr( + "apparser.core.ui.window_by_display.ImageGrab.grab", + grab, + ) + window = FakeWindow(left_top=Point(10, 20), size=Size(5, 7)) + ui = WindowByDisplayUi(window) + + result = ui.get_screenshot() + + assert isinstance(result, numpy.ndarray) + assert result.shape == (2, 2, 3) + assert grab_calls == [ + {"bbox": (10, 20, 15, 27), "all_screens": True}, + ] + assert ui.window is window diff --git a/tests/apparser/cv/__init__.py b/tests/apparser/cv/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/tests/apparser/cv/__init__.py @@ -0,0 +1 @@ + diff --git a/tests/apparser/cv/events/__init__.py b/tests/apparser/cv/events/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/tests/apparser/cv/events/__init__.py @@ -0,0 +1 @@ + diff --git a/tests/apparser/cv/events/test_base.py b/tests/apparser/cv/events/test_base.py new file mode 100644 index 0000000..dbb1722 --- /dev/null +++ b/tests/apparser/cv/events/test_base.py @@ -0,0 +1,10 @@ +from __future__ import annotations + +import pytest + +from apparser.cv.events.base import CvEvent + + +def test_cv_event_is_abstract() -> None: + with pytest.raises(TypeError): + CvEvent() diff --git a/tests/apparser/cv/events/test_detected.py b/tests/apparser/cv/events/test_detected.py new file mode 100644 index 0000000..759d647 --- /dev/null +++ b/tests/apparser/cv/events/test_detected.py @@ -0,0 +1,7 @@ +from __future__ import annotations + +from apparser.cv.events.detected import Detected + + +def test_detected_string_representation() -> None: + assert str(Detected()) == "Detected" diff --git a/tests/apparser/cv/events/test_moved.py b/tests/apparser/cv/events/test_moved.py new file mode 100644 index 0000000..10ddca0 --- /dev/null +++ b/tests/apparser/cv/events/test_moved.py @@ -0,0 +1,7 @@ +from __future__ import annotations + +from apparser.cv.events.moved import Moved + + +def test_moved_string_representation() -> None: + assert str(Moved()) == "Moved" diff --git a/tests/apparser/cv/events/test_resized.py b/tests/apparser/cv/events/test_resized.py new file mode 100644 index 0000000..173f6d7 --- /dev/null +++ b/tests/apparser/cv/events/test_resized.py @@ -0,0 +1,7 @@ +from __future__ import annotations + +from apparser.cv.events.resized import Resized + + +def test_resized_string_representation() -> None: + assert str(Resized()) == "Resized" diff --git a/tests/apparser/cv/events/test_undetected.py b/tests/apparser/cv/events/test_undetected.py new file mode 100644 index 0000000..692e368 --- /dev/null +++ b/tests/apparser/cv/events/test_undetected.py @@ -0,0 +1,7 @@ +from __future__ import annotations + +from apparser.cv.events.undetected import UnDetected + + +def test_undetected_string_representation() -> None: + assert str(UnDetected()) == "UnDetected" diff --git a/tests/apparser/cv/handlers/__init__.py b/tests/apparser/cv/handlers/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/tests/apparser/cv/handlers/__init__.py @@ -0,0 +1 @@ + diff --git a/tests/apparser/cv/handlers/test_base.py b/tests/apparser/cv/handlers/test_base.py new file mode 100644 index 0000000..73ef746 --- /dev/null +++ b/tests/apparser/cv/handlers/test_base.py @@ -0,0 +1,10 @@ +from __future__ import annotations + +import pytest + +from apparser.cv.handlers.base import CvHandlers + + +def test_cv_handlers_is_abstract() -> None: + with pytest.raises(TypeError): + CvHandlers() diff --git a/tests/apparser/cv/handlers/test_default.py b/tests/apparser/cv/handlers/test_default.py new file mode 100644 index 0000000..ce82c60 --- /dev/null +++ b/tests/apparser/cv/handlers/test_default.py @@ -0,0 +1,65 @@ +import pytest + +from apparser.cv.events import CvEvent, Detected, Moved +from apparser.cv.handlers.default import DefaultHandlers, _form_args +from apparser.cv.models import CvAllData, CvBox, CvChangeData +from tests.utils import FakeUi + + +def test_form_args_matches_by_annotation_type() -> None: + ui = FakeUi() + box = CvBox("button", 1, 2, 3, 4, 5, ui) + changed_data = CvChangeData(Detected, box, box) + cv_data = CvAllData([box]) + + def callback(data: CvAllData, ui_arg: FakeUi, change: CvChangeData) -> None: + return None + + result = _form_args(callback, cv_data, ui, changed_data) + + assert result == { + "data": cv_data, + "ui_arg": ui, + "change": changed_data, + } + + +def test_register_handler_rejects_base_event() -> None: + handlers = DefaultHandlers() + + with pytest.raises(TypeError): + handlers.register_handler(CvEvent) + + +def test_default_handlers_calls_matching_handler() -> None: + handlers = DefaultHandlers() + ui = FakeUi() + box = CvBox("button", 1, 2, 3, 4, 5, ui) + changed_data = CvChangeData(Detected, box, box) + cv_data = CvAllData([box]) + received: list[tuple[CvChangeData, CvAllData, FakeUi]] = [] + + @handlers.register_handler(Detected, class_name="button") + def callback(change: CvChangeData, data: CvAllData, ui_arg: FakeUi) -> None: + received.append((change, data, ui_arg)) + + handlers.call(Detected, changed_data, cv_data, ui) + + assert received == [(changed_data, cv_data, ui)] + + +def test_default_handlers_skips_non_matching_handlers() -> None: + handlers = DefaultHandlers() + ui = FakeUi() + box = CvBox("button", 1, 2, 3, 4, 5, ui) + changed_data = CvChangeData(Detected, box, box) + called = False + + @handlers.register_handler(Moved, class_name="other") + def callback(change: CvChangeData) -> None: + nonlocal called + called = True + + handlers.call(Detected, changed_data, CvAllData([box]), ui) + + assert called is False diff --git a/tests/apparser/cv/models/__init__.py b/tests/apparser/cv/models/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/tests/apparser/cv/models/__init__.py @@ -0,0 +1 @@ + diff --git a/tests/apparser/cv/models/test_data.py b/tests/apparser/cv/models/test_data.py new file mode 100644 index 0000000..a50422a --- /dev/null +++ b/tests/apparser/cv/models/test_data.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +from appwindows.geometry import Point + +from apparser.cv.events import Detected +from apparser.cv.models.data import CvAllData, CvBox, CvChangeData +from tests.utils import FakeUi + + +def test_cv_box_and_related_dataclasses_store_values() -> None: + ui = FakeUi() + box = CvBox("button", 1, 2, 3, 4, 5, ui) + change = CvChangeData(Detected, box, box) + all_data = CvAllData([box]) + + assert box.class_name == "button" + assert box.track_id == 1 + assert change.event is Detected + assert all_data.boxes == [box] diff --git a/tests/apparser/cv/models/test_handler.py b/tests/apparser/cv/models/test_handler.py new file mode 100644 index 0000000..85ea938 --- /dev/null +++ b/tests/apparser/cv/models/test_handler.py @@ -0,0 +1,15 @@ +from __future__ import annotations + +from apparser.cv.events import Detected +from apparser.cv.models.handler import CvHandler + + +def test_cv_handler_stores_configuration() -> None: + def callback() -> None: + return None + + handler = CvHandler(Detected, callback, "button") + + assert handler.event is Detected + assert handler.function is callback + assert handler.class_name == "button" diff --git a/tests/apparser/cv/processes/__init__.py b/tests/apparser/cv/processes/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/tests/apparser/cv/processes/__init__.py @@ -0,0 +1 @@ + diff --git a/tests/apparser/cv/processes/test_base.py b/tests/apparser/cv/processes/test_base.py new file mode 100644 index 0000000..29c8899 --- /dev/null +++ b/tests/apparser/cv/processes/test_base.py @@ -0,0 +1,10 @@ +from __future__ import annotations + +import pytest + +from apparser.cv.processes.base import CvProcess + + +def test_cv_process_is_abstract() -> None: + with pytest.raises(TypeError): + CvProcess() diff --git a/tests/apparser/cv/processes/test_default.py b/tests/apparser/cv/processes/test_default.py new file mode 100644 index 0000000..9c30396 --- /dev/null +++ b/tests/apparser/cv/processes/test_default.py @@ -0,0 +1,75 @@ +from __future__ import annotations + +from apparser.cv.events import Detected +from apparser.cv.models import CvAllData, CvBox, CvChangeData +from apparser.cv.processes.default import DefaultCvProcess +from tests.utils import FakeChangesChecker, FakeCvHandlers, FakeCvReader, FakeUi + + +def test_default_cv_process_starts_and_dispatches_changes() -> None: + ui = FakeUi() + box = CvBox("button", 1, 2, 3, 4, 5, ui) + cv_data = CvAllData([box]) + changed_data = CvChangeData(Detected, box, box) + reader = FakeCvReader(results=[cv_data]) + checker = FakeChangesChecker(results=[[changed_data]]) + process = DefaultCvProcess(reader, changes_checker=checker) + handler = FakeCvHandlers() + + def stop_after_call(event: type[Detected], change: CvChangeData, *args: object) -> None: + handler.calls.append( + { + "event": event, + "changed_data": change, + "args": args, + } + ) + process.stop() + + handler.call = stop_after_call + process.include_handlers(handler) + + process.start(ui) + + assert reader.calls == [ui] + assert checker.calls == [cv_data] + assert handler.calls[0]["event"] is Detected + assert handler.calls[0]["changed_data"] == changed_data + + +def test_default_cv_process_includes_multiple_handlers() -> None: + ui = FakeUi() + box = CvBox("button", 1, 2, 3, 4, 5, ui) + cv_data = CvAllData([box]) + changed_data = CvChangeData(Detected, box, box) + reader = FakeCvReader(results=[cv_data]) + checker = FakeChangesChecker(results=[[changed_data]]) + process = DefaultCvProcess(reader, changes_checker=checker) + first_handler = FakeCvHandlers() + second_handler = FakeCvHandlers() + + def stop_after_second_handler(event: type[Detected], change: CvChangeData, *args: object) -> None: + second_handler.calls.append( + { + "event": event, + "changed_data": change, + "args": args, + } + ) + process.stop() + + first_handler.call = lambda event, change, *args: first_handler.calls.append( + { + "event": event, + "changed_data": change, + "args": args, + } + ) + second_handler.call = stop_after_second_handler + process.include_handlers(first_handler) + process.include_handlers(second_handler) + + process.start(ui) + + assert len(first_handler.calls) == 1 + assert len(second_handler.calls) == 1 diff --git a/tests/apparser/cv/readers/__init__.py b/tests/apparser/cv/readers/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/tests/apparser/cv/readers/__init__.py @@ -0,0 +1 @@ + diff --git a/tests/apparser/cv/readers/test_base.py b/tests/apparser/cv/readers/test_base.py new file mode 100644 index 0000000..7ac2e80 --- /dev/null +++ b/tests/apparser/cv/readers/test_base.py @@ -0,0 +1,10 @@ +from __future__ import annotations + +import pytest + +from apparser.cv.readers.base import CvReader + + +def test_cv_reader_is_abstract() -> None: + with pytest.raises(TypeError): + CvReader() diff --git a/tests/apparser/cv/readers/test_yolo.py b/tests/apparser/cv/readers/test_yolo.py new file mode 100644 index 0000000..ec05ed3 --- /dev/null +++ b/tests/apparser/cv/readers/test_yolo.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +from types import SimpleNamespace + +import numpy +import pytest + +from apparser.cv.readers.yolo import YoloReader +from tests.utils import FakeUi + + +def test_yolo_reader_uses_existing_model() -> None: + model = SimpleNamespace(track=lambda **kwargs: [SimpleNamespace(boxes=[])], model=SimpleNamespace(names={})) + + reader = YoloReader(model) + + assert reader._YoloReader__model is model + + +def test_yolo_reader_creates_model_from_path() -> None: + reader = YoloReader("weights.pt") + + assert reader._YoloReader__model.model_path == "weights.pt" + + +def test_yolo_reader_maps_detected_boxes(monkeypatch: pytest.MonkeyPatch) -> None: + created_uis: list[CoordinatesUiSpy] = [] + + class CoordinatesUiSpy: + def __init__(self, *args: object, **kwargs: object) -> None: + self.args = args + self.kwargs = kwargs + created_uis.append(self) + + box_with_id = SimpleNamespace( + id=numpy.asarray([10]), + cls=numpy.asarray([1]), + xyxy=numpy.asarray([[1, 2, 6, 8]]), + ) + box_without_id = SimpleNamespace( + id=None, + cls=numpy.asarray([0]), + xyxy=numpy.asarray([[3, 4, 9, 12]]), + ) + model = SimpleNamespace( + model=SimpleNamespace(names={0: "cat", 1: "dog"}), + track=lambda **kwargs: [SimpleNamespace(boxes=[box_with_id, box_without_id])], + ) + ui = FakeUi() + reader = YoloReader(model, persist=False, conf=0.5) + monkeypatch.setattr("apparser.cv.readers.yolo.CoordinatesUi", CoordinatesUiSpy) + + result = reader.read(ui) + + assert len(result.boxes) == 2 + assert result.boxes[0].class_name == "dog" + assert result.boxes[0].track_id == 10 + assert result.boxes[0].width == 5 + assert result.boxes[0].height == 6 + assert result.boxes[0].ui is created_uis[0] + assert result.boxes[1].class_name == "cat" + assert result.boxes[1].track_id is None + assert result.boxes[1].ui is created_uis[1] diff --git a/tests/apparser/cv/test___init__.py b/tests/apparser/cv/test___init__.py new file mode 100644 index 0000000..2ba6a9f --- /dev/null +++ b/tests/apparser/cv/test___init__.py @@ -0,0 +1,10 @@ +from __future__ import annotations + +from apparser import cv + + +def test_cv_exports_expected_symbols() -> None: + assert hasattr(cv, "DefaultHandlers") + assert hasattr(cv, "DefaultCvProcess") + assert hasattr(cv, "YoloReader") + assert hasattr(cv, "CvBox") diff --git a/tests/apparser/cv/utils/__init__.py b/tests/apparser/cv/utils/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/tests/apparser/cv/utils/__init__.py @@ -0,0 +1 @@ + diff --git a/tests/apparser/cv/utils/test_changes_checker.py b/tests/apparser/cv/utils/test_changes_checker.py new file mode 100644 index 0000000..f205d25 --- /dev/null +++ b/tests/apparser/cv/utils/test_changes_checker.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +from apparser.cv.events import Detected, Moved, Resized, UnDetected +from apparser.cv.models import CvAllData, CvBox +from apparser.cv.utils.changes_checker import ChangesChecker, _is_moved, _is_resized +from tests.utils import FakeUi + + +def test_is_moved_checks_coordinates_difference() -> None: + ui = FakeUi() + first = CvBox("button", 1, 1, 2, 3, 4, ui) + second = CvBox("button", 1, 2, 2, 3, 4, ui) + + assert _is_moved(second, first) is True + + +def test_is_resized_requires_both_dimensions_to_change() -> None: + ui = FakeUi() + first = CvBox("button", 1, 1, 2, 3, 4, ui) + changed = CvBox("button", 1, 1, 2, 5, 6, ui) + only_width = CvBox("button", 1, 1, 2, 5, 4, ui) + + assert _is_resized(changed, first) is True + assert _is_resized(only_width, first) is False + + +def test_changes_checker_reports_detected_moved_resized_and_undetected() -> None: + ui = FakeUi() + checker = ChangesChecker() + old_box = CvBox("button", 1, 1, 2, 3, 4, ui) + checker.check(CvAllData([old_box])) + new_box = CvBox("button", 1, 2, 3, 5, 6, ui) + missing_box = CvBox("icon", 2, 9, 9, 1, 1, ui) + checker.check(CvAllData([new_box, missing_box])) + + next_result = checker.check(CvAllData([new_box])) + + assert next_result[0].event is UnDetected + + +def test_changes_checker_reports_new_and_changed_boxes_in_order() -> None: + ui = FakeUi() + checker = ChangesChecker() + first_box = CvBox("button", 1, 1, 2, 3, 4, ui) + + initial = checker.check(CvAllData([first_box])) + changed_box = CvBox("button", 1, 2, 3, 5, 6, ui) + next_result = checker.check(CvAllData([changed_box])) + + assert initial[0].event is Detected + assert next_result[0].event is Moved + assert next_result[1].event is Resized diff --git a/tests/apparser/exceptions/__init__.py b/tests/apparser/exceptions/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/tests/apparser/exceptions/__init__.py @@ -0,0 +1 @@ + diff --git a/tests/apparser/exceptions/test_debug.py b/tests/apparser/exceptions/test_debug.py new file mode 100644 index 0000000..6a6c446 --- /dev/null +++ b/tests/apparser/exceptions/test_debug.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +from typing import Any + +import pytest + +from apparser.exceptions.debug import DebugException + + +def test_debug_exception_accepts_string() -> None: + error = DebugException("message") + + assert isinstance(error, Exception) + + +@pytest.mark.parametrize("message", [1, None, object()]) +def test_debug_exception_rejects_invalid_message_type(message: Any) -> None: + with pytest.raises(TypeError): + DebugException(message) diff --git a/tests/apparser/exceptions/test_instruction_not_found.py b/tests/apparser/exceptions/test_instruction_not_found.py new file mode 100644 index 0000000..f978360 --- /dev/null +++ b/tests/apparser/exceptions/test_instruction_not_found.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +from apparser.exceptions.instruction_not_found import ( + InstructionNotFoundException, + InstructionWithIdNotFoundException, + InstructionWithNameNotFoundException, +) + + +def test_instruction_not_found_accepts_none_message() -> None: + error = InstructionNotFoundException(None) + + assert isinstance(error, Exception) + + +def test_instruction_with_id_not_found_is_specialized_exception() -> None: + error = InstructionWithIdNotFoundException(7) + + assert isinstance(error, InstructionNotFoundException) + + +def test_instruction_with_name_not_found_is_specialized_exception() -> None: + error = InstructionWithNameNotFoundException("Missing") + + assert isinstance(error, InstructionNotFoundException) diff --git a/tests/apparser/exceptions/test_text_not_found.py b/tests/apparser/exceptions/test_text_not_found.py new file mode 100644 index 0000000..931c684 --- /dev/null +++ b/tests/apparser/exceptions/test_text_not_found.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +from typing import Any + +import pytest + +from apparser.exceptions.text_not_found import TextNotFoundException + + +def test_text_not_found_accepts_valid_similarity() -> None: + error = TextNotFoundException(0.5) + + assert isinstance(error, Exception) + + +@pytest.mark.parametrize("value", [0, "0.5", None, object()]) +def test_text_not_found_rejects_invalid_similarity_type(value: Any) -> None: + with pytest.raises(TypeError): + TextNotFoundException(value) + + +@pytest.mark.parametrize("value", [-0.1, 1.1]) +def test_text_not_found_rejects_out_of_range_similarity(value: float) -> None: + with pytest.raises(ValueError): + TextNotFoundException(float(value)) diff --git a/tests/apparser/exceptions/test_timeout.py b/tests/apparser/exceptions/test_timeout.py new file mode 100644 index 0000000..b886ef3 --- /dev/null +++ b/tests/apparser/exceptions/test_timeout.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +from typing import Any + +import pytest + +from apparser.exceptions.timeout import TimeoutException + + +def test_timeout_exception_accepts_none_wait_time() -> None: + error = TimeoutException() + + assert str(error) == "Timeout error" + + +def test_timeout_exception_accepts_valid_wait_time() -> None: + error = TimeoutException(1.5) + + assert str(error) == "Timeout error. The wait lasted more than 1.5 seconds." + + +@pytest.mark.parametrize("wait_time", ["1", object()]) +def test_timeout_exception_rejects_invalid_wait_time_type( + wait_time: Any, +) -> None: + with pytest.raises(TypeError): + TimeoutException(wait_time) + + +def test_timeout_exception_rejects_negative_wait_time() -> None: + with pytest.raises(ValueError): + TimeoutException(-1) diff --git a/tests/apparser/exceptions/test_window_action_with_desktop.py b/tests/apparser/exceptions/test_window_action_with_desktop.py new file mode 100644 index 0000000..1c637e2 --- /dev/null +++ b/tests/apparser/exceptions/test_window_action_with_desktop.py @@ -0,0 +1,9 @@ +from __future__ import annotations + +from apparser.exceptions.window_action_with_desktop import WindowActionWithDesktopException + + +def test_window_action_with_desktop_exception_is_exception() -> None: + error = WindowActionWithDesktopException() + + assert isinstance(error, Exception) diff --git a/tests/apparser/geometry/__init__.py b/tests/apparser/geometry/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/tests/apparser/geometry/__init__.py @@ -0,0 +1 @@ + diff --git a/tests/apparser/geometry/test_distance.py b/tests/apparser/geometry/test_distance.py new file mode 100644 index 0000000..f17925b --- /dev/null +++ b/tests/apparser/geometry/test_distance.py @@ -0,0 +1,24 @@ +from __future__ import annotations + +from typing import Any + +import pytest +from appwindows.geometry import Point + +from apparser.geometry.distance import distance + + +def test_distance_returns_manhattan_distance() -> None: + assert distance(Point(1, 2), Point(4, 6)) == 7 + + +@pytest.mark.parametrize( + ("first_point", "second_point"), + [ + (object(), Point(1, 1)), + (Point(1, 1), object()), + ], +) +def test_distance_rejects_invalid_points(first_point: Any, second_point: Any) -> None: + with pytest.raises(TypeError): + distance(first_point, second_point) diff --git a/tests/apparser/geometry/test_relatively_point.py b/tests/apparser/geometry/test_relatively_point.py new file mode 100644 index 0000000..40686f0 --- /dev/null +++ b/tests/apparser/geometry/test_relatively_point.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +from typing import Any + +import pytest + +from apparser.geometry.relatively_point import RelativelyPoint + + +def test_relatively_point_stores_coordinates() -> None: + point = RelativelyPoint(1, -1) + + assert point.x == 1 + assert point.y == -1 + + +@pytest.mark.parametrize("value", ["1", object()]) +def test_relatively_point_rejects_invalid_x_type(value: Any) -> None: + with pytest.raises(TypeError): + RelativelyPoint(value, 0) + + +@pytest.mark.parametrize("value", ["1", object()]) +def test_relatively_point_rejects_invalid_y_type(value: Any) -> None: + with pytest.raises(TypeError): + RelativelyPoint(0, value) + + +@pytest.mark.parametrize( + ("x_percent", "y_percent"), + [ + (-1.1, 0), + (1.1, 0), + (0, -1.1), + (0, 1.1), + ], +) +def test_relatively_point_rejects_out_of_range_values(x_percent: float, y_percent: float) -> None: + with pytest.raises(ValueError): + RelativelyPoint(x_percent, y_percent) diff --git a/tests/apparser/instructions/__init__.py b/tests/apparser/instructions/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/tests/apparser/instructions/__init__.py @@ -0,0 +1 @@ + diff --git a/tests/apparser/instructions/debuggers/__init__.py b/tests/apparser/instructions/debuggers/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/tests/apparser/instructions/debuggers/__init__.py @@ -0,0 +1 @@ + diff --git a/tests/apparser/instructions/debuggers/test_base.py b/tests/apparser/instructions/debuggers/test_base.py new file mode 100644 index 0000000..1e25190 --- /dev/null +++ b/tests/apparser/instructions/debuggers/test_base.py @@ -0,0 +1,10 @@ +from __future__ import annotations + +import pytest + +from apparser.instructions.debuggers.base import BaseDebugger + + +def test_base_debugger_is_abstract() -> None: + with pytest.raises(TypeError): + BaseDebugger() diff --git a/tests/apparser/instructions/debuggers/test_default.py b/tests/apparser/instructions/debuggers/test_default.py new file mode 100644 index 0000000..5d234ad --- /dev/null +++ b/tests/apparser/instructions/debuggers/test_default.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +import pytest + +from apparser.exceptions import DebugException +from apparser.instructions.debuggers.default import Debugger +from tests.utils import FakeInstruction + + +def test_debugger_executes_instruction() -> None: + instruction = FakeInstruction() + debugger = Debugger() + + debugger.try_perform(instruction, 1, key="value") + + assert instruction.calls == [{"args": (1,), "kwargs": {"key": "value"}}] + + +def test_debugger_wraps_debug_exception() -> None: + instruction = FakeInstruction(raised_exception=DebugException("boom")) + debugger = Debugger() + + with pytest.raises(DebugException): + debugger.try_perform(instruction) + + +def test_debugger_wraps_generic_exception() -> None: + instruction = FakeInstruction(raised_exception=ValueError("boom")) + debugger = Debugger() + + with pytest.raises(DebugException): + debugger.try_perform(instruction) + + +def test_debugger_clears_context() -> None: + debugger = Debugger() + + debugger.clear_context() diff --git a/tests/apparser/instructions/default/__init__.py b/tests/apparser/instructions/default/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/tests/apparser/instructions/default/__init__.py @@ -0,0 +1 @@ + diff --git a/tests/apparser/instructions/default/test_click.py b/tests/apparser/instructions/default/test_click.py new file mode 100644 index 0000000..2656c49 --- /dev/null +++ b/tests/apparser/instructions/default/test_click.py @@ -0,0 +1,57 @@ +from __future__ import annotations + +import pytest + +from apparser.instructions.default.click import MouseClick, MouseDown, MouseUp +from apparser.key_codes import LeftClick, RightClick +from tests.utils import pyautogui_stub + + +def test_mouse_click_performs_left_click() -> None: + instruction = MouseClick(LeftClick()) + + instruction.perform() + + assert pyautogui_stub.click_calls == 1 + assert instruction.id == 1 + + +def test_mouse_click_performs_right_click() -> None: + instruction = MouseClick(RightClick()) + + instruction.perform() + + assert pyautogui_stub.click_calls == 1 + + +def test_mouse_click_rejects_invalid_click_type() -> None: + with pytest.raises(TypeError): + MouseClick(object()) + + +def test_mouse_down_presses_left_button() -> None: + instruction = MouseDown(LeftClick()) + + instruction.perform() + + assert pyautogui_stub.mouse_down_calls == ["LEFT"] + assert instruction.id == 13 + + +def test_mouse_down_rejects_invalid_click_type() -> None: + with pytest.raises(TypeError): + MouseDown(object()) + + +def test_mouse_up_releases_right_button() -> None: + instruction = MouseUp(RightClick()) + + instruction.perform() + + assert pyautogui_stub.mouse_up_calls == ["RIGHT"] + assert instruction.id == 12 + + +def test_mouse_up_rejects_invalid_click_type() -> None: + with pytest.raises(TypeError): + MouseUp(object()) diff --git a/tests/apparser/instructions/default/test_play_audio.py b/tests/apparser/instructions/default/test_play_audio.py new file mode 100644 index 0000000..5057e09 --- /dev/null +++ b/tests/apparser/instructions/default/test_play_audio.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +from typing import Any + +import numpy +import pytest + +from apparser.instructions.default.play_audio import PlayAudio +from tests.utils import sounddevice_stub + + +@pytest.mark.parametrize( + ("kwargs", "error_type"), + [ + ({"sample_rate": "rate"}, TypeError), + ({"sample_rate": 0}, ValueError), + ({"blocking": "yes"}, TypeError), + ({"device": object()}, TypeError), + ({"audio": numpy.zeros((1, 1, 1), dtype=numpy.float32)}, ValueError), + ({"audio": []}, ValueError), + ], +) +def test_play_audio_validates_arguments( + kwargs: dict[str, Any], + error_type: type[Exception], +) -> None: + default_kwargs = {"audio": [0.1, 0.2]} + default_kwargs.update(kwargs) + + with pytest.raises(error_type): + PlayAudio(**default_kwargs) + + +def test_play_audio_checks_output_and_plays_audio() -> None: + instruction = PlayAudio(audio=[0.1, 0.2], sample_rate=24_000, device=3, blocking=False, latency="low") + + instruction.perform() + + assert sounddevice_stub.check_output_settings_calls == [ + { + "device": 3, + "channels": 1, + "dtype": "float32", + "samplerate": 24_000, + } + ] + assert sounddevice_stub.play_calls[0]["samplerate"] == 24_000 + assert sounddevice_stub.play_calls[0]["device"] == 3 + assert sounddevice_stub.play_calls[0]["blocking"] is False + assert sounddevice_stub.play_calls[0]["latency"] == "low" + + +def test_play_audio_uses_mapping_to_compute_channels() -> None: + instruction = PlayAudio(audio=[[0.1, 0.2], [0.3, 0.4]]) + + instruction.perform(mapping=[2, 1], samplerate=12_000) + + assert sounddevice_stub.check_output_settings_calls[0]["channels"] == 2 + assert sounddevice_stub.check_output_settings_calls[0]["samplerate"] == 12_000 diff --git a/tests/apparser/instructions/default/test_play_audio_file.py b/tests/apparser/instructions/default/test_play_audio_file.py new file mode 100644 index 0000000..2daf22c --- /dev/null +++ b/tests/apparser/instructions/default/test_play_audio_file.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +from types import SimpleNamespace +from typing import Any + +import numpy +import pytest + +from apparser.instructions.default.play_audio_file import PlayAudioFile +from tests.utils import create_temp_audio_path, create_wave_file + + +def test_play_audio_file_validates_path_type() -> None: + with pytest.raises(TypeError): + PlayAudioFile(1) + + +@pytest.mark.parametrize("path", ["", "missing.wav"]) +def test_play_audio_file_validates_path_value(path: str) -> None: + with pytest.raises(ValueError): + PlayAudioFile(path) + + +def test_play_audio_file_reads_audio_and_builds_instruction( + monkeypatch: pytest.MonkeyPatch, +) -> None: + file_path = create_wave_file( + create_temp_audio_path("audio.wav"), + bytes([0, 255]), + sample_width=1, + sample_rate=8_000, + ) + created: list[dict[str, Any]] = [] + + def fake_play_audio(**kwargs: Any) -> SimpleNamespace: + created.append({"init": kwargs}) + return SimpleNamespace(perform=lambda *args, **kwargs2: created.append({"perform": kwargs2})) + + monkeypatch.setattr("apparser.instructions.default.play_audio_file.PlayAudio", fake_play_audio) + instruction = PlayAudioFile(str(file_path), device=4, blocking=False) + + instruction.perform(volume=0.5) + + assert created[0]["init"]["sample_rate"] == 8_000 + assert created[0]["init"]["device"] == 4 + assert created[0]["init"]["blocking"] is False + assert numpy.allclose(created[0]["init"]["audio"], numpy.asarray([-1.0, 0.9921875], dtype=numpy.float32)) + assert created[1] == {"perform": {"volume": 0.5}} + + +def test_play_audio_file_rejects_unsupported_sample_width() -> None: + file_path = create_wave_file(create_temp_audio_path("audio.wav"), b"\x00\x00\x00", sample_width=3) + + with pytest.raises(ValueError): + PlayAudioFile(str(file_path)) diff --git a/tests/apparser/instructions/default/test_press.py b/tests/apparser/instructions/default/test_press.py new file mode 100644 index 0000000..81d3425 --- /dev/null +++ b/tests/apparser/instructions/default/test_press.py @@ -0,0 +1,62 @@ +from __future__ import annotations + +import pytest + +from apparser.instructions.default.press import ( + PressKey, + PressKeyDown, + PressKeysCombination, + PressKeyUp, +) +from apparser.key_codes import Control +from tests.utils import pyautogui_stub + + +def test_press_key_sends_key() -> None: + instruction = PressKey(Control()) + instruction.perform() + assert pyautogui_stub.send_calls == ["ctrl"] + assert instruction.id == 2 + + +def test_press_key_rejects_invalid_key() -> None: + with pytest.raises(TypeError): + PressKey(object()) + + +def test_press_keys_combination_presses_and_releases_keys() -> None: + instruction = PressKeysCombination([Control(), "a"]) + instruction.perform() + assert pyautogui_stub.press_calls == ["ctrl", "a"] + assert pyautogui_stub.release_calls == ["ctrl", "a"] + assert instruction.id == 3 + + +def test_press_keys_combination_rejects_invalid_key_on_perform() -> None: + instruction = PressKeysCombination([object()]) + with pytest.raises(TypeError): + instruction.perform() + + +def test_press_key_down_sends_key_down() -> None: + instruction = PressKeyDown(Control()) + instruction.perform() + assert pyautogui_stub.press_calls == ["ctrl"] + assert instruction.id == 10 + + +def test_press_key_down_rejects_invalid_key() -> None: + with pytest.raises(TypeError): + PressKeyDown(object()) + + +def test_press_key_up_releases_key() -> None: + instruction = PressKeyUp("a") + instruction.perform() + assert pyautogui_stub.release_calls == ["a"] + assert instruction.id == 11 + + +def test_press_key_up_rejects_invalid_key() -> None: + with pytest.raises(TypeError): + PressKeyUp(object()) diff --git a/tests/apparser/instructions/default/test_say_audio.py b/tests/apparser/instructions/default/test_say_audio.py new file mode 100644 index 0000000..9a10625 --- /dev/null +++ b/tests/apparser/instructions/default/test_say_audio.py @@ -0,0 +1,48 @@ +from __future__ import annotations + +from typing import Any + +import numpy +import pytest + +from apparser.instructions.default.say_audio import SayAudio +from tests.utils import sounddevice_stub + + +@pytest.mark.parametrize( + ("kwargs", "error_type"), + [ + ({"sample_rate": "rate"}, TypeError), + ({"sample_rate": 0}, ValueError), + ({"blocking": "yes"}, TypeError), + ({"microphone_device": object()}, TypeError), + ({"audio": numpy.zeros((1, 1, 1), dtype=numpy.float32)}, ValueError), + ({"audio": []}, ValueError), + ], +) +def test_say_audio_validates_arguments( + kwargs: dict[str, Any], + error_type: type[Exception], +) -> None: + default_kwargs = {"audio": [0.1, 0.2]} + default_kwargs.update(kwargs) + + with pytest.raises(error_type): + SayAudio(**default_kwargs) + + +def test_say_audio_requires_microphone_device() -> None: + instruction = SayAudio(audio=[0.1, 0.2]) + + with pytest.raises(ValueError): + instruction.perform() + + +def test_say_audio_checks_output_and_plays_audio() -> None: + instruction = SayAudio(audio=[0.1, 0.2], microphone_device=7, blocking=False) + + instruction.perform(mapping=[1]) + + assert sounddevice_stub.check_output_settings_calls[0]["device"] == 7 + assert sounddevice_stub.play_calls[0]["device"] == 7 + assert sounddevice_stub.play_calls[0]["blocking"] is False diff --git a/tests/apparser/instructions/default/test_say_audio_file.py b/tests/apparser/instructions/default/test_say_audio_file.py new file mode 100644 index 0000000..cee4c32 --- /dev/null +++ b/tests/apparser/instructions/default/test_say_audio_file.py @@ -0,0 +1,54 @@ +from __future__ import annotations + +from types import SimpleNamespace +from typing import Any + +import numpy +import pytest + +from apparser.instructions.default.say_audio_file import SayAudioFile +from tests.utils import create_temp_audio_path, create_wave_file + + +def test_say_audio_file_validates_path_type() -> None: + with pytest.raises(TypeError): + SayAudioFile(1) + + +@pytest.mark.parametrize("path", ["", "missing.wav"]) +def test_say_audio_file_validates_path_value(path: str) -> None: + with pytest.raises(ValueError): + SayAudioFile(path) + + +def test_say_audio_file_reads_audio_and_builds_instruction( + monkeypatch: pytest.MonkeyPatch, +) -> None: + file_path = create_wave_file( + create_temp_audio_path("audio.wav"), + b"\x00\x00\xff\x7f", + sample_width=2, + sample_rate=16_000, + ) + created: list[dict[str, Any]] = [] + + def fake_say_audio(**kwargs: Any) -> SimpleNamespace: + created.append({"init": kwargs}) + return SimpleNamespace(perform=lambda *args, **kwargs2: created.append({"perform": kwargs2})) + + monkeypatch.setattr("apparser.instructions.default.say_audio_file.SayAudio", fake_say_audio) + instruction = SayAudioFile(str(file_path), microphone_device=5) + + instruction.perform(loop=True) + + assert created[0]["init"]["sample_rate"] == 16_000 + assert created[0]["init"]["microphone_device"] == 5 + assert numpy.allclose(created[0]["init"]["audio"], numpy.asarray([0.0, 0.9999695], dtype=numpy.float32)) + assert created[1] == {"perform": {"loop": True}} + + +def test_say_audio_file_rejects_unsupported_sample_width() -> None: + file_path = create_wave_file(create_temp_audio_path("audio.wav"), b"\x00\x00\x00", sample_width=3) + + with pytest.raises(ValueError): + SayAudioFile(str(file_path)) diff --git a/tests/apparser/instructions/default/test_sleep.py b/tests/apparser/instructions/default/test_sleep.py new file mode 100644 index 0000000..c681cba --- /dev/null +++ b/tests/apparser/instructions/default/test_sleep.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +import pytest + +from apparser.instructions.default.sleep import Sleep + + +def test_sleep_rejects_non_positive_time() -> None: + with pytest.raises(ValueError): + Sleep(0) + + +def test_sleep_calls_time_sleep(monkeypatch: pytest.MonkeyPatch) -> None: + sleep_calls: list[float] = [] + instruction = Sleep(0.3) + monkeypatch.setattr("apparser.instructions.default.sleep.time.sleep", lambda value: sleep_calls.append(value)) + + instruction.perform() + + assert sleep_calls == [0.3] + assert instruction.id == 9 diff --git a/tests/apparser/instructions/default/test_write_text.py b/tests/apparser/instructions/default/test_write_text.py new file mode 100644 index 0000000..dd39244 --- /dev/null +++ b/tests/apparser/instructions/default/test_write_text.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +from typing import Any + +import pytest + +from apparser.instructions.default.write_text import WriteText +from tests.utils import pyautogui_stub + + +@pytest.mark.parametrize( + ("text", "pause_time", "error_type"), + [ + (1, 0.1, TypeError), + ("text", "0.1", TypeError), + ("", 0.1, ValueError), + ], +) +def test_write_text_validates_arguments(text: Any, pause_time: Any, error_type: type[Exception]) -> None: + with pytest.raises(error_type): + WriteText(text, pause_time) + + +def test_write_text_uses_keyboard_backend() -> None: + instruction = WriteText("hello", 0.2) + + instruction.perform() + + assert pyautogui_stub.write_calls == [("hello", 0.2)] + assert instruction.id == 4 diff --git a/tests/apparser/instructions/ocr/__init__.py b/tests/apparser/instructions/ocr/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/tests/apparser/instructions/ocr/__init__.py @@ -0,0 +1 @@ + diff --git a/tests/apparser/instructions/ocr/test_base.py b/tests/apparser/instructions/ocr/test_base.py new file mode 100644 index 0000000..553ef0b --- /dev/null +++ b/tests/apparser/instructions/ocr/test_base.py @@ -0,0 +1,10 @@ +from __future__ import annotations + +import pytest + +from apparser.instructions.ocr.base import OCRInstruction + + +def test_ocr_instruction_is_abstract() -> None: + with pytest.raises(TypeError): + OCRInstruction() diff --git a/tests/apparser/instructions/ocr/test_click_on_text.py b/tests/apparser/instructions/ocr/test_click_on_text.py new file mode 100644 index 0000000..22c9638 --- /dev/null +++ b/tests/apparser/instructions/ocr/test_click_on_text.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +from appwindows.geometry import Point + +from apparser.instructions.ocr.click_on_text import ClickOnText +from tests.utils import FakeTextReader, FakeUi + + +def test_click_on_text_moves_sleeps_and_clicks(monkeypatch: pytest.MonkeyPatch) -> None: + calls: list[str] = [] + instruction = ClickOnText("hello", offset=Point(1, 2)) + monkeypatch.setattr( + "apparser.instructions.ocr.click_on_text.MoveToText.perform", + lambda self, ui, ocr, *args, **kwargs: calls.append("move"), + ) + monkeypatch.setattr( + "apparser.instructions.ocr.click_on_text.Sleep.perform", + lambda self, *args, **kwargs: calls.append("sleep"), + ) + monkeypatch.setattr( + "apparser.instructions.ocr.click_on_text.MouseClick.perform", + lambda self, *args, **kwargs: calls.append("click"), + ) + + instruction.perform(FakeUi(), FakeTextReader()) + + assert calls == ["move", "sleep", "click"] + assert instruction.id == 2002 diff --git a/tests/apparser/instructions/ocr/test_move_to_text.py b/tests/apparser/instructions/ocr/test_move_to_text.py new file mode 100644 index 0000000..87b66e2 --- /dev/null +++ b/tests/apparser/instructions/ocr/test_move_to_text.py @@ -0,0 +1,72 @@ +from __future__ import annotations + +import pytest +from appwindows.geometry import Point + +from apparser.exceptions import TextNotFoundException +from apparser.instructions.ocr.move_to_text import MoveToText +from apparser.instructions.ocr.text_getter import GetText +from apparser.text_readers import TextData +from tests.utils import FakeTextReader, FakeUi + + +def test_move_to_text_finds_best_match() -> None: + instruction = MoveToText("hello") + texts = [ + TextData("other", [Point(0, 0)]), + TextData("hello", [Point(1, 1)]), + ] + + found, rating = instruction.find_text(texts) + + assert found.text == "hello" + assert rating == 1.0 + + +def test_move_to_text_raises_when_no_texts_exist() -> None: + instruction = MoveToText("hello") + + with pytest.raises(TextNotFoundException): + instruction.find_text([]) + + +def test_move_to_text_rejects_low_similarity(monkeypatch: pytest.MonkeyPatch) -> None: + getter = GetText() + getter._GetText__local_answer = [ + TextData("hello", [Point(0, 0), Point(2, 0), Point(2, 2), Point(0, 2)]) + ] + monkeypatch.setattr(getter, "perform", lambda ui, text_reader: None) + instruction = MoveToText("hello", min_similarity=0.5, text_getter=getter) + monkeypatch.setattr( + instruction, + "find_text", + lambda texts: (texts[0], 0.1), + ) + + with pytest.raises(TextNotFoundException): + instruction.perform(FakeUi(), FakeTextReader()) + + +def test_move_to_text_moves_to_text_center(monkeypatch: pytest.MonkeyPatch) -> None: + getter = GetText() + getter._GetText__local_answer = [ + TextData("hello", [Point(0, 0), Point(4, 0), Point(4, 4), Point(0, 4)]) + ] + monkeypatch.setattr(getter, "perform", lambda ui, text_reader: None) + moved_to: list[Point] = [] + monkeypatch.setattr( + "apparser.instructions.ocr.move_to_text.MouseMove.perform", + lambda self, ui, *args, **kwargs: moved_to.append(self._MouseMove__coordinates), + ) + instruction = MoveToText("hello", offset=Point(1, -1), text_getter=getter) + monkeypatch.setattr( + instruction, + "find_text", + lambda texts: (texts[0], 1.0), + ) + + instruction.perform(FakeUi(), FakeTextReader()) + + assert moved_to == [Point(3, 1)] + assert instruction.id == 2001 + assert instruction.text == "hello" diff --git a/tests/apparser/instructions/ocr/test_plot_text.py b/tests/apparser/instructions/ocr/test_plot_text.py new file mode 100644 index 0000000..e440f4a --- /dev/null +++ b/tests/apparser/instructions/ocr/test_plot_text.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +from unittest.mock import Mock + +import numpy +from appwindows.geometry import Point + +from apparser.instructions.ocr.plot_text import PlotAllText, _Painter +from apparser.instructions.ocr.text_getter import GetText +from apparser.text_readers import TextData +from tests.utils import FakeTextReader, FakeUi + + +def test_painter_draws_rectangle_and_text() -> None: + draw = Mock() + painter = _Painter(draw, (255, 255, 255, 255)) + data = TextData("hello", [Point(1, 2), Point(3, 2), Point(3, 4), Point(1, 4)]) + + painter.draw([data]) + + draw.rectangle.assert_called_once() + draw.text.assert_called_once() + + +def test_plot_all_text_draws_and_shows_image(monkeypatch: pytest.MonkeyPatch) -> None: + getter = GetText() + getter._GetText__global_answer = [ + TextData("hello", [Point(1, 2), Point(3, 2), Point(3, 4), Point(1, 4)]) + ] + getter._GetText__screenshot = numpy.zeros((4, 4, 3), dtype=numpy.uint8) + monkeypatch.setattr(getter, "perform", lambda ui, text_reader: None) + painter_calls: list[list[TextData]] = [] + shown: list[bool] = [] + monkeypatch.setattr("apparser.instructions.ocr.plot_text._Painter.draw", lambda self, texts: painter_calls.append(texts)) + monkeypatch.setattr("PIL.Image.Image.show", lambda self: shown.append(True)) + instruction = PlotAllText(text_getter=getter) + + instruction.perform(FakeUi(), FakeTextReader()) + + assert painter_calls == [getter.global_answer] + assert shown == [True] + assert instruction.id == 2004 diff --git a/tests/apparser/instructions/ocr/test_print_all_text.py b/tests/apparser/instructions/ocr/test_print_all_text.py new file mode 100644 index 0000000..06ecae1 --- /dev/null +++ b/tests/apparser/instructions/ocr/test_print_all_text.py @@ -0,0 +1,24 @@ +from __future__ import annotations + +from appwindows.geometry import Point + +from apparser.instructions.ocr.print_all_text import PrintAllText +from apparser.instructions.ocr.text_getter import GetText +from apparser.text_readers import TextData +from tests.utils import FakeTextReader, FakeUi + + +def test_print_all_text_prints_each_entry(monkeypatch: pytest.MonkeyPatch) -> None: + getter = GetText() + getter._GetText__local_answer = [ + TextData("hello", [Point(1, 2), Point(3, 4)]), + ] + printed: list[str] = [] + monkeypatch.setattr(getter, "perform", lambda ui, text_reader: None) + monkeypatch.setattr("builtins.print", lambda value: printed.append(value)) + instruction = PrintAllText(text_getter=getter) + + instruction.perform(FakeUi(), FakeTextReader()) + + assert printed == ['text: "hello", coordinates: Point(x = 1, y = 2) Point(x = 3, y = 4) '] + assert instruction.id == 2003 diff --git a/tests/apparser/instructions/ocr/test_text_getter.py b/tests/apparser/instructions/ocr/test_text_getter.py new file mode 100644 index 0000000..a18e96a --- /dev/null +++ b/tests/apparser/instructions/ocr/test_text_getter.py @@ -0,0 +1,47 @@ +from __future__ import annotations + +import numpy +from appwindows.geometry import Point + +from apparser.geometry import RelativelyPoint +from apparser.instructions.ocr.text_getter import GetText +from apparser.text_readers import TextData +from tests.utils import FakeTextReader, FakeUi + + +def test_text_getter_reads_and_converts_coordinates() -> None: + screenshot = numpy.arange(300, dtype=numpy.uint8).reshape(10, 10, 3) + reader = FakeTextReader( + result=[ + TextData( + "hello", + [Point(0, 0), Point(1, 0), Point(1, 1), Point(0, 1)], + ) + ] + ) + instruction = GetText(Point(1, 1), Point(4, 3)) + ui = FakeUi(screenshot=screenshot) + + instruction.perform(ui, reader) + + assert reader.images[0].shape == (2, 3, 3) + assert instruction.global_answer[0].coordinates[0] == Point(0, 0) + assert instruction.local_answer[0].coordinates[0] == Point(1, 1) + assert instruction.screenshot.shape == (2, 3, 3) + + +def test_text_getter_respects_cached_result() -> None: + reader = FakeTextReader( + result=[TextData("hello", [Point(0, 0), Point(1, 0), Point(1, 1), Point(0, 1)])] + ) + instruction = GetText( + RelativelyPoint(0, 0), + RelativelyPoint(1, 1), + reload_every_try=False, + ) + ui = FakeUi() + + instruction.perform(ui, reader) + instruction.perform(ui, reader) + + assert len(reader.images) == 1 diff --git a/tests/apparser/instructions/ocr/test_wait_text.py b/tests/apparser/instructions/ocr/test_wait_text.py new file mode 100644 index 0000000..0b5269c --- /dev/null +++ b/tests/apparser/instructions/ocr/test_wait_text.py @@ -0,0 +1,64 @@ +from __future__ import annotations + +import pytest +from appwindows.geometry import Point + +from apparser.exceptions import TimeoutException +from apparser.instructions.ocr.text_getter import GetText +from apparser.instructions.ocr.wait_text import WaitText +from apparser.text_readers import TextData +from tests.utils import FakeTextReader, FakeUi + + +def test_wait_text_returns_when_text_is_found() -> None: + reader = FakeTextReader( + result=[ + TextData( + "hello", + [Point(0, 0), Point(1, 0), Point(1, 1), Point(0, 1)], + ), + ], + ) + instruction = WaitText("hello", interval=0, expire_time=1) + + instruction.perform(FakeUi(), reader) + + assert len(reader.images) == 1 + assert instruction.id == 2005 + assert instruction.text == "hello" + + +def test_wait_text_raises_timeout( + monkeypatch: pytest.MonkeyPatch, +) -> None: + times = iter([0.0, 2.0]) + getter = GetText() + getter._GetText__local_answer = [] + + def get_time() -> float: + return next(times) + + def perform(ui: object, text_reader: object) -> None: + return None + + def sleep(interval: float | int) -> None: + return None + + monkeypatch.setattr( + "apparser.instructions.ocr.wait_text.time.time", + get_time, + ) + monkeypatch.setattr( + "apparser.instructions.ocr.wait_text.time.sleep", + sleep, + ) + monkeypatch.setattr(getter, "perform", perform) + instruction = WaitText( + "missing", + text_getter=getter, + interval=0, + expire_time=1, + ) + + with pytest.raises(TimeoutException): + instruction.perform(FakeUi(), FakeTextReader()) diff --git a/tests/apparser/instructions/speak/__init__.py b/tests/apparser/instructions/speak/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/tests/apparser/instructions/speak/__init__.py @@ -0,0 +1 @@ + diff --git a/tests/apparser/instructions/speak/test_base.py b/tests/apparser/instructions/speak/test_base.py new file mode 100644 index 0000000..e7fff66 --- /dev/null +++ b/tests/apparser/instructions/speak/test_base.py @@ -0,0 +1,10 @@ +from __future__ import annotations + +import pytest + +from apparser.instructions.speak.base import SpeakInstruction + + +def test_speak_instruction_is_abstract() -> None: + with pytest.raises(TypeError): + SpeakInstruction() diff --git a/tests/apparser/instructions/speak/test_play_text.py b/tests/apparser/instructions/speak/test_play_text.py new file mode 100644 index 0000000..00f35be --- /dev/null +++ b/tests/apparser/instructions/speak/test_play_text.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +from types import SimpleNamespace +from typing import Any + +import numpy +import pytest + +from apparser.instructions.speak.play_text import PlayTextAudio +from tests.utils import FakeSpeaker, FakeUi + + +@pytest.mark.parametrize("text", [1, None]) +def test_play_text_audio_rejects_invalid_text_type(text: Any) -> None: + with pytest.raises(TypeError): + PlayTextAudio(text) + + +def test_play_text_audio_rejects_empty_text() -> None: + with pytest.raises(ValueError): + PlayTextAudio("") + + +def test_play_text_audio_generates_audio_and_plays(monkeypatch: pytest.MonkeyPatch) -> None: + speaker = FakeSpeaker(result=(numpy.asarray([0.1, 0.2], dtype=numpy.float32), 16_000)) + created: list[dict[str, Any]] = [] + + def fake_play_audio(**kwargs: Any) -> SimpleNamespace: + created.append(kwargs) + return SimpleNamespace(perform=lambda *args, **kwargs2: created.append(kwargs2)) + + monkeypatch.setattr("apparser.instructions.speak.play_text.PlayAudio", fake_play_audio) + instruction = PlayTextAudio("hello", sample_rate=16_000, device=2) + + instruction.perform(speaker, volume=0.5) + + assert speaker.calls == ["hello"] + assert created[0]["sample_rate"] == 16_000 + assert numpy.array_equal(created[0]["audio"], numpy.asarray([0.1, 0.2], dtype=numpy.float32)) + assert created[1] == {"volume": 0.5} diff --git a/tests/apparser/instructions/speak/test_say_text.py b/tests/apparser/instructions/speak/test_say_text.py new file mode 100644 index 0000000..b0eaaaf --- /dev/null +++ b/tests/apparser/instructions/speak/test_say_text.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +from types import SimpleNamespace +from typing import Any + +import numpy +import pytest + +from apparser.instructions.speak.say_text import SayTextAudio +from tests.utils import FakeSpeaker, FakeUi + + +@pytest.mark.parametrize("text", [1, None]) +def test_say_text_audio_rejects_invalid_text_type(text: Any) -> None: + with pytest.raises(TypeError): + SayTextAudio(text) + + +def test_say_text_audio_rejects_empty_text() -> None: + with pytest.raises(ValueError): + SayTextAudio("") + + +def test_say_text_audio_generates_audio_and_plays(monkeypatch: pytest.MonkeyPatch) -> None: + speaker = FakeSpeaker(result=(numpy.asarray([0.3, 0.4], dtype=numpy.float32), 22_050)) + created: list[dict[str, Any]] = [] + + def fake_say_audio(**kwargs: Any) -> SimpleNamespace: + created.append(kwargs) + return SimpleNamespace(perform=lambda *args, **kwargs2: created.append(kwargs2)) + + monkeypatch.setattr("apparser.instructions.speak.say_text.SayAudio", fake_say_audio) + instruction = SayTextAudio("hello", sample_rate=22_050, microphone_device=3) + + instruction.perform(speaker, blocking=False) + + assert speaker.calls == ["hello"] + assert created[0]["sample_rate"] == 22_050 + assert numpy.array_equal(created[0]["audio"], numpy.asarray([0.3, 0.4], dtype=numpy.float32)) + assert created[1] == {"blocking": False} diff --git a/tests/apparser/instructions/test_base.py b/tests/apparser/instructions/test_base.py new file mode 100644 index 0000000..50a51f2 --- /dev/null +++ b/tests/apparser/instructions/test_base.py @@ -0,0 +1,10 @@ +from __future__ import annotations + +import pytest + +from apparser.instructions.base import BaseInstruction + + +def test_base_instruction_is_abstract() -> None: + with pytest.raises(TypeError): + BaseInstruction() diff --git a/tests/apparser/instructions/ui/__init__.py b/tests/apparser/instructions/ui/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/tests/apparser/instructions/ui/__init__.py @@ -0,0 +1 @@ + diff --git a/tests/apparser/instructions/ui/algorithms/__init__.py b/tests/apparser/instructions/ui/algorithms/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/tests/apparser/instructions/ui/algorithms/__init__.py @@ -0,0 +1 @@ + diff --git a/tests/apparser/instructions/ui/algorithms/test_algorithm.py b/tests/apparser/instructions/ui/algorithms/test_algorithm.py new file mode 100644 index 0000000..8ca1636 --- /dev/null +++ b/tests/apparser/instructions/ui/algorithms/test_algorithm.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +from typing import Any + +import pytest + +from apparser.instructions.ui.algorithms.algorithm import Algorithm +from tests.utils import FakeDebugger, FakeInstruction, FakeUi + + +def test_algorithm_rejects_invalid_debugger() -> None: + with pytest.raises(TypeError): + Algorithm([], debugger="debugger") + + +def test_algorithm_uses_default_debugger_when_requested(monkeypatch: pytest.MonkeyPatch) -> None: + debugger = FakeDebugger() + instruction = FakeInstruction(1) + monkeypatch.setattr("apparser.instructions.ui.algorithms.algorithm.Debugger", lambda: debugger) + algorithm = Algorithm([instruction], debugger=True) + + algorithm.perform(FakeUi()) + + assert debugger.clear_calls == 1 + assert debugger.try_calls[0]["instruction"] is instruction + + +def test_algorithm_performs_instructions_without_debugger() -> None: + instruction = FakeInstruction(1) + ui = FakeUi() + algorithm = Algorithm([instruction], debugger=False) + + algorithm.perform(ui) + + assert ui.window.to_foreground_calls == 1 + assert instruction.calls == [{"args": (ui,), "kwargs": {}}] + + +def test_algorithm_rejects_invalid_instruction_on_perform() -> None: + algorithm = Algorithm([FakeInstruction(2000)], debugger=False) + + with pytest.raises(TypeError): + algorithm.perform(FakeUi()) + + +def test_algorithm_add_instruction_validates_type() -> None: + algorithm = Algorithm([], debugger=False) + + with pytest.raises(TypeError): + algorithm.add_instruction(object()) + + +def test_algorithm_add_instruction_appends_instruction() -> None: + algorithm = Algorithm([], debugger=False) + instruction = FakeInstruction(1) + + algorithm.add_instruction(instruction) + + assert algorithm.instructions == [instruction] diff --git a/tests/apparser/instructions/ui/algorithms/test_base.py b/tests/apparser/instructions/ui/algorithms/test_base.py new file mode 100644 index 0000000..3457e37 --- /dev/null +++ b/tests/apparser/instructions/ui/algorithms/test_base.py @@ -0,0 +1,10 @@ +from __future__ import annotations + +import pytest + +from apparser.instructions.ui.algorithms.base import BaseAlgorithm + + +def test_base_algorithm_is_abstract() -> None: + with pytest.raises(TypeError): + BaseAlgorithm() diff --git a/tests/apparser/instructions/ui/algorithms/test_ids.py b/tests/apparser/instructions/ui/algorithms/test_ids.py new file mode 100644 index 0000000..2ac209e --- /dev/null +++ b/tests/apparser/instructions/ui/algorithms/test_ids.py @@ -0,0 +1,78 @@ +from __future__ import annotations + +from typing import Any + +import pytest + +from apparser.instructions.ui.algorithms.ids import IdsAlgorithm, _check_instruction +from tests.utils import FakeDebugger, FakeInstruction, FakeUi + + +@pytest.mark.parametrize( + ("instruction", "error_type"), + [ + ("value", TypeError), + ((1, "args"), TypeError), + (("1", []), TypeError), + ], +) +def test_check_instruction_validates_structure( + instruction: Any, + error_type: type[Exception], +) -> None: + with pytest.raises(error_type): + _check_instruction(instruction) + + +def test_ids_algorithm_rejects_invalid_debugger() -> None: + with pytest.raises(TypeError): + IdsAlgorithm([], [], debugger="debugger") + + +def test_ids_algorithm_performs_resolved_instructions_without_debugger( + monkeypatch: pytest.MonkeyPatch, +) -> None: + created: list[FakeInstruction] = [] + + def factory(*args: Any) -> FakeInstruction: + instruction = FakeInstruction(1) + created.append(instruction) + return instruction + + monkeypatch.setattr("apparser.instructions.ui.algorithms.ids.get_instruction_by_id", lambda instruction_id: factory) + ui = FakeUi() + algorithm = IdsAlgorithm([(10, ["hello"])], [], debugger=False) + + algorithm.perform(ui) + + assert ui.window.to_foreground_calls == 1 + + +def test_ids_algorithm_uses_debugger(monkeypatch: pytest.MonkeyPatch) -> None: + debugger = FakeDebugger(call_inner=False) + monkeypatch.setattr( + "apparser.instructions.ui.algorithms.ids.get_instruction_by_id", + lambda instruction_id: lambda *args: FakeInstruction(1), + ) + algorithm = IdsAlgorithm([(10, [])], [], debugger=debugger) + + algorithm.perform(FakeUi()) + + assert debugger.clear_calls == 1 + assert len(debugger.try_calls) == 1 + + +def test_ids_algorithm_raises_when_instruction_is_missing(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr("apparser.instructions.ui.algorithms.ids.get_instruction_by_id", lambda instruction_id: None) + algorithm = IdsAlgorithm([(10, [])], [], debugger=False) + + with pytest.raises(ValueError): + algorithm.perform(FakeUi()) + + +def test_ids_algorithm_add_instruction_appends_value() -> None: + algorithm = IdsAlgorithm([], [], debugger=False) + + algorithm.add_instruction((1, [])) + + assert algorithm.instructions == [(1, [])] diff --git a/tests/apparser/instructions/ui/algorithms/test_names.py b/tests/apparser/instructions/ui/algorithms/test_names.py new file mode 100644 index 0000000..ed1c0fe --- /dev/null +++ b/tests/apparser/instructions/ui/algorithms/test_names.py @@ -0,0 +1,78 @@ +from __future__ import annotations + +from typing import Any + +import pytest + +from apparser.instructions.ui.algorithms.names import NamesAlgorithm, _check_instruction +from tests.utils import FakeDebugger, FakeInstruction, FakeUi + + +@pytest.mark.parametrize( + ("instruction", "error_type"), + [ + ("value", TypeError), + (("name", "args"), TypeError), + ((1, []), TypeError), + ], +) +def test_check_instruction_validates_name_structure( + instruction: Any, + error_type: type[Exception], +) -> None: + with pytest.raises(error_type): + _check_instruction(instruction) + + +def test_names_algorithm_rejects_invalid_debugger() -> None: + with pytest.raises(TypeError): + NamesAlgorithm([], [], debugger="debugger") + + +def test_names_algorithm_performs_resolved_instructions_without_debugger( + monkeypatch: pytest.MonkeyPatch, +) -> None: + created: list[FakeInstruction] = [] + + def factory(*args: Any) -> FakeInstruction: + instruction = FakeInstruction(1) + created.append(instruction) + return instruction + + monkeypatch.setattr("apparser.instructions.ui.algorithms.names.get_instruction_by_name", lambda name: factory) + ui = FakeUi() + algorithm = NamesAlgorithm([("WriteText", ["hello"])], [], debugger=False) + + algorithm.perform(ui) + + assert ui.window.to_foreground_calls == 1 + + +def test_names_algorithm_uses_debugger(monkeypatch: pytest.MonkeyPatch) -> None: + debugger = FakeDebugger(call_inner=False) + monkeypatch.setattr( + "apparser.instructions.ui.algorithms.names.get_instruction_by_name", + lambda name: lambda *args: FakeInstruction(1), + ) + algorithm = NamesAlgorithm([("Click", [])], [], debugger=debugger) + + algorithm.perform(FakeUi()) + + assert debugger.clear_calls == 1 + assert len(debugger.try_calls) == 1 + + +def test_names_algorithm_raises_when_instruction_is_missing(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr("apparser.instructions.ui.algorithms.names.get_instruction_by_name", lambda name: None) + algorithm = NamesAlgorithm([("Missing", [])], [], debugger=False) + + with pytest.raises(ValueError): + algorithm.perform(FakeUi()) + + +def test_names_algorithm_add_instruction_appends_value() -> None: + algorithm = NamesAlgorithm([], [], debugger=False) + + algorithm.add_instruction(("Name", [])) + + assert algorithm.instructions == [("Name", [])] diff --git a/tests/apparser/instructions/ui/algorithms/test_ocr.py b/tests/apparser/instructions/ui/algorithms/test_ocr.py new file mode 100644 index 0000000..15b89d7 --- /dev/null +++ b/tests/apparser/instructions/ui/algorithms/test_ocr.py @@ -0,0 +1,64 @@ +from __future__ import annotations + +import pytest + +from apparser.instructions.ui.algorithms.ocr import OCRAlgorithm +from tests.utils import FakeDebugger, FakeOcrInstruction, FakeTextReader, FakeUi + + +def test_ocr_algorithm_rejects_invalid_text_reader() -> None: + with pytest.raises(TypeError): + OCRAlgorithm([], text_reader=object(), debugger=False) + + +def test_ocr_algorithm_creates_default_text_reader() -> None: + algorithm = OCRAlgorithm([], debugger=False) + + assert algorithm.instructions == [] + + +def test_ocr_algorithm_performs_instructions(monkeypatch: pytest.MonkeyPatch) -> None: + instruction = FakeOcrInstruction(2000) + reader = FakeTextReader() + ui = FakeUi() + algorithm = OCRAlgorithm([instruction], text_reader=reader, debugger=False) + + algorithm.perform(ui) + + assert ui.window.to_foreground_calls == 1 + assert instruction.calls == [{"args": (ui, reader), "kwargs": {}}] + + +def test_ocr_algorithm_uses_debugger() -> None: + debugger = FakeDebugger(call_inner=False) + instruction = FakeOcrInstruction(2000) + reader = FakeTextReader() + algorithm = OCRAlgorithm([instruction], text_reader=reader, debugger=debugger) + + algorithm.perform(FakeUi()) + + assert debugger.clear_calls == 1 + assert debugger.try_calls[0]["instruction"] is instruction + + +def test_ocr_algorithm_rejects_invalid_instruction_on_perform() -> None: + algorithm = OCRAlgorithm([object()], text_reader=FakeTextReader(), debugger=False) + + with pytest.raises(TypeError): + algorithm.perform(FakeUi()) + + +def test_ocr_algorithm_add_instruction_validates_type() -> None: + algorithm = OCRAlgorithm([], text_reader=FakeTextReader(), debugger=False) + + with pytest.raises(TypeError): + algorithm.add_instruction(object()) + + +def test_ocr_algorithm_add_instruction_appends_instruction() -> None: + algorithm = OCRAlgorithm([], text_reader=FakeTextReader(), debugger=False) + instruction = FakeOcrInstruction(2000) + + algorithm.add_instruction(instruction) + + assert algorithm.instructions == [instruction] diff --git a/tests/apparser/instructions/ui/algorithms/test_speak.py b/tests/apparser/instructions/ui/algorithms/test_speak.py new file mode 100644 index 0000000..0efff8e --- /dev/null +++ b/tests/apparser/instructions/ui/algorithms/test_speak.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +import pytest + +from apparser.instructions.ui.algorithms.speak import SpeakAlgorithm +from tests.utils import FakeDebugger, FakeSpeaker, FakeSpeakInstruction, FakeUi + + +def test_speak_algorithm_rejects_invalid_speaker() -> None: + with pytest.raises(TypeError): + SpeakAlgorithm([], speaker=object(), debugger=False) + + +def test_speak_algorithm_creates_default_speaker() -> None: + algorithm = SpeakAlgorithm([], debugger=False) + + assert algorithm.instructions == [] + + +def test_speak_algorithm_performs_instructions() -> None: + instruction = FakeSpeakInstruction(3000) + speaker = FakeSpeaker() + ui = FakeUi() + algorithm = SpeakAlgorithm([instruction], speaker=speaker, debugger=False) + + algorithm.perform(ui) + + assert ui.window.to_foreground_calls == 1 + assert instruction.calls == [{"args": tuple([ui]), "kwargs": {}}] + + +def test_speak_algorithm_uses_debugger() -> None: + debugger = FakeDebugger(call_inner=False) + instruction = FakeSpeakInstruction(3000) + algorithm = SpeakAlgorithm([instruction], speaker=FakeSpeaker(), debugger=debugger) + + algorithm.perform(FakeUi()) + + assert debugger.clear_calls == 1 + assert debugger.try_calls[0]["instruction"] is instruction + + +def test_speak_algorithm_rejects_invalid_instruction_on_perform() -> None: + algorithm = SpeakAlgorithm([object()], speaker=FakeSpeaker(), debugger=False) + + with pytest.raises(TypeError): + algorithm.perform(FakeUi()) + + +def test_speak_algorithm_add_instruction_validates_type() -> None: + algorithm = SpeakAlgorithm([], speaker=FakeSpeaker(), debugger=False) + + with pytest.raises(TypeError): + algorithm.add_instruction(object()) + + +def test_speak_algorithm_add_instruction_appends_instruction() -> None: + algorithm = SpeakAlgorithm([], speaker=FakeSpeaker(), debugger=False) + instruction = FakeSpeakInstruction(3000) + + algorithm.add_instruction(instruction) + + assert algorithm.instructions == [instruction] diff --git a/tests/apparser/instructions/ui/algorithms/test_unique.py b/tests/apparser/instructions/ui/algorithms/test_unique.py new file mode 100644 index 0000000..83b960d --- /dev/null +++ b/tests/apparser/instructions/ui/algorithms/test_unique.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +import pytest + +from apparser.instructions.ui.algorithms.unique import UniqueAlgorithm +from tests.utils import ( + FakeDebugger, + FakeInstruction, + FakeIntAttributeInstruction, + FakeStrAttributeInstruction, + FakeUi, +) + + +def test_unique_algorithm_rejects_invalid_debugger() -> None: + with pytest.raises(TypeError): + UniqueAlgorithm([], [], debugger="debugger") + + +def test_unique_algorithm_injects_attributes_by_type() -> None: + first = FakeIntAttributeInstruction(1) + second = FakeStrAttributeInstruction(2) + ui = FakeUi() + algorithm = UniqueAlgorithm([first, second], attributes=[10, "alex"], debugger=False) + + algorithm.perform(ui) + + assert first.calls == [{"args": (ui,), "kwargs": {"number": 10}}] + assert second.calls == [{"args": (ui,), "kwargs": {"name": "alex"}}] + + +def test_unique_algorithm_uses_debugger() -> None: + debugger = FakeDebugger(call_inner=False) + instruction = FakeIntAttributeInstruction(1) + algorithm = UniqueAlgorithm([instruction], attributes=[10], debugger=debugger) + + algorithm.perform(FakeUi()) + + assert debugger.clear_calls == 1 + assert debugger.try_calls[0]["instruction"] is instruction + + +def test_unique_algorithm_rejects_invalid_instruction_on_perform() -> None: + algorithm = UniqueAlgorithm([object()], attributes=[], debugger=False) + + with pytest.raises(TypeError): + algorithm.perform(FakeUi()) + + +def test_unique_algorithm_add_instruction_validates_type() -> None: + algorithm = UniqueAlgorithm([], attributes=[], debugger=False) + + with pytest.raises(TypeError): + algorithm.add_instruction(object()) + + +def test_unique_algorithm_add_instruction_appends_instruction() -> None: + algorithm = UniqueAlgorithm([], attributes=[], debugger=False) + instruction = FakeInstruction(1) + + algorithm.add_instruction(instruction) + + assert algorithm.instructions == [instruction] diff --git a/tests/apparser/instructions/ui/test_base.py b/tests/apparser/instructions/ui/test_base.py new file mode 100644 index 0000000..5295d58 --- /dev/null +++ b/tests/apparser/instructions/ui/test_base.py @@ -0,0 +1,10 @@ +from __future__ import annotations + +import pytest + +from apparser.instructions.ui.base import UiInstruction + + +def test_ui_instruction_is_abstract() -> None: + with pytest.raises(TypeError): + UiInstruction() diff --git a/tests/apparser/instructions/ui/test_click.py b/tests/apparser/instructions/ui/test_click.py new file mode 100644 index 0000000..d1c75fb --- /dev/null +++ b/tests/apparser/instructions/ui/test_click.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +from typing import Any + +import pytest +from appwindows.geometry import Point + +from apparser.instructions.ui.click import MouseClickTo +from tests.utils import FakeUi + + +def test_mouse_click_to_rejects_invalid_coordinates() -> None: + with pytest.raises(ValueError): + MouseClickTo(object()) + + +def test_mouse_click_to_moves_and_clicks(monkeypatch: pytest.MonkeyPatch) -> None: + calls: list[str] = [] + instruction = MouseClickTo(Point(1, 2)) + monkeypatch.setattr( + "apparser.instructions.ui.click.MouseMove.perform", + lambda self, ui, *args, **kwargs: calls.append("move"), + ) + monkeypatch.setattr( + "apparser.instructions.ui.click.MouseClick.perform", + lambda self, *args, **kwargs: calls.append("click"), + ) + + instruction.perform(FakeUi()) + + assert calls == ["move", "click"] + assert instruction.id == 1005 diff --git a/tests/apparser/instructions/ui/test_mouse_move.py b/tests/apparser/instructions/ui/test_mouse_move.py new file mode 100644 index 0000000..019941e --- /dev/null +++ b/tests/apparser/instructions/ui/test_mouse_move.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +from typing import Any + +import pytest +from appwindows.geometry import Point + +from apparser.geometry import RelativelyPoint +from apparser.instructions.ui.mouse_move import MouseMove +from tests.utils import FakeUi, pyautogui_stub + + +@pytest.mark.parametrize("coordinates", [object(), "1"]) +def test_mouse_move_rejects_invalid_coordinates(coordinates: Any) -> None: + with pytest.raises(TypeError): + MouseMove(coordinates) + + +def test_mouse_move_rejects_invalid_mover() -> None: + with pytest.raises(TypeError): + MouseMove(Point(1, 1), mover=object()) + + +def test_mouse_move_moves_cursor_to_global_coordinates() -> None: + ui = FakeUi(offset=Point(10, 20)) + instruction = MouseMove(RelativelyPoint(0.5, 0.25)) + + instruction.perform(ui) + + assert pyautogui_stub.move_calls[0]["x"] == 60 + assert pyautogui_stub.move_calls[0]["y"] == 45 + assert instruction.id == 1004 diff --git a/tests/apparser/instructions/ui/test_move_window.py b/tests/apparser/instructions/ui/test_move_window.py new file mode 100644 index 0000000..3036192 --- /dev/null +++ b/tests/apparser/instructions/ui/test_move_window.py @@ -0,0 +1,22 @@ +from __future__ import annotations + +import pytest +from appwindows.geometry import Point + +from apparser.instructions.ui.move_window import WindowMove +from tests.utils import FakeUi + + +def test_window_move_rejects_invalid_position() -> None: + with pytest.raises(TypeError): + WindowMove(object()) + + +def test_window_move_moves_window() -> None: + ui = FakeUi() + instruction = WindowMove(Point(3, 4)) + + instruction.perform(ui) + + assert ui.window.move_calls == [Point(3, 4)] + assert instruction.id == 1002 diff --git a/tests/apparser/instructions/ui/test_resize_window.py b/tests/apparser/instructions/ui/test_resize_window.py new file mode 100644 index 0000000..84462a3 --- /dev/null +++ b/tests/apparser/instructions/ui/test_resize_window.py @@ -0,0 +1,22 @@ +from __future__ import annotations + +import pytest +from appwindows.geometry import Size + +from apparser.instructions.ui.resize_window import WindowResize +from tests.utils import FakeUi + + +def test_window_resize_rejects_invalid_size() -> None: + with pytest.raises(TypeError): + WindowResize(object()) + + +def test_window_resize_resizes_window() -> None: + ui = FakeUi() + instruction = WindowResize(Size(20, 30)) + + instruction.perform(ui) + + assert ui.window.resize_calls == [Size(20, 30)] + assert instruction.id == 1003 diff --git a/tests/apparser/instructions/ui/test_to_window.py b/tests/apparser/instructions/ui/test_to_window.py new file mode 100644 index 0000000..3663963 --- /dev/null +++ b/tests/apparser/instructions/ui/test_to_window.py @@ -0,0 +1,24 @@ +from __future__ import annotations + +from apparser.instructions.ui.to_window import WindowToBackground, WindowToForeground +from tests.utils import FakeUi + + +def test_window_to_background_moves_window_back() -> None: + ui = FakeUi() + instruction = WindowToBackground() + + instruction.perform(ui) + + assert ui.window.to_background_calls == 1 + assert instruction.id == 1001 + + +def test_window_to_foreground_brings_window_forward() -> None: + ui = FakeUi() + instruction = WindowToForeground() + + instruction.perform(ui) + + assert ui.window.to_foreground_calls == 1 + assert instruction.id == 1000 diff --git a/tests/apparser/instructions/utils/__init__.py b/tests/apparser/instructions/utils/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/tests/apparser/instructions/utils/__init__.py @@ -0,0 +1 @@ + diff --git a/tests/apparser/instructions/utils/test__get_all_instructions.py b/tests/apparser/instructions/utils/test__get_all_instructions.py new file mode 100644 index 0000000..5246305 --- /dev/null +++ b/tests/apparser/instructions/utils/test__get_all_instructions.py @@ -0,0 +1,8 @@ +from apparser.instructions.utils import get_all_instructions + + +def test_get_all_instructions_collects_only_concrete_instruction_classes() -> None: + + result = get_all_instructions() + + assert len(result) > 0 diff --git a/tests/apparser/instructions/utils/test_get_by_id.py b/tests/apparser/instructions/utils/test_get_by_id.py new file mode 100644 index 0000000..442f7cf --- /dev/null +++ b/tests/apparser/instructions/utils/test_get_by_id.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +from typing import Any + +import pytest + +from apparser.exceptions import InstructionWithIdNotFoundException +from apparser.instructions.utils.get_by_id import get_instruction_by_id +from tests.utils import make_instruction_type + + +@pytest.mark.parametrize("instruction_id", ["1", None, object()]) +def test_get_instruction_by_id_rejects_invalid_type(instruction_id: Any) -> None: + with pytest.raises(TypeError): + get_instruction_by_id(instruction_id) + + +def test_get_instruction_by_id_rejects_negative_value() -> None: + with pytest.raises(ValueError): + get_instruction_by_id(-1) + + +def test_get_instruction_by_id_returns_matching_instruction(monkeypatch: pytest.MonkeyPatch) -> None: + first = make_instruction_type("First", 1) + second = make_instruction_type("Second", 2) + monkeypatch.setattr("apparser.instructions.utils.get_by_id.get_all_instructions", lambda: [first, second]) + + assert get_instruction_by_id(2) is second + + +def test_get_instruction_by_id_raises_when_not_found(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr("apparser.instructions.utils.get_by_id.get_all_instructions", lambda: []) + + with pytest.raises(InstructionWithIdNotFoundException): + get_instruction_by_id(1) diff --git a/tests/apparser/instructions/utils/test_get_by_name.py b/tests/apparser/instructions/utils/test_get_by_name.py new file mode 100644 index 0000000..7179e33 --- /dev/null +++ b/tests/apparser/instructions/utils/test_get_by_name.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +from typing import Any + +import pytest + +from apparser.exceptions import InstructionWithNameNotFoundException +from apparser.instructions.utils.get_by_name import get_instruction_by_name +from tests.utils import make_instruction_type + + +@pytest.mark.parametrize("instruction_name", [1, None, object()]) +def test_get_instruction_by_name_rejects_invalid_type(instruction_name: Any) -> None: + with pytest.raises(TypeError): + get_instruction_by_name(instruction_name) + + +def test_get_instruction_by_name_rejects_empty_name() -> None: + with pytest.raises(ValueError): + get_instruction_by_name("") + + +def test_get_instruction_by_name_returns_matching_instruction(monkeypatch: pytest.MonkeyPatch) -> None: + first = make_instruction_type("First", 1) + second = make_instruction_type("Second", 2) + monkeypatch.setattr("apparser.instructions.utils.get_by_name.get_all_instructions", lambda: [first, second]) + + assert get_instruction_by_name("Second") is second + + +def test_get_instruction_by_name_raises_when_not_found(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr("apparser.instructions.utils.get_by_name.get_all_instructions", lambda: []) + + with pytest.raises(InstructionWithNameNotFoundException): + get_instruction_by_name("Missing") diff --git a/tests/apparser/key_codes/__init__.py b/tests/apparser/key_codes/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/tests/apparser/key_codes/__init__.py @@ -0,0 +1 @@ + diff --git a/tests/apparser/key_codes/test___init__.py b/tests/apparser/key_codes/test___init__.py new file mode 100644 index 0000000..8fba20d --- /dev/null +++ b/tests/apparser/key_codes/test___init__.py @@ -0,0 +1,15 @@ +from __future__ import annotations + +from apparser import key_codes + + +def test_key_codes_exports_expected_symbols() -> None: + assert set(key_codes.__all__) == { + "BaseKeyCode", + "Enter", + "Control", + "RightClick", + "LeftClick", + "Alt", + "Delete", + } diff --git a/tests/apparser/key_codes/test_base.py b/tests/apparser/key_codes/test_base.py new file mode 100644 index 0000000..57db2e5 --- /dev/null +++ b/tests/apparser/key_codes/test_base.py @@ -0,0 +1,10 @@ +from __future__ import annotations + +import pytest + +from apparser.key_codes.base import BaseKeyCode + + +def test_base_key_code_is_abstract() -> None: + with pytest.raises(TypeError): + BaseKeyCode() diff --git a/tests/apparser/key_codes/test_keyboard_keys.py b/tests/apparser/key_codes/test_keyboard_keys.py new file mode 100644 index 0000000..e773d16 --- /dev/null +++ b/tests/apparser/key_codes/test_keyboard_keys.py @@ -0,0 +1,18 @@ +from __future__ import annotations + +import pytest + +from apparser.key_codes.keyboard_keys import Alt, Control, Delete, Enter + + +@pytest.mark.parametrize( + ("key_code", "expected"), + [ + (Enter(), "enter"), + (Control(), "ctrl"), + (Alt(), "alt"), + (Delete(), "del"), + ], +) +def test_keyboard_key_string_values(key_code: object, expected: str) -> None: + assert str(key_code) == expected diff --git a/tests/apparser/key_codes/test_mouse_keys.py b/tests/apparser/key_codes/test_mouse_keys.py new file mode 100644 index 0000000..826b186 --- /dev/null +++ b/tests/apparser/key_codes/test_mouse_keys.py @@ -0,0 +1,16 @@ +from __future__ import annotations + +import pytest + +from apparser.key_codes.mouse_keys import LeftClick, RightClick + + +@pytest.mark.parametrize( + ("key_code", "expected"), + [ + (RightClick(), "RIGHT"), + (LeftClick(), "LEFT"), + ], +) +def test_mouse_key_string_values(key_code: object, expected: str) -> None: + assert str(key_code) == expected diff --git a/tests/apparser/movers/__init__.py b/tests/apparser/movers/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/tests/apparser/movers/__init__.py @@ -0,0 +1 @@ + diff --git a/tests/apparser/movers/test_base.py b/tests/apparser/movers/test_base.py new file mode 100644 index 0000000..cb0ff0f --- /dev/null +++ b/tests/apparser/movers/test_base.py @@ -0,0 +1,10 @@ +from __future__ import annotations + +import pytest + +from apparser.movers.base import BaseMover + + +def test_base_mover_is_abstract() -> None: + with pytest.raises(TypeError): + BaseMover() diff --git a/tests/apparser/movers/test_default.py b/tests/apparser/movers/test_default.py new file mode 100644 index 0000000..f2bf9e2 --- /dev/null +++ b/tests/apparser/movers/test_default.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +from typing import Any + +import pytest +from appwindows.geometry import Point + +from apparser.movers.default import DefaultMover +from tests.utils import pyautogui_stub + + +@pytest.mark.parametrize("duration", ["1", object()]) +def test_default_mover_rejects_invalid_duration_type(duration: Any) -> None: + with pytest.raises(TypeError): + DefaultMover(duration=duration) + + +def test_default_mover_rejects_invalid_absolute_type() -> None: + with pytest.raises(TypeError): + DefaultMover(absolute="yes") + + +def test_default_mover_rejects_negative_duration() -> None: + with pytest.raises(ValueError): + DefaultMover(duration=-0.1) + + +def test_default_mover_moves_mouse() -> None: + mover = DefaultMover(duration=0.5) + + mover.move(Point(3, 7)) + + assert pyautogui_stub.move_calls == [ + { + "x": 3, + "y": 7, + "duration": 0.5, + } + ] diff --git a/tests/apparser/movers/test_math_antirobot.py b/tests/apparser/movers/test_math_antirobot.py new file mode 100644 index 0000000..4dbfb07 --- /dev/null +++ b/tests/apparser/movers/test_math_antirobot.py @@ -0,0 +1,95 @@ +from __future__ import annotations + +from collections.abc import Iterator +from typing import Any + +import pytest +from appwindows.geometry import Point + +from apparser.movers.math_antirobot import AntiRobotMover, DefaultMoveGenerator +from tests.utils import pyautogui_stub + + +@pytest.mark.parametrize( + ("kwargs", "error_type"), + [ + ({"min_time": "0.1"}, TypeError), + ({"max_time": "2"}, TypeError), + ({"min_shift": "30"}, TypeError), + ({"max_shift": "100"}, TypeError), + ({"min_shift": 10, "max_shift": 5}, ValueError), + ({"min_time": 2, "max_time": 1}, ValueError), + ({"min_time": -0.1}, ValueError), + ], +) +def test_default_move_generator_validates_init_arguments( + kwargs: dict[str, Any], + error_type: type[Exception], +) -> None: + with pytest.raises(error_type): + DefaultMoveGenerator(**kwargs) + + +def test_default_move_generator_returns_steps(monkeypatch: pytest.MonkeyPatch) -> None: + generator = DefaultMoveGenerator(min_time=0.1, max_time=1, min_shift=3, max_shift=4) + values = iter([3.0, 0.5, 3.0, 0.6, 0.7]) + + monkeypatch.setattr( + "apparser.movers.math_antirobot.random.uniform", + lambda _min, _max: next(values), + ) + + result = list(generator(Point(0, 0), Point(10, 0))) + + assert result == [ + (Point(3, 0), 0.5), + (Point(6, 0), 0.6), + (Point(10, 0), 0.7), + ] + + +@pytest.mark.parametrize( + ("start_position", "end_position"), + [ + (object(), Point(1, 1)), + (Point(1, 1), object()), + ], +) +def test_default_move_generator_rejects_invalid_points(start_position: Any, end_position: Any) -> None: + generator = DefaultMoveGenerator() + + with pytest.raises(TypeError): + list(generator(start_position, end_position)) + + +def test_antirobot_mover_rejects_invalid_position() -> None: + mover = AntiRobotMover() + + with pytest.raises(TypeError): + mover.move(object()) + + +def test_antirobot_mover_uses_generated_path() -> None: + def fake_generator(start: Point, end: Point) -> Iterator[tuple[Point, float]]: + assert start == Point(1, 2) + assert end == Point(10, 20) + yield Point(3, 4), 0.1 + yield Point(10, 20), 0.2 + + pyautogui_stub._position = (1, 2) + mover = AntiRobotMover(move_generator=fake_generator) + + mover.move(Point(10, 20)) + + assert pyautogui_stub.move_calls == [ + { + "x": 3, + "y": 4, + "duration": 0.1, + }, + { + "x": 10, + "y": 20, + "duration": 0.2, + }, + ] diff --git a/tests/apparser/speakers/__init__.py b/tests/apparser/speakers/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/tests/apparser/speakers/__init__.py @@ -0,0 +1 @@ + diff --git a/tests/apparser/speakers/test_base.py b/tests/apparser/speakers/test_base.py new file mode 100644 index 0000000..fff5761 --- /dev/null +++ b/tests/apparser/speakers/test_base.py @@ -0,0 +1,10 @@ +from __future__ import annotations + +import pytest + +from apparser.speakers.base import BaseSpeaker + + +def test_base_speaker_is_abstract() -> None: + with pytest.raises(TypeError): + BaseSpeaker() diff --git a/tests/apparser/speakers/test_chattts.py b/tests/apparser/speakers/test_chattts.py new file mode 100644 index 0000000..0ab583b --- /dev/null +++ b/tests/apparser/speakers/test_chattts.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +import numpy + +from apparser.speakers.chat_tts import ChatTTSSpeaker +from tests.utils import chattts_stub, torch_stub + + +def test_chattts_speaker_initializes_dependencies() -> None: + speaker = ChatTTSSpeaker(device="cpu", source="local") + instance = chattts_stub.Chat.instances[0] + + assert isinstance(speaker, ChatTTSSpeaker) + assert torch_stub.device_calls == ["cpu"] + assert instance.load_calls[0]["source"] == "local" + assert instance.sample_random_speaker_calls == 1 + + +def test_chattts_speaker_uses_explicit_speaker() -> None: + ChatTTSSpeaker(speaker="speaker-id") + instance = chattts_stub.Chat.instances[0] + + assert instance.sample_random_speaker_calls == 0 + + +def test_chattts_speaker_returns_empty_audio() -> None: + chattts_stub.Chat.default_infer_result = [] + speaker = ChatTTSSpeaker(speaker="speaker-id") + + result = speaker.speak("hello") + + assert numpy.array_equal(result[0], numpy.array([], dtype=numpy.float32)) + + +def test_chattts_speaker_concatenates_multiple_chunks() -> None: + chattts_stub.Chat.default_infer_result = [ + numpy.asarray([1.0, 2.0], dtype=numpy.float32), + numpy.asarray([3.0], dtype=numpy.float32), + ] + speaker = ChatTTSSpeaker(speaker="speaker-id") + + result = speaker.speak("hello") + + assert numpy.array_equal(result[0], numpy.asarray([1.0, 2.0, 3.0], dtype=numpy.float32)) + + +def test_chattts_speaker_sets_missing_speaker_on_custom_params() -> None: + chattts_stub.Chat.default_infer_result = [numpy.asarray([1.0], dtype=numpy.float32)] + speaker = ChatTTSSpeaker(speaker="speaker-id") + + class Params: + def __init__(self) -> None: + self.spk_emb = None + + params = Params() + + speaker.speak("hello", params_infer_code=params) + + instance = chattts_stub.Chat.instances[0] + assert params.spk_emb == "speaker-id" + assert instance.infer_calls[0]["params_infer_code"] is params diff --git a/tests/apparser/speakers/test_torch.py b/tests/apparser/speakers/test_torch.py new file mode 100644 index 0000000..3ef48d3 --- /dev/null +++ b/tests/apparser/speakers/test_torch.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +import numpy + +from apparser.speakers.torch import TorchSpeaker +from tests.utils import torch_stub + + +def test_torch_speaker_initializes_model() -> None: + speaker = TorchSpeaker(device="cpu", language="en", speaker_model="v3_en") + + assert isinstance(speaker, TorchSpeaker) + assert torch_stub.device_calls == ["cpu"] + assert torch_stub.hub_load_calls[0]["language"] == "en" + assert torch_stub.hub_load_calls[0]["speaker"] == "v3_en" + assert torch_stub.hub_model.to_calls == ["device:cpu"] + + +def test_torch_speaker_returns_numpy_audio() -> None: + torch_stub.hub_model.result.values = numpy.asarray([0.1, 0.2], dtype=numpy.float32) + speaker = TorchSpeaker(speaker="aidar", sample_rate=24_000, device="cpu") + + result = speaker.speak("hello", put_accent=True) + + assert numpy.array_equal(result[0], numpy.asarray([0.1, 0.2], dtype=numpy.float32)) + assert torch_stub.hub_model.apply_tts_calls[0] == { + "text": "hello", + "speaker": "aidar", + "sample_rate": 24_000, + "put_accent": True, + } diff --git a/tests/apparser/test___init__.py b/tests/apparser/test___init__.py new file mode 100644 index 0000000..ae0eb77 --- /dev/null +++ b/tests/apparser/test___init__.py @@ -0,0 +1,8 @@ +from __future__ import annotations + +import apparser + + +def test_apparser_exports_core_symbols() -> None: + assert hasattr(apparser, "App") + assert hasattr(apparser, "BaseUi") diff --git a/tests/apparser/text_readers/__init__.py b/tests/apparser/text_readers/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/tests/apparser/text_readers/__init__.py @@ -0,0 +1 @@ + diff --git a/tests/apparser/text_readers/models/__init__.py b/tests/apparser/text_readers/models/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/tests/apparser/text_readers/models/__init__.py @@ -0,0 +1 @@ + diff --git a/tests/apparser/text_readers/models/test_text_data.py b/tests/apparser/text_readers/models/test_text_data.py new file mode 100644 index 0000000..14cd423 --- /dev/null +++ b/tests/apparser/text_readers/models/test_text_data.py @@ -0,0 +1,12 @@ +from __future__ import annotations + +from appwindows.geometry import Point + +from apparser.text_readers.models.text_data import TextData + + +def test_text_data_is_frozen_dataclass() -> None: + text_data = TextData("hello", [Point(1, 2), Point(3, 4)]) + + assert text_data.text == "hello" + assert text_data.coordinates == [Point(1, 2), Point(3, 4)] diff --git a/tests/apparser/text_readers/test_base.py b/tests/apparser/text_readers/test_base.py new file mode 100644 index 0000000..e2c87b6 --- /dev/null +++ b/tests/apparser/text_readers/test_base.py @@ -0,0 +1,10 @@ +from __future__ import annotations + +import pytest + +from apparser.text_readers.base import BaseTextReader + + +def test_base_text_reader_is_abstract() -> None: + with pytest.raises(TypeError): + BaseTextReader() diff --git a/tests/apparser/text_readers/test_easy_ocr.py b/tests/apparser/text_readers/test_easy_ocr.py new file mode 100644 index 0000000..94c21a3 --- /dev/null +++ b/tests/apparser/text_readers/test_easy_ocr.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +import numpy +from appwindows.geometry import Point + +from apparser.text_readers.easy_ocr import EasyOcrReader +from tests.utils import easyocr_stub + + +def test_easy_ocr_reader_uses_default_language() -> None: + EasyOcrReader() + + instance = easyocr_stub.Reader.instances[0] + assert instance.lang_list == ["en"] + + +def test_easy_ocr_reader_maps_prediction_result() -> None: + reader = EasyOcrReader(["ru"], gpu=False) + instance = easyocr_stub.Reader.instances[0] + instance.predicted = [ + ( + [(1.1, 2.8), (3.9, 4.2), (5.0, 6.0), (7.0, 8.0)], + "text", + 0.99, + ) + ] + + result = reader.read_image(numpy.zeros((2, 2, 3), dtype=numpy.uint8), detail=1) + + assert instance.settings == {"gpu": False} + assert instance.read_calls[0]["settings"] == {"detail": 1} + assert result[0].text == "text" + assert result[0].coordinates == [ + Point(1, 2), + Point(3, 4), + Point(5, 6), + Point(7, 8), + ] diff --git a/tests/apparser/text_readers/test_paddle_ocr.py b/tests/apparser/text_readers/test_paddle_ocr.py new file mode 100644 index 0000000..09c6f75 --- /dev/null +++ b/tests/apparser/text_readers/test_paddle_ocr.py @@ -0,0 +1,23 @@ +import numpy + +from apparser.text_readers.paddle import ( + PaddleTextReader, +) +from tests.utils import paddleocr_stub + + +def test_paddle_text_reader_uses_predict_when_available() -> None: + reader = PaddleTextReader(lang="ru") + instance = paddleocr_stub.PaddleOCR.instances[0] + instance.predict_result = [ + { + "rec_texts": ["hello"], + "rec_polys": [[[1, 1], [2, 2], [3, 3], [4, 4]]], + } + ] + + result = reader.read_image(numpy.zeros((2, 2, 3), dtype=numpy.uint8), use_doc_orientation_classify=False) + + assert instance.lang == "ru" + assert instance.predict_calls[0]["settings"] == {"use_doc_orientation_classify": False} + assert result[0].text == "hello" diff --git a/tests/apparser/text_readers/test_screens_controller.py b/tests/apparser/text_readers/test_screens_controller.py new file mode 100644 index 0000000..11f480d --- /dev/null +++ b/tests/apparser/text_readers/test_screens_controller.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +import numpy + +from apparser.text_readers.screens_controller import ScreensController +from tests.utils import FakeTextReader + + +def test_screens_controller_caches_equal_images() -> None: + reader = FakeTextReader(result=["text"]) + controller = ScreensController(reader) + image = numpy.zeros((2, 2, 3), dtype=numpy.uint8) + + first = controller.read_image(image) + second = controller.read_image(image.copy()) + + assert first == ["text"] + assert second == ["text"] + assert len(reader.images) == 1 diff --git a/tests/apparser/text_readers/test_white_black_reader.py b/tests/apparser/text_readers/test_white_black_reader.py new file mode 100644 index 0000000..0b24106 --- /dev/null +++ b/tests/apparser/text_readers/test_white_black_reader.py @@ -0,0 +1,17 @@ +from __future__ import annotations + +import numpy + +from apparser.text_readers.white_black_reader import WhiteBlackReader +from tests.utils import FakeTextReader + + +def test_white_black_reader_converts_image_to_grayscale() -> None: + reader = FakeTextReader(result=["text"]) + wrapped_reader = WhiteBlackReader(reader) + image = numpy.zeros((3, 3, 3), dtype=numpy.uint8) + + result = wrapped_reader.read_image(image) + + assert result == ["text"] + assert reader.images[0].ndim == 2 diff --git a/tests/conftest.py b/tests/conftest.py index 53d90b3..2e82d51 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,57 +1,21 @@ -import sys -import types +from __future__ import annotations +import shutil +from pathlib import Path +from collections.abc import Iterator -class _FakeEasyOcrReader: - def __init__(self, lang_list, **settings): - self.lang_list = lang_list - self.settings = settings - self.result = [] - self.calls = [] +import pytest - def readtext(self, image, **settings): - self.calls.append((image, settings)) - return self.result +from tests.utils.external_stubs import install_external_stubs, reset_external_stubs -easyocr = types.ModuleType('easyocr') -easyocr.last_reader = None +install_external_stubs() +temp_dir = Path(__file__).resolve().parent / "_tmp" -def _easyocr_reader_factory(lang_list, **settings): - easyocr.last_reader = _FakeEasyOcrReader(lang_list, **settings) - return easyocr.last_reader - - -easyocr.Reader = _easyocr_reader_factory -sys.modules['easyocr'] = easyocr - - -class _FakeYoloModel: - def __init__(self, **kwargs): - self.kwargs = kwargs - self.model = types.SimpleNamespace(names={}) - self.results = [types.SimpleNamespace(boxes=[])] - self.calls = [] - self.track_calls = [] - - def __call__(self, image): - self.calls.append(image) - return self.results - - def track(self, source=None, persist=False, **kwargs): - self.track_calls.append((source, persist, kwargs)) - return self.results - - -ultralytics = types.ModuleType('ultralytics') -ultralytics.last_model = None - - -def _yolo_factory(**kwargs): - ultralytics.last_model = _FakeYoloModel(**kwargs) - return ultralytics.last_model - - -ultralytics.YOLO = _yolo_factory -sys.modules['ultralytics'] = ultralytics +@pytest.fixture(autouse=True) +def reset_external_modules() -> Iterator[None]: + shutil.rmtree(temp_dir, ignore_errors=True) + reset_external_stubs() + yield + shutil.rmtree(temp_dir, ignore_errors=True) diff --git a/tests/test_algorithms.py b/tests/test_algorithms.py deleted file mode 100644 index 77d658f..0000000 --- a/tests/test_algorithms.py +++ /dev/null @@ -1,62 +0,0 @@ -"""Tests for algorithm classes.""" - -import pytest - -from apparser.instructions.algorithms import AiAlgorithm, Algorithm -from tests.utils.instructions import DummyAiInstruction, DummyInstruction -from tests.utils.readers import FakeTextReader -from tests.utils.ui import InteractionUi - - -def test_algorithm_perform_and_add_instruction(): - calls = [] - ui = InteractionUi() - first = DummyInstruction(calls, "first") - second = DummyInstruction(calls, "second") - algorithm = Algorithm([first], None) - - algorithm.add_instruction(second) - algorithm.perform(ui) - - assert algorithm.instructions == [first, second] - assert ui.window.calls[0] == ("to_foreground",) - assert [call[0] for call in calls] == ["first", "second"] - - -def test_algorithm_validation(): - algorithm = Algorithm([], None) - - with pytest.raises(TypeError, match="must be Instruction"): - algorithm.add_instruction("instruction") - - with pytest.raises(TypeError, match="must be Instruction"): - Algorithm(["instruction"], None).perform(InteractionUi()) - - -def test_ai_algorithm_perform_and_add_instruction(): - calls = [] - ui = InteractionUi() - ai_reader = FakeTextReader() - first = DummyInstruction(calls, "instruction") - second = DummyAiInstruction(calls, "ai_instruction") - algorithm = AiAlgorithm([first], text_reader=ai_reader) - - algorithm.add_instruction(second) - algorithm.perform(ui) - - assert algorithm.instructions == [first, second] - assert ui.window.calls == [("to_foreground",)] - assert calls[0][0] == "instruction" - assert calls[1][:3] == ("ai_instruction", ui, ai_reader) - - -def test_ai_algorithm_validation(): - algorithm = AiAlgorithm([], text_reader=FakeTextReader()) - - with pytest.raises(TypeError, match="must be Instruction or AiInstruction"): - algorithm.add_instruction("instruction") - - with pytest.raises(TypeError, match="must be Instruction or AiInstruction"): - AiAlgorithm(["instruction"], text_reader=FakeTextReader()).perform( - InteractionUi() - ) diff --git a/tests/test_debuggers.py b/tests/test_debuggers.py deleted file mode 100644 index 0bc6b3b..0000000 --- a/tests/test_debuggers.py +++ /dev/null @@ -1,50 +0,0 @@ -"""Tests for debugger helpers.""" - -import pytest - -from apparser.debuggers import Debugger -from apparser.exceptions import DebugException -from tests.utils.instructions import DummyInstruction - - -def test_debugger_try_perform_calls_instruction(): - calls = [] - debugger = Debugger() - instruction = DummyInstruction(calls, "instruction", instruction_id=7) - - debugger.try_perform(instruction, "ui") - - assert calls == [("instruction", "ui", (), {})] - - -def test_debugger_wraps_unexpected_exceptions(): - debugger = Debugger() - instruction = DummyInstruction( - instruction_id=5, - error=ValueError("boom"), - ) - - with pytest.raises(DebugException) as exc_info: - debugger.try_perform(instruction, "ui") - - message = str(exc_info.value) - - assert "0\t5\tDummyInstruction" in message - assert "boom" in message - - -def test_debugger_clear_context_resets_log(): - debugger = Debugger() - debugger.try_perform(DummyInstruction(instruction_id=1), "ui") - debugger.clear_contex() - - with pytest.raises(DebugException) as exc_info: - debugger.try_perform( - DummyInstruction(instruction_id=2, error=ValueError("boom")), - "ui", - ) - - message = str(exc_info.value) - - assert "0\t2\tDummyInstruction" in message - assert "1\t1\tDummyInstruction" not in message diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py deleted file mode 100644 index 784c31b..0000000 --- a/tests/test_exceptions.py +++ /dev/null @@ -1,43 +0,0 @@ -"""Tests for project exceptions.""" - -import pytest - -from apparser.exceptions import ( - DebugException, - TextNotFoundException, - WindowActionWithDesktopException, -) - - -@pytest.mark.parametrize( - ("min_similarity", "error", "message"), - [ - ("0.5", TypeError, "min_similarity must be float"), - (-0.1, ValueError, "min_similarity must be between 0 and 1"), - (1.1, ValueError, "min_similarity must be between 0 and 1"), - ], -) -def test_text_not_found_exception_validation(min_similarity, error, message): - with pytest.raises(error, match=message): - TextNotFoundException(min_similarity) - - -def test_text_not_found_exception_message(): - message = "No text with similarity greater than or equal to 0.5 was found." - - assert str(TextNotFoundException(0.5)) == message - - -def test_window_action_with_desktop_exception_message(): - message = "You cannot treat the DesktopUi class as a window." - - assert str(WindowActionWithDesktopException()) == message - - -def test_debug_exception_validation(): - with pytest.raises(TypeError, match="message must be a string"): - DebugException(1) - - -def test_debug_exception_message(): - assert str(DebugException("boom")) == "boom" diff --git a/tests/test_geometry.py b/tests/test_geometry.py deleted file mode 100644 index 0869a86..0000000 --- a/tests/test_geometry.py +++ /dev/null @@ -1,32 +0,0 @@ -import pytest -from appwindows.geometry import Point - -from apparser.geometry import RelativelyPoint, distance - - -def test_distance_validation_and_value(): - with pytest.raises(TypeError, match='First Point must be of type Point'): - distance('first', Point(0, 0)) - - with pytest.raises(TypeError, match='Second Point must be of type Point'): - distance(Point(0, 0), 'second') - - assert distance(Point(1, 2), Point(4, 8)) == 9 - - -@pytest.mark.parametrize(('x_percent', 'y_percent', 'error', 'message'), [ - ('1', 0, TypeError, 'x_percent must be number'), - (0, '1', TypeError, 'y_percent must be number'), - (-2, 0, ValueError, 'x must be between -1 and 1'), - (0, 2, ValueError, 'y must be between -1 and 1'), -]) -def test_relatively_point_validation(x_percent, y_percent, error, message): - with pytest.raises(error, match=message): - RelativelyPoint(x_percent, y_percent) - - -def test_relatively_point_properties(): - point = RelativelyPoint(0.25, -0.5) - - assert point.x == 0.25 - assert point.y == -0.5 diff --git a/tests/test_imports.py b/tests/test_imports.py deleted file mode 100644 index 9315435..0000000 --- a/tests/test_imports.py +++ /dev/null @@ -1,166 +0,0 @@ -import sys -import types - -import numpy - - -def _install_optional_dependency_stubs(): - if "torch" not in sys.modules: - torch = types.ModuleType("torch") - torch.device = lambda value: value - - class _Hub: - @staticmethod - def load(**kwargs): - class _Model: - def to(self, device): - self.device = device - - def apply_tts(self, **settings): - class _Audio: - def detach(self): - return self - - def cpu(self): - return self - - def numpy(self): - return numpy.array([], dtype=numpy.float32) - - return _Audio() - - return _Model(), None - - torch.hub = _Hub() - sys.modules["torch"] = torch - - if "ChatTTS" not in sys.modules: - chattts = types.ModuleType("ChatTTS") - - class _Chat: - class InferCodeParams: - def __init__(self, spk_emb=None): - self.spk_emb = spk_emb - - def load(self, **kwargs): - pass - - def sample_random_speaker(self): - return "speaker" - - def infer(self, text, params_infer_code=None, **kwargs): - return [numpy.array([], dtype=numpy.float32)] - - chattts.Chat = _Chat - sys.modules["ChatTTS"] = chattts - - -_install_optional_dependency_stubs() - -from apparser import App, BaseUi, CoordinatesUi, DesktopUi -from apparser.instructions.algorithms import AiAlgorithm, Algorithm -from apparser.core import WindowUi -from apparser.cv import DefaultCvProcess, DefaultHandlers, YoloReader -from apparser.exceptions import ( - DebugException, - TextNotFoundException, - WindowActionWithDesktopException, -) -from apparser.geometry import Point, RelativelyPoint, Size, distance -from apparser.instructions import ( - BaseInstruction, - MouseClick, - MouseClickTo, - MouseMove, - PressKey, - PressKeysCombination, - Sleep, - WindowMove, - WindowResize, - WindowToBackground, - WindowToForeground, - WriteText, -) -from apparser.instructions.ocr import ( - OCRInstruction, - ClickOnText, - GetText, - MoveToText, - PlotAllText, - PrintAllText, -) -from apparser.instructions.speak import PlayTextAudio, SayTextAudio, SpeakInstruction -from apparser.key_codes import ( - Alt, - Control, - Delete, - Enter, - KeyboardKeyCode, - LeftClick, - RightClick, -) -from apparser.movers import AntiRobotMover, DefaultMover -from apparser.text_readers import ( - BaseTextReader, - EasyOcrReader, - PaddleTextReader, - ScreensController, - TextData, - WhiteBlackReader, -) - - -def test_public_imports_are_available(): - assert App is not None - assert BaseUi is not None - assert DesktopUi is not None - assert CoordinatesUi is not None - assert WindowUi is not None - assert Point is not None - assert Size is not None - assert RelativelyPoint is not None - assert distance is not None - assert DebugException is not None - assert TextNotFoundException is not None - assert WindowActionWithDesktopException is not None - assert KeyboardKeyCode is not None - assert Enter is not None - assert Control is not None - assert Alt is not None - assert Delete is not None - assert RightClick is not None - assert LeftClick is not None - assert DefaultMover is not None - assert AntiRobotMover is not None - assert BaseInstruction is not None - assert Algorithm is not None - assert MouseMove is not None - assert MouseClickTo is not None - assert MouseClick is not None - assert WindowMove is not None - assert PressKey is not None - assert PressKeysCombination is not None - assert WindowResize is not None - assert Sleep is not None - assert WindowToForeground is not None - assert WindowToBackground is not None - assert WriteText is not None - assert OCRInstruction is not None - assert SpeakInstruction is not None - assert AiAlgorithm is not None - assert ClickOnText is not None - assert GetText is not None - assert MoveToText is not None - assert PlotAllText is not None - assert PrintAllText is not None - assert PlayTextAudio is not None - assert SayTextAudio is not None - assert BaseTextReader is not None - assert EasyOcrReader is not None - assert PaddleTextReader is not None - assert ScreensController is not None - assert TextData is not None - assert WhiteBlackReader is not None - assert DefaultHandlers is not None - assert DefaultCvProcess is not None - assert YoloReader is not None diff --git a/tests/test_key_codes.py b/tests/test_key_codes.py deleted file mode 100644 index 16b95d6..0000000 --- a/tests/test_key_codes.py +++ /dev/null @@ -1,32 +0,0 @@ -"""Tests for key-code classes.""" - -import pytest - -from apparser.key_codes import ( - Alt, - Control, - Delete, - Enter, - KeyboardKeyCode, - LeftClick, - RightClick, -) - - -def test_keyboard_key_code_to_string(): - assert str(KeyboardKeyCode("space")) == "space" - - -@pytest.mark.parametrize( - ("key_code", "expected"), - [ - (Enter(), "enter"), - (Control(), "ctrl"), - (Alt(), "alt"), - (Delete(), "del"), - (RightClick(), "RIGHT"), - (LeftClick(), "LEFT"), - ], -) -def test_predefined_key_codes_to_string(key_code, expected): - assert str(key_code) == expected diff --git a/tests/test_movers.py b/tests/test_movers.py deleted file mode 100644 index 80ed3fb..0000000 --- a/tests/test_movers.py +++ /dev/null @@ -1,193 +0,0 @@ -"""Tests for mover classes.""" - -import pytest -from appwindows.geometry import Point - -import apparser.movers.default as default_mover_module -import apparser.movers.math_antirobot as math_antirobot -from apparser.movers import DefaultMover -from apparser.movers.math_antirobot import AntiRobotMover, DefaultMoveGenerator - - -def test_default_mover_validation_and_move(monkeypatch): - calls = [] - monkeypatch.setattr( - default_mover_module.mouse, - "move", - lambda x, y, absolute, duration: calls.append((x, y, absolute, duration)), - ) - - with pytest.raises(TypeError, match="Duration must be a number"): - DefaultMover("0") - - with pytest.raises(TypeError, match="Absolute must be a boolean"): - DefaultMover(0, "true") - - with pytest.raises(ValueError, match="Duration must be a >= 0"): - DefaultMover(-1) - - mover = DefaultMover(0.5, False) - mover.move(Point(7, 8)) - - assert calls == [(7, 8, False, 0.5)] - - -@pytest.mark.parametrize( - ("kwargs", "error", "message"), - [ - ({"min_time": "1"}, TypeError, "min_time must be number"), - ({"max_time": "2"}, TypeError, "max_time must be number"), - ({"min_shift": "3"}, TypeError, "min_shift must be number"), - ({"max_shift": "4"}, TypeError, "max_shift must be number"), - ( - {"min_shift": 20, "max_shift": 10}, - ValueError, - "min_shift must be less than max_shift", - ), - ( - {"min_time": 3, "max_time": 2}, - ValueError, - "min_time must be less than max_time", - ), - ({"min_time": -1}, ValueError, "min_time must be greater than 0"), - ], -) -def test_default_move_generator_init_validation(kwargs, error, message): - with pytest.raises(error, match=message): - DefaultMoveGenerator(**kwargs) - - -def test_default_move_generator_get_random_time(monkeypatch): - random_calls = [] - - def fake_uniform(min_time, max_time): - random_calls.append((min_time, max_time)) - return 1.5 - - monkeypatch.setattr(math_antirobot.random, "uniform", fake_uniform) - generator = DefaultMoveGenerator(min_time=1, max_time=2) - - assert generator._DefaultMoveGenerator__get_random_time() == 1.5 - assert random_calls == [(1, 2)] - - -@pytest.mark.parametrize( - ("current_position", "end_position", "random_shift", "expected_position"), - [ - (Point(10, 20), Point(20, 30), 6, Point(3, 3)), - (Point(10, 10), Point(7, 4), 100, Point(-3, -6)), - ], -) -def test_default_move_generator_get_random_position( - monkeypatch, - current_position, - end_position, - random_shift, - expected_position, -): - monkeypatch.setattr( - math_antirobot.random, - "uniform", - lambda min_shift, max_shift: random_shift, - ) - generator = DefaultMoveGenerator(min_shift=0, max_shift=100) - - assert ( - generator._DefaultMoveGenerator__get_random_position( - current_position, - end_position, - ) - == expected_position - ) - - -@pytest.mark.parametrize( - ("start_position", "end_position", "message"), - [ - ("start", Point(1, 1), "start_position must be Point"), - (Point(1, 1), "end", "end_position must be Point"), - ], -) -def test_default_move_generator_call_validation(start_position, end_position, message): - generator = DefaultMoveGenerator() - - with pytest.raises(TypeError, match=message): - list(generator(start_position, end_position)) - - -def test_default_move_generator_call_returns_empty_if_positions_equal(): - generator = DefaultMoveGenerator() - - assert list(generator(Point(10, 10), Point(10, 10))) == [] - - -def test_default_move_generator_call_returns_end_position_if_it_is_close(monkeypatch): - generator = DefaultMoveGenerator(min_shift=0, max_shift=10) - monkeypatch.setattr( - generator, - "_DefaultMoveGenerator__get_random_time", - lambda: 0.5, - ) - - result = list(generator(Point(1, 1), Point(4, 4))) - - assert result == [(Point(4, 4), 0.5)] - - -def test_default_move_generator_call_returns_intermediate_and_end_positions( - monkeypatch, -): - generator = DefaultMoveGenerator(min_shift=0, max_shift=6) - time_values = iter([0.1, 0.2]) - positions = [] - - monkeypatch.setattr( - generator, - "_DefaultMoveGenerator__get_random_time", - lambda: next(time_values), - ) - - def fake_get_random_position(start_position, end_position): - positions.append((start_position, end_position)) - return Point(5, 0) - - monkeypatch.setattr( - generator, - "_DefaultMoveGenerator__get_random_position", - fake_get_random_position, - ) - - result = list(generator(Point(0, 0), Point(10, 0))) - - assert positions == [(Point(0, 0), Point(10, 0))] - assert result == [(Point(5, 0), 0.1), (Point(10, 0), 0.2)] - - -def test_anti_robot_mover_move_validation(): - mover = AntiRobotMover() - - with pytest.raises(TypeError, match="position must be Point"): - mover.move("position") - - -def test_anti_robot_mover_move(monkeypatch): - generated_points = [] - moved_points = [] - - def fake_move_generator(start_position, end_position): - generated_points.append((start_position, end_position)) - yield Point(5, 6), 0.1 - yield Point(7, 8), 0.2 - - monkeypatch.setattr(math_antirobot.mouse, "get_position", lambda: (1, 2)) - monkeypatch.setattr( - math_antirobot.mouse, - "move", - lambda x, y, duration=0: moved_points.append((x, y, duration)), - ) - - mover = AntiRobotMover(fake_move_generator) - mover.move(Point(10, 20)) - - assert generated_points == [(Point(1, 2), Point(10, 20))] - assert moved_points == [(5, 6, 0.1), (7, 8, 0.2)] diff --git a/tests/test_speakers.py b/tests/test_speakers.py deleted file mode 100644 index 405a48e..0000000 --- a/tests/test_speakers.py +++ /dev/null @@ -1,296 +0,0 @@ -import sys -import types - -import numpy -import pytest - - -def _install_optional_dependency_stubs(): - if "torch" not in sys.modules: - torch = types.ModuleType("torch") - torch.device = lambda value: value - - class _Hub: - @staticmethod - def load(**kwargs): - class _Model: - def to(self, device): - self.device = device - - def apply_tts(self, **settings): - class _Audio: - def detach(self): - return self - - def cpu(self): - return self - - def numpy(self): - return numpy.array([], dtype=numpy.float32) - - return _Audio() - - return _Model(), None - - torch.hub = _Hub() - sys.modules["torch"] = torch - - if "ChatTTS" not in sys.modules: - chattts = types.ModuleType("ChatTTS") - - class _Chat: - class InferCodeParams: - def __init__(self, spk_emb=None): - self.spk_emb = spk_emb - - def load(self, **kwargs): - pass - - def sample_random_speaker(self): - return "speaker" - - def infer(self, text, params_infer_code=None, **kwargs): - return [numpy.array([], dtype=numpy.float32)] - - chattts.Chat = _Chat - sys.modules["ChatTTS"] = chattts - - -_install_optional_dependency_stubs() - -import apparser.speakers.chattts as chattts_module -import apparser.speakers.torch as torch_module -from apparser.speakers.base import BaseSpeaker -from apparser.speakers.chattts import ChatTTSSpeaker -from apparser.speakers.torch import TorchSpeaker - - -class FakeTorchModule: - def __init__(self): - self.device_calls = [] - self.hub_calls = [] - self.model = None - self.audio = None - - class _Hub: - pass - - self.hub = _Hub() - self.hub.load = self._load - - def device(self, value): - self.device_calls.append(value) - return f"device:{value}" - - def _load(self, **kwargs): - self.hub_calls.append(kwargs) - - class FakeAudio: - def __init__(self, data): - self.data = numpy.asarray(data) - - def detach(self): - return self - - def cpu(self): - return self - - def numpy(self): - return self.data - - class FakeModel: - def __init__(self, owner): - self.owner = owner - self.to_calls = [] - self.tts_calls = [] - - def to(self, device): - self.to_calls.append(device) - - def apply_tts(self, **settings): - self.tts_calls.append(settings) - return FakeAudio(self.owner.audio) - - self.model = FakeModel(self) - return self.model, None - - -class FakeChatTTSModule: - def __init__(self): - owner = self - - class InferCodeParams: - def __init__(self, spk_emb=None): - self.spk_emb = spk_emb - - class Chat: - def __init__(self): - self.load_calls = [] - self.infer_calls = [] - self.result = [] - self.sample_calls = 0 - owner.chat = self - - def load(self, **kwargs): - self.load_calls.append(kwargs) - - def sample_random_speaker(self): - self.sample_calls += 1 - return "random-speaker" - - def infer(self, text, params_infer_code=None, **kwargs): - self.infer_calls.append((text, params_infer_code, kwargs)) - return self.result - - Chat.InferCodeParams = InferCodeParams - self.Chat = Chat - self.chat = None - - -def test_base_speaker_is_abstract(): - with pytest.raises(TypeError): - BaseSpeaker() - - -def test_torch_speaker_init_and_speak(monkeypatch): - fake_torch = FakeTorchModule() - monkeypatch.setattr(torch_module.importlib, "import_module", lambda name: fake_torch) - - fake_torch.audio = numpy.array([0.1, 0.2], dtype=numpy.float32) - speaker = TorchSpeaker( - language="en", - speaker_model="v4_en", - speaker="alex", - sample_rate=22050, - device="cuda", - repo_or_dir="repo", - model="model", - source="local", - trust_repo=True, - skip_validation=False, - foo="bar", - ) - audio = speaker.speak("hello", pitch=2) - - assert fake_torch.hub_calls == [ - { - "repo_or_dir": "repo", - "model": "model", - "language": "en", - "speaker": "v4_en", - "source": "local", - "foo": "bar", - "trust_repo": True, - "skip_validation": False, - } - ] - assert fake_torch.device_calls == ["cuda"] - assert fake_torch.model.to_calls == ["device:cuda"] - assert fake_torch.model.tts_calls == [ - { - "text": "hello", - "speaker": "alex", - "sample_rate": 22050, - "pitch": 2, - } - ] - assert numpy.array_equal(audio, numpy.array([0.1, 0.2], dtype=numpy.float32)) - - -def test_chattts_speaker_init_uses_random_speaker(monkeypatch): - fake_chattts = FakeChatTTSModule() - fake_torch = FakeTorchModule() - monkeypatch.setattr( - chattts_module.importlib, - "import_module", - lambda name: {"ChatTTS": fake_chattts, "torch": fake_torch}[name], - ) - - ChatTTSSpeaker( - source="huggingface", - force_redownload=True, - compile=True, - custom_path="model-path", - device="cpu", - coef="coef", - use_flash_attn=True, - use_vllm=True, - experimental=True, - enable_cache=False, - ) - - assert fake_torch.device_calls == ["cpu"] - assert fake_chattts.chat.load_calls == [ - { - "source": "huggingface", - "force_redownload": True, - "compile": True, - "custom_path": "model-path", - "device": "device:cpu", - "coef": "coef", - "use_flash_attn": True, - "use_vllm": True, - "experimental": True, - "enable_cache": False, - } - ] - assert fake_chattts.chat.sample_calls == 1 - - -def test_chattts_speaker_speak_creates_default_params(monkeypatch): - fake_chattts = FakeChatTTSModule() - fake_torch = FakeTorchModule() - monkeypatch.setattr( - chattts_module.importlib, - "import_module", - lambda name: {"ChatTTS": fake_chattts, "torch": fake_torch}[name], - ) - - speaker = ChatTTSSpeaker(speaker="voice") - fake_chattts.chat.result = [ - numpy.array([0.1], dtype=numpy.float32), - numpy.array([0.2, 0.3], dtype=numpy.float32), - ] - audio = speaker.speak("hello", temperature=0.5) - - params = fake_chattts.chat.infer_calls[0][1] - - assert params.spk_emb == "voice" - assert fake_chattts.chat.infer_calls[0][0] == "hello" - assert fake_chattts.chat.infer_calls[0][2] == {"temperature": 0.5} - assert numpy.array_equal(audio, numpy.array([0.1, 0.2, 0.3], dtype=numpy.float32)) - - -def test_chattts_speaker_speak_updates_params_and_returns_single_audio(monkeypatch): - fake_chattts = FakeChatTTSModule() - fake_torch = FakeTorchModule() - monkeypatch.setattr( - chattts_module.importlib, - "import_module", - lambda name: {"ChatTTS": fake_chattts, "torch": fake_torch}[name], - ) - - speaker = ChatTTSSpeaker(speaker="voice") - params = fake_chattts.Chat.InferCodeParams() - fake_chattts.chat.result = [numpy.array([0.4, 0.5], dtype=numpy.float32)] - audio = speaker.speak("hello", params_infer_code=params) - - assert params.spk_emb == "voice" - assert numpy.array_equal(audio, numpy.array([0.4, 0.5], dtype=numpy.float32)) - - -def test_chattts_speaker_speak_returns_empty_array(monkeypatch): - fake_chattts = FakeChatTTSModule() - fake_torch = FakeTorchModule() - monkeypatch.setattr( - chattts_module.importlib, - "import_module", - lambda name: {"ChatTTS": fake_chattts, "torch": fake_torch}[name], - ) - - speaker = ChatTTSSpeaker(speaker="voice") - fake_chattts.chat.result = [] - audio = speaker.speak("hello") - - assert audio.dtype == numpy.float32 - assert audio.size == 0 diff --git a/tests/test_text_readers.py b/tests/test_text_readers.py deleted file mode 100644 index bf2f978..0000000 --- a/tests/test_text_readers.py +++ /dev/null @@ -1,213 +0,0 @@ -import sys -import types - -import easyocr -import numpy -from appwindows.geometry import Point - - -def _install_optional_dependency_stubs(): - if "torch" not in sys.modules: - torch = types.ModuleType("torch") - torch.device = lambda value: value - - class _Hub: - @staticmethod - def load(**kwargs): - class _Model: - def to(self, device): - self.device = device - - def apply_tts(self, **settings): - class _Audio: - def detach(self): - return self - - def cpu(self): - return self - - def numpy(self): - return numpy.array([], dtype=numpy.float32) - - return _Audio() - - return _Model(), None - - torch.hub = _Hub() - sys.modules["torch"] = torch - - if "ChatTTS" not in sys.modules: - chattts = types.ModuleType("ChatTTS") - - class _Chat: - class InferCodeParams: - def __init__(self, spk_emb=None): - self.spk_emb = spk_emb - - def load(self, **kwargs): - pass - - def sample_random_speaker(self): - return "speaker" - - def infer(self, text, params_infer_code=None, **kwargs): - return [numpy.array([], dtype=numpy.float32)] - - chattts.Chat = _Chat - sys.modules["ChatTTS"] = chattts - - -_install_optional_dependency_stubs() - -from apparser.text_readers.easy_ocr import EasyOcrReader -from apparser.text_readers.models.text_data import TextData -import apparser.text_readers.paddle_ocr as paddle_ocr_module -from apparser.text_readers.paddle_ocr import PaddleTextReader -from apparser.text_readers.screens_controller import ScreensController -from apparser.text_readers.white_black_reader import WhiteBlackReader -from tests.utils.readers import FakeTextReader - - -def test_easy_ocr_reader_default_lang_and_read_image(): - reader = EasyOcrReader(gpu=False) - image = numpy.array([[1, 2], [3, 4]]) - easyocr.last_reader.result = [ - ( - [(1.2, 2.8), (3.9, 4.1), (5.7, 6.3), (7.4, 8.6)], - "hello", - 0.99, - ) - ] - - result = reader.read_image(image, detail=1) - - assert easyocr.last_reader.lang_list == ["en"] - assert easyocr.last_reader.settings == {"gpu": False} - assert easyocr.last_reader.calls == [(image, {"detail": 1})] - assert result == [ - TextData( - "hello", - [Point(1, 2), Point(3, 4), Point(5, 6), Point(7, 8)], - ) - ] - - -def test_screens_controller_caches_images(): - ai_reader = FakeTextReader() - ai_reader.result = [TextData("cached", [Point(0, 0)])] - controller = ScreensController(ai_reader) - image = numpy.array([[1, 2], [3, 4]]) - - first = controller.read_image(image) - second = controller.read_image(image.copy()) - - assert first == ai_reader.result - assert second == ai_reader.result - assert len(ai_reader.calls) == 1 - - -def test_white_black_reader_converts_image_to_grayscale(): - ai_reader = FakeTextReader() - ai_reader.result = [TextData("gray", [Point(0, 0)])] - image = numpy.array([[[255, 0, 0], [0, 255, 0]]], dtype=numpy.uint8) - - result = WhiteBlackReader(ai_reader).read_image(image) - - assert result == ai_reader.result - assert len(ai_reader.calls) == 1 - assert ai_reader.calls[0].ndim == 2 - - -def test_paddle_text_reader_predict_read_image(monkeypatch): - created = {} - - class FakePaddleOCR: - def __init__(self, lang, **settings): - self.lang = lang - self.settings = settings - self.calls = [] - created["reader"] = self - - def predict(self, image, **settings): - self.calls.append((image, settings)) - return [ - { - "rec_texts": ["hello"], - "rec_polys": [ - [(1.2, 2.8), (3.9, 4.1), (5.7, 6.3), (7.4, 8.6)], - ], - }, - types.SimpleNamespace( - res={ - "rec_texts": ["world"], - "dt_polys": [ - [(10.2, 11.8), (12.9, 13.1), (14.7, 15.3), (16.4, 17.6)], - ], - } - ), - ] - - monkeypatch.setattr( - paddle_ocr_module.importlib, - "import_module", - lambda name: types.SimpleNamespace(PaddleOCR=FakePaddleOCR), - ) - - image = numpy.array([[1, 2], [3, 4]]) - reader = PaddleTextReader(lang="ru", use_doc_orientation_classify=False) - result = reader.read_image(image, cls=True) - - assert created["reader"].lang == "ru" - assert created["reader"].settings == {"use_doc_orientation_classify": False} - assert created["reader"].calls == [(image, {"cls": True})] - assert result == [ - TextData( - "hello", - [Point(1, 2), Point(3, 4), Point(5, 6), Point(7, 8)], - ), - TextData( - "world", - [Point(10, 11), Point(12, 13), Point(14, 15), Point(16, 17)], - ), - ] - - -def test_paddle_text_reader_ocr_read_image(monkeypatch): - created = {} - - class FakePaddleOCR: - def __init__(self, lang, **settings): - self.lang = lang - self.settings = settings - self.calls = [] - created["reader"] = self - - def ocr(self, image, **settings): - self.calls.append((image, settings)) - return [[ - ( - [(1.2, 2.8), (3.9, 4.1), (5.7, 6.3), (7.4, 8.6)], - ("hello", 0.99), - ), - None, - ]] - - monkeypatch.setattr( - paddle_ocr_module.importlib, - "import_module", - lambda name: types.SimpleNamespace(PaddleOCR=FakePaddleOCR), - ) - - image = numpy.array([[1, 2], [3, 4]]) - reader = PaddleTextReader(lang="en", use_angle_cls=True) - result = reader.read_image(image, det=True) - - assert created["reader"].lang == "en" - assert created["reader"].settings == {"use_angle_cls": True} - assert created["reader"].calls == [(image, {"det": True})] - assert result == [ - TextData( - "hello", - [Point(1, 2), Point(3, 4), Point(5, 6), Point(7, 8)], - ) - ] diff --git a/tests/tests_core/__init__.py b/tests/tests_core/__init__.py deleted file mode 100644 index 67ad1ef..0000000 --- a/tests/tests_core/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Core tests.""" diff --git a/tests/tests_core/test_app.py b/tests/tests_core/test_app.py deleted file mode 100644 index 931fc99..0000000 --- a/tests/tests_core/test_app.py +++ /dev/null @@ -1,81 +0,0 @@ -"""Tests for the App class.""" - -import pytest -from appwindows.geometry import Size - -import apparser.core.app as app_module -from apparser.core.app import App - - -@pytest.mark.parametrize( - ("path_to_exe", "window_title", "window_size", "timeout", "error", "message"), - [ - (1, "title", Size(1, 1), 1, TypeError, "path_to_exe must be a string"), - ("app.exe", 1, Size(1, 1), 1, TypeError, "window_title must be a string"), - ("app.exe", "title", "size", 1, TypeError, "window_size must be a Size"), - ("app.exe", "title", Size(1, 1), "1", TypeError, "timeout must be a number"), - ], -) -def test_app_init_validation( - monkeypatch, - path_to_exe, - window_title, - window_size, - timeout, - error, - message, -): - monkeypatch.setattr(app_module.App, "start_app", lambda self: None) - - with pytest.raises(error, match=message): - App(path_to_exe, window_title, window_size, timeout) - - -def test_app_start_and_stop(monkeypatch): - popen_calls = [] - sleep_calls = [] - resize_calls = [] - close_calls = [] - kill_calls = [] - windows = [] - - class FakeProcess: - def kill(self): - kill_calls.append(True) - - class FakeWindow: - def resize(self, size): - resize_calls.append(size) - - def close(self): - close_calls.append(True) - - class FakeWindowUi: - def __init__(self, window): - windows.append(window) - self.window = window - - class FakeFinder: - def get_window_by_title(self, title): - assert title == "window" - return FakeWindow() - - monkeypatch.setattr( - app_module.subprocess, - "Popen", - lambda args: popen_calls.append(args) or FakeProcess(), - ) - monkeypatch.setattr(app_module.time, "sleep", lambda timeout: sleep_calls.append(timeout)) - monkeypatch.setattr(app_module, "get_finder", lambda: FakeFinder()) - monkeypatch.setattr(app_module, "WindowUi", FakeWindowUi) - - app = App("app.exe", "window", Size(100, 200), 2) - app.stop_app() - - assert popen_calls == [["app.exe"]] - assert sleep_calls == [2] - assert isinstance(app.ui, FakeWindowUi) - assert len(windows) == 1 - assert resize_calls == [Size(100, 200)] - assert close_calls == [True] - assert kill_calls == [True] diff --git a/tests/tests_core/test_ui.py b/tests/tests_core/test_ui.py deleted file mode 100644 index 8241c1b..0000000 --- a/tests/tests_core/test_ui.py +++ /dev/null @@ -1,153 +0,0 @@ -"""Tests for UI classes.""" - -from types import SimpleNamespace - -import numpy -import pytest -from PIL import Image -from appwindows.geometry import Point, Size - -import apparser.core.ui.desktop as desktop_module -import apparser.core.ui.window as window_module -from apparser.core.ui.base import BaseUi -from apparser.core.ui.coordinates import CoordinatesUi -from apparser.core.ui.desktop import DesktopUi -from apparser.core.ui.window import WindowUi -from apparser.exceptions import WindowActionWithDesktopException -from apparser.geometry import RelativelyPoint - - -class DummyUi(BaseUi): - def __init__(self): - self._window = SimpleNamespace(name="window") - self._screenshot = numpy.arange(10000).reshape(100, 100) - - def point_to_global(self, coordinates): - if isinstance(coordinates, RelativelyPoint): - coordinates = Point(round(coordinates.x * 100), round(coordinates.y * 80)) - if not isinstance(coordinates, Point): - raise NotImplementedError() - return coordinates + Point(100, 200) - - def point_to_local(self, coordinates): - if not isinstance(coordinates, Point): - raise NotImplementedError() - return coordinates - Point(100, 200) - - def get_screenshot(self): - return self._screenshot - - @property - def window(self): - return self._window - - -@pytest.mark.parametrize( - ("from_ui", "left_top_point", "size", "error", "message"), - [ - ("ui", Point(1, 2), Size(10, 10), TypeError, "from_ui must be Ui"), - ( - DummyUi(), - "point", - Size(10, 10), - TypeError, - "left_top_point must be Point or RelativelyPoint", - ), - (DummyUi(), Point(1, 2), "size", TypeError, "size must be Size"), - ], -) -def test_coordinates_ui_validation(from_ui, left_top_point, size, error, message): - with pytest.raises(error, match=message): - CoordinatesUi(from_ui, left_top_point, size) - - -def test_coordinates_ui_methods(): - from_ui = DummyUi() - ui = CoordinatesUi(from_ui, Point(10, 20), Size(30, 40)) - - assert ui.point_to_global(Point(1, 2)) == Point(111, 222) - assert ui.point_to_global(RelativelyPoint(0.5, 0.25)) == Point(125, 230) - assert ui.point_to_local(Point(140, 260)) == Point(30, 40) - assert numpy.array_equal(ui.get_screenshot(), from_ui.get_screenshot()[20:60, 10:40]) - assert ui.window is from_ui.window - - with pytest.raises(NotImplementedError): - ui.point_to_global("coordinates") - - -def test_coordinates_ui_with_relative_left_top_point(): - ui = CoordinatesUi(DummyUi(), RelativelyPoint(0.1, 0.25), Size(30, 40)) - - assert ui.point_to_global(Point(1, 2)) == Point(111, 222) - assert ui.point_to_local(Point(130, 260)) == Point(20, 40) - - -def test_coordinates_ui_get_screenshot_for_image(): - from_ui = DummyUi() - from_ui._screenshot = Image.fromarray( - numpy.arange(10000, dtype=numpy.uint8).reshape(100, 100) - ) - ui = CoordinatesUi(from_ui, Point(10, 20), Size(30, 40)) - - assert numpy.array_equal( - numpy.asarray(ui.get_screenshot()), - numpy.asarray(from_ui.get_screenshot().crop((10, 20, 40, 60))), - ) - - -def test_desktop_ui_methods(monkeypatch): - image = Image.new("RGB", (2, 2), color="white") - monkeypatch.setattr( - desktop_module, - "get_monitors", - lambda: [SimpleNamespace(width=200, height=100)], - ) - monkeypatch.setattr(desktop_module.ImageGrab, "grab", lambda: image) - - ui = DesktopUi() - - assert ui.point_to_global(Point(3, 4)) == Point(3, 4) - assert ui.point_to_global(RelativelyPoint(0.5, 0.25)) == Point(100, 25) - assert ui.point_to_local(Point(5, 6)) == Point(5, 6) - assert numpy.array_equal(ui.get_screenshot(), numpy.asarray(image)) - - with pytest.raises(WindowActionWithDesktopException): - _ = ui.window - - -def test_desktop_ui_point_to_global_for_unknown_type(): - ui = DesktopUi() - - with pytest.raises(NotImplementedError): - ui.point_to_global("coordinates") - - -def test_window_ui_validation_and_methods(monkeypatch): - screenshot = numpy.array([[1, 2], [3, 4]]) - - class FakeWindow: - def get_points(self): - return SimpleNamespace(left_top=Point(10, 20)) - - def get_size(self): - return Size(200, 100) - - def get_screenshot(self): - return screenshot - - monkeypatch.setattr(window_module, "Window", FakeWindow) - - with pytest.raises(TypeError, match="window must be Window"): - WindowUi(object()) - - window = FakeWindow() - ui = WindowUi(window) - - assert ui.point_to_global(Point(1, 2)) == Point(11, 22) - assert ui.point_to_global(RelativelyPoint(0.5, 0.25)) == Point(110, 45) - assert ui.point_to_local(Point(15, 28)) == Point(5, 8) - assert numpy.array_equal(ui.get_screenshot(), screenshot) - assert ui.window is window - - with pytest.raises(NotImplementedError): - ui.point_to_global("coordinates") diff --git a/tests/tests_cv/__init__.py b/tests/tests_cv/__init__.py deleted file mode 100644 index 5add7b3..0000000 --- a/tests/tests_cv/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Computer-vision tests.""" diff --git a/tests/tests_cv/test_events.py b/tests/tests_cv/test_events.py deleted file mode 100644 index d961b9f..0000000 --- a/tests/tests_cv/test_events.py +++ /dev/null @@ -1,10 +0,0 @@ -"""Tests for CV events.""" - -from apparser.cv.events import Detected, Moved, Resized, UnDetected - - -def test_cv_event_strings(): - assert str(Detected()) == "Detected" - assert str(Moved()) == "Moved" - assert str(Resized()) == "Resized" - assert str(UnDetected()) == "UnDetected" diff --git a/tests/tests_cv/test_handlers.py b/tests/tests_cv/test_handlers.py deleted file mode 100644 index ec876ed..0000000 --- a/tests/tests_cv/test_handlers.py +++ /dev/null @@ -1,54 +0,0 @@ -"""Tests for CV handlers.""" - -import pytest - -from apparser.cv.events import CvEvent, Detected -from apparser.cv.handlers.default import DefaultHandlers, _form_args -from apparser.cv.models import CvAllData, CvChangeData -from tests.utils.cv import CvUi, make_cv_box - - -def test_form_args_matches_only_exact_types(): - ui = CvUi() - changed_data = CvChangeData( - Detected, - make_cv_box(ui=ui), - make_cv_box(ui=ui), - ) - all_data = CvAllData([]) - - def handler(data: CvAllData, ui_arg: CvUi, change: CvChangeData): - return data, ui_arg, change - - assert _form_args(handler, changed_data, all_data, ui) == { - "data": all_data, - "ui_arg": ui, - "change": changed_data, - } - - -def test_default_handlers_register_and_call(): - calls = [] - handlers = DefaultHandlers() - ui = CvUi() - changed_data = CvChangeData( - Detected, - make_cv_box(ui=ui), - make_cv_box(ui=ui), - ) - all_data = CvAllData([changed_data.box]) - - @handlers.register_handler(Detected) - def on_detected(change: CvChangeData, data: CvAllData, ui_arg: CvUi): - calls.append(("detected", change, data, ui_arg)) - - @handlers.register_handler(Detected, class_name="dog") - def on_dog(change: CvChangeData): - calls.append(("dog", change)) - - handlers.call(Detected, changed_data, all_data, ui) - - with pytest.raises(TypeError, match="event must be a apparser.cv.events.CvEvent"): - handlers.register_handler(CvEvent) - - assert calls == [("detected", changed_data, all_data, ui)] diff --git a/tests/tests_cv/test_models.py b/tests/tests_cv/test_models.py deleted file mode 100644 index dd9f7c7..0000000 --- a/tests/tests_cv/test_models.py +++ /dev/null @@ -1,24 +0,0 @@ -"""Tests for CV data models.""" - -from apparser.cv.events import Detected -from apparser.cv.models import CvAllData, CvChangeData -from tests.utils.cv import CvUi, make_cv_box - - -def test_cv_models_store_data(): - ui = CvUi() - box = make_cv_box(class_name="bird", class_id=3, x=7, y=8, width=9, height=10, ui=ui) - all_data = CvAllData([box]) - change = CvChangeData(Detected, box, box) - - assert box.class_name == "bird" - assert box.track_id == 3 - assert box.x == 7 - assert box.y == 8 - assert box.width == 9 - assert box.height == 10 - assert box.ui is ui - assert all_data.boxes == [box] - assert change.event is Detected - assert change.box is box - assert change.old_box is box diff --git a/tests/tests_cv/test_processes.py b/tests/tests_cv/test_processes.py deleted file mode 100644 index 949ee49..0000000 --- a/tests/tests_cv/test_processes.py +++ /dev/null @@ -1,38 +0,0 @@ -"""Tests for CV processes.""" - -from apparser.cv.events import Detected -from apparser.cv.models import CvAllData, CvChangeData -from apparser.cv.processes.default import DefaultCvProcess -from tests.utils.cv import CvUi, make_cv_box - - -def test_default_cv_process_start_stop_and_include_handlers(): - ui = CvUi() - change = CvChangeData(Detected, make_cv_box(ui=ui), make_cv_box(ui=ui)) - reader_calls = [] - checker_calls = [] - handler_calls = [] - - class FakeReader: - def read(self, ui_arg): - reader_calls.append(ui_arg) - return CvAllData([change.box]) - - class FakeChecker: - def check(self, data): - checker_calls.append(data) - return [change] - - process = DefaultCvProcess(reader=FakeReader(), changes_checker=FakeChecker()) - - class FakeHandler: - def call(self, event, changed_data, cv_data, ui_arg): - handler_calls.append((event, changed_data, cv_data, ui_arg)) - process.stop() - - process.include_handlers(FakeHandler()) - process.start(ui) - - assert reader_calls == [ui] - assert checker_calls == [CvAllData([change.box])] - assert handler_calls == [(Detected, change, CvAllData([change.box]), ui)] diff --git a/tests/tests_cv/test_readers.py b/tests/tests_cv/test_readers.py deleted file mode 100644 index d1515c0..0000000 --- a/tests/tests_cv/test_readers.py +++ /dev/null @@ -1,114 +0,0 @@ -import sys -import types - -from appwindows.geometry import Point -import numpy -import ultralytics - - -def _install_optional_dependency_stubs(): - if "torch" not in sys.modules: - torch = types.ModuleType("torch") - torch.device = lambda value: value - - class _Hub: - @staticmethod - def load(**kwargs): - class _Model: - def to(self, device): - self.device = device - - def apply_tts(self, **settings): - class _Audio: - def detach(self): - return self - - def cpu(self): - return self - - def numpy(self): - return numpy.array([], dtype=numpy.float32) - - return _Audio() - - return _Model(), None - - torch.hub = _Hub() - sys.modules["torch"] = torch - - if "ChatTTS" not in sys.modules: - chattts = types.ModuleType("ChatTTS") - - class _Chat: - class InferCodeParams: - def __init__(self, spk_emb=None): - self.spk_emb = spk_emb - - def load(self, **kwargs): - pass - - def sample_random_speaker(self): - return "speaker" - - def infer(self, text, params_infer_code=None, **kwargs): - return [numpy.array([], dtype=numpy.float32)] - - chattts.Chat = _Chat - sys.modules["ChatTTS"] = chattts - - -_install_optional_dependency_stubs() - -from apparser.core.ui.coordinates import CoordinatesUi -from apparser.cv.readers.yolo import YoloReader -from tests.utils.cv import CvUi - - -def test_yolo_reader_init_and_read(): - reader = YoloReader(model="fake.pt") - fake_box = type( - "FakeBox", - (), - { - "id": type("FakeId", (), {"item": staticmethod(lambda: 42)})(), - "cls": type("FakeCls", (), {"item": staticmethod(lambda: 0)})(), - "xyxy": [type("FakeCoords", (), {"tolist": staticmethod(lambda: [1.2, 2.4, 8.8, 12.9])})()], - }, - )() - ultralytics.last_model.model.names = {0: "cat"} - ultralytics.last_model.results = [type("FakeResult", (), {"boxes": [fake_box]})()] - image = numpy.array([[1, 2], [3, 4]]) - ui = CvUi(image) - - result = reader.read(ui) - - assert ultralytics.last_model.kwargs == {"model": "fake.pt"} - assert ultralytics.last_model.calls == [] - assert ultralytics.last_model.track_calls == [(image, True, {})] - assert len(result.boxes) == 1 - assert result.boxes[0].class_name == "cat" - assert result.boxes[0].track_id == 42 - assert result.boxes[0].x == 1 - assert result.boxes[0].y == 2 - assert result.boxes[0].width == 7 - assert result.boxes[0].height == 10 - assert isinstance(result.boxes[0].ui, CoordinatesUi) - assert result.boxes[0].ui.point_to_global(Point(0, 0)) == Point(1, 2) - - -def test_yolo_reader_reads_zero_tracking_id(): - reader = YoloReader(model="fake.pt") - fake_box = type( - "FakeBox", - (), - { - "id": type("FakeId", (), {"item": staticmethod(lambda: 0)})(), - "cls": type("FakeCls", (), {"item": staticmethod(lambda: 0)})(), - "xyxy": [type("FakeCoords", (), {"tolist": staticmethod(lambda: [1.2, 2.4, 8.8, 12.9])})()], - }, - )() - ultralytics.last_model.model.names = {0: "cat"} - ultralytics.last_model.results = [type("FakeResult", (), {"boxes": [fake_box]})()] - result = reader.read(CvUi(numpy.array([[1, 2], [3, 4]]))) - - assert result.boxes[0].track_id == 0 diff --git a/tests/tests_cv/test_utils.py b/tests/tests_cv/test_utils.py deleted file mode 100644 index 335dc6d..0000000 --- a/tests/tests_cv/test_utils.py +++ /dev/null @@ -1,45 +0,0 @@ -"""Tests for CV utilities.""" - -import pytest - -from apparser.cv.events import Detected, Moved, Resized, UnDetected -from apparser.cv.models import CvAllData, CvChangeData -from apparser.cv.utils.changes_checker import ChangesChecker, _is_moved, _is_resized -from tests.utils.cv import CvUi, make_cv_box - - -def test_changes_checker_helpers(): - ui = CvUi() - old_box = make_cv_box(x=1, y=1, width=10, height=10, ui=ui) - moved_and_resized = make_cv_box(x=2, y=3, width=12, height=13, ui=ui) - only_x_changed = make_cv_box(x=2, y=1, width=12, height=13, ui=ui) - - assert _is_moved(moved_and_resized, old_box) is True - assert _is_moved(only_x_changed, old_box) is True - assert _is_resized(moved_and_resized, old_box) is True - assert _is_resized(make_cv_box(x=2, y=3, width=12, height=10, ui=ui), old_box) is False - - -def test_changes_checker_initial_state_returns_no_events(): - assert ChangesChecker().check(CvAllData([])) == [] - - -def test_changes_checker_check_with_preloaded_old_data(): - checker = ChangesChecker() - old_ui = CvUi() - new_ui = CvUi() - old_cat = make_cv_box(x=0, y=0, width=10, height=10, ui=old_ui) - old_dog = make_cv_box(class_name="dog", class_id=2, x=5, y=5, width=20, height=20, ui=old_ui) - new_cat = make_cv_box(x=1, y=1, width=12, height=12, ui=new_ui) - new_bird = make_cv_box(class_name="bird", class_id=3, x=7, y=8, width=9, height=10, ui=new_ui) - checker._ChangesChecker__old_data = CvAllData([old_cat, old_dog]) - data = CvAllData([new_cat, new_bird]) - - result = checker.check(data) - - assert result == [ - CvChangeData(UnDetected, old_dog, old_dog), - CvChangeData(Moved, new_cat, old_cat), - CvChangeData(Resized, new_cat, old_cat), - CvChangeData(Detected, new_bird, new_bird), - ] diff --git a/tests/tests_instructions/__init__.py b/tests/tests_instructions/__init__.py deleted file mode 100644 index a088390..0000000 --- a/tests/tests_instructions/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Instruction tests.""" diff --git a/tests/tests_instructions/test_default.py b/tests/tests_instructions/test_default.py deleted file mode 100644 index 9886eee..0000000 --- a/tests/tests_instructions/test_default.py +++ /dev/null @@ -1,433 +0,0 @@ -import sys -import types - -import numpy -import pytest - - -def _install_optional_dependency_stubs(): - if "torch" not in sys.modules: - torch = types.ModuleType("torch") - torch.device = lambda value: value - - class _Hub: - @staticmethod - def load(**kwargs): - class _Model: - def to(self, device): - self.device = device - - def apply_tts(self, **settings): - class _Audio: - def detach(self): - return self - - def cpu(self): - return self - - def numpy(self): - return numpy.array([], dtype=numpy.float32) - - return _Audio() - - return _Model(), None - - torch.hub = _Hub() - sys.modules["torch"] = torch - - if "ChatTTS" not in sys.modules: - chattts = types.ModuleType("ChatTTS") - - class _Chat: - class InferCodeParams: - def __init__(self, spk_emb=None): - self.spk_emb = spk_emb - - def load(self, **kwargs): - pass - - def sample_random_speaker(self): - return "speaker" - - def infer(self, text, params_infer_code=None, **kwargs): - return [numpy.array([], dtype=numpy.float32)] - - chattts.Chat = _Chat - sys.modules["ChatTTS"] = chattts - - -_install_optional_dependency_stubs() - -import apparser.instructions.default.click as click_module -import apparser.instructions.default.play_audio as play_audio_module -import apparser.instructions.default.play_audio_file as play_audio_file_module -import apparser.instructions.default.press as press_module -import apparser.instructions.default.say_audio as say_audio_module -import apparser.instructions.default.say_audio_file as say_audio_file_module -import apparser.instructions.default.sleep as sleep_module -import apparser.instructions.default.write_text as write_text_module -from apparser.instructions.default.click import MouseClick -from apparser.instructions.default.play_audio import PlayAudio -from apparser.instructions.default.play_audio_file import PlayAudioFile -from apparser.instructions.default.press import PressKey, PressKeysCombination -from apparser.instructions.default.say_audio import SayAudio -from apparser.instructions.default.say_audio_file import SayAudioFile -from apparser.instructions.default.sleep import Sleep -from apparser.instructions.default.write_text import WriteText -from apparser.key_codes import RightClick - - -class FakeSoundDevice: - def __init__(self): - self.check_calls = [] - self.play_calls = [] - - def check_output_settings(self, **kwargs): - self.check_calls.append(kwargs) - - def play(self, audio, **kwargs): - self.play_calls.append((audio, kwargs)) - - -class FakeWaveReader: - def __init__(self, samples, sample_rate=8000): - self.samples = numpy.asarray(samples) - self.sample_rate = sample_rate - - def __enter__(self): - return self - - def __exit__(self, exc_type, exc, tb): - return False - - def getnchannels(self): - if self.samples.ndim == 1: - return 1 - return self.samples.shape[1] - - def getframerate(self): - return self.sample_rate - - def getsampwidth(self): - return self.samples.dtype.itemsize - - def getnframes(self): - return len(self.samples) - - def readframes(self, frames): - return self.samples.tobytes() - - -def test_mouse_click_validation_and_perform(monkeypatch): - calls = [] - monkeypatch.setattr(click_module.mouse, "click", lambda: calls.append("left")) - monkeypatch.setattr(click_module.mouse, "right_click", lambda: calls.append("right")) - - instruction = MouseClick() - instruction.perform() - MouseClick(RightClick()).perform() - - with pytest.raises(TypeError, match="click_type must be RightClick or LeftClick"): - MouseClick("click") - - assert calls == ["left", "right"] - - -def test_press_key_and_combination(monkeypatch): - send_calls = [] - press_calls = [] - release_calls = [] - monkeypatch.setattr(press_module.keyboard, "send", lambda key: send_calls.append(key)) - monkeypatch.setattr(press_module.keyboard, "press", lambda key: press_calls.append(key)) - monkeypatch.setattr(press_module.keyboard, "release", lambda key: release_calls.append(key)) - - instruction = PressKey("a") - instruction.perform() - PressKey(RightClick()).perform() - PressKeysCombination(["ctrl", "c"]).perform() - - with pytest.raises(TypeError, match="key_code must be KeyCode or str"): - PressKey(1) - - with pytest.raises(TypeError, match="key_code must be KeyCode or str"): - PressKeysCombination(["ctrl", 1]).perform() - - assert send_calls == ["a", "RIGHT"] - assert press_calls == ["ctrl", "c", "ctrl"] - assert release_calls == ["ctrl", "c"] - - -def test_sleep_validation_and_perform(monkeypatch): - sleep_calls = [] - monkeypatch.setattr(sleep_module.time, "sleep", lambda seconds: sleep_calls.append(seconds)) - - with pytest.raises(ValueError, match="sleep_time must be >= 0"): - Sleep(0) - - instruction = Sleep(0.5) - instruction.perform() - - assert sleep_calls == [0.5] - - -def test_write_text_validation_and_perform(monkeypatch): - calls = [] - monkeypatch.setattr(write_text_module.keyboard, "write", lambda text, pause: calls.append((text, pause))) - - with pytest.raises(TypeError, match="text must be a string"): - WriteText(1) - - with pytest.raises(TypeError, match="pause_time must be a number"): - WriteText("text", "0.1") - - with pytest.raises(ValueError, match="text cannot be empty"): - WriteText("") - - instruction = WriteText("hello", 0.2) - instruction.perform() - - assert calls == [("hello", 0.2)] - - -@pytest.mark.parametrize( - ("kwargs", "exception", "message"), - [ - ({"audio": [0.1], "sample_rate": "48000"}, TypeError, "sample_rate must be a number"), - ({"audio": [0.1], "sample_rate": 0}, ValueError, "sample_rate must be > 0"), - ({"audio": [0.1], "blocking": "yes"}, TypeError, "blocking must be bool"), - ({"audio": [0.1], "device": object()}, TypeError, "device must be int, str or None"), - ({"audio": numpy.zeros((1, 1, 1))}, ValueError, "audio must be 1D or 2D array"), - ({"audio": numpy.array([], dtype=numpy.float32)}, ValueError, "audio cannot be empty"), - ({"audio": numpy.empty((1, 0), dtype=numpy.float32)}, ValueError, "audio cannot be empty"), - ], -) -def test_play_audio_validation(kwargs, exception, message, monkeypatch): - fake_sounddevice = FakeSoundDevice() - monkeypatch.setattr(play_audio_module.importlib, "import_module", lambda name: fake_sounddevice) - - with pytest.raises(exception, match=message): - PlayAudio(**kwargs) - - -def test_play_audio_perform(monkeypatch): - fake_sounddevice = FakeSoundDevice() - monkeypatch.setattr(play_audio_module.importlib, "import_module", lambda name: fake_sounddevice) - - instruction = PlayAudio( - audio=[[0.1, 0.2], [0.3, 0.4]], - sample_rate=48000, - device="speaker", - blocking=False, - mapping=[1, 2], - latency="low", - ) - instruction.perform(samplerate=16000, device="headphones", blocking=True) - - audio, settings = fake_sounddevice.play_calls[0] - - assert fake_sounddevice.check_calls == [ - { - "device": "headphones", - "channels": 2, - "dtype": "float32", - "samplerate": 16000, - } - ] - assert isinstance(audio, numpy.ndarray) - assert audio.dtype == numpy.float32 - assert settings == { - "samplerate": 16000, - "device": "headphones", - "blocking": True, - "mapping": [1, 2], - "latency": "low", - } - - -def test_play_audio_file_validation(): - with pytest.raises(TypeError, match="path must be str"): - PlayAudioFile(1) - - with pytest.raises(ValueError, match="path cannot be empty"): - PlayAudioFile("") - - with pytest.raises(ValueError, match="file does not exist"): - PlayAudioFile("missing.wav") - - -def test_play_audio_file_reads_audio_and_delegates(monkeypatch): - calls = [] - - class FakePlayAudio: - def __init__(self, **kwargs): - calls.append(("init", kwargs)) - - def perform(self, *args, **kwargs): - calls.append(("perform", args, kwargs)) - - monkeypatch.setattr(play_audio_file_module, "PlayAudio", FakePlayAudio) - monkeypatch.setattr(play_audio_file_module.pathlib.Path, "is_file", lambda self: True) - monkeypatch.setattr( - play_audio_file_module.wave, - "open", - lambda path, mode: FakeWaveReader(numpy.array([[0, 32767], [-32768, 0]], dtype=numpy.int16)), - ) - - instruction = PlayAudioFile( - "audio.wav", - device="speaker", - blocking=False, - mapping=[1, 2], - ) - instruction.perform("arg", key="value") - - kwargs = calls[0][1] - - assert kwargs["sample_rate"] == 8000 - assert kwargs["device"] == "speaker" - assert kwargs["blocking"] is False - assert kwargs["mapping"] == [1, 2] - assert kwargs["audio"].dtype == numpy.float32 - assert kwargs["audio"].shape == (2, 2) - assert numpy.allclose( - kwargs["audio"], - numpy.array([[0.0, 32767 / 32768], [-1.0, 0.0]], dtype=numpy.float32), - ) - assert calls[1] == ("perform", ("arg",), {"key": "value"}) - - -@pytest.mark.parametrize( - ("kwargs", "exception", "message"), - [ - ({"audio": [0.1], "sample_rate": "48000"}, TypeError, "sample_rate must be a number"), - ({"audio": [0.1], "sample_rate": 0}, ValueError, "sample_rate must be > 0"), - ({"audio": [0.1], "blocking": "yes"}, TypeError, "blocking must be bool"), - ( - {"audio": [0.1], "microphone_device": object()}, - TypeError, - "microphone_device must be int, str or None", - ), - ({"audio": numpy.zeros((1, 1, 1))}, ValueError, "audio must be 1D or 2D array"), - ({"audio": numpy.array([], dtype=numpy.float32)}, ValueError, "audio cannot be empty"), - ({"audio": numpy.empty((1, 0), dtype=numpy.float32)}, ValueError, "audio cannot be empty"), - ], -) -def test_say_audio_validation(kwargs, exception, message, monkeypatch): - fake_sounddevice = FakeSoundDevice() - monkeypatch.setattr(say_audio_module.importlib, "import_module", lambda name: fake_sounddevice) - - with pytest.raises(exception, match=message): - SayAudio(**kwargs) - - -def test_say_audio_perform_uses_microphone_device(monkeypatch): - fake_sounddevice = FakeSoundDevice() - monkeypatch.setattr(say_audio_module.importlib, "import_module", lambda name: fake_sounddevice) - - instruction = SayAudio( - audio=[[0.1, 0.2], [0.3, 0.4]], - sample_rate=48000, - microphone_device="microphone", - blocking=False, - mapping=[1], - latency="low", - ) - instruction.perform(samplerate=16000, microphone_device="override", blocking=True) - - audio, settings = fake_sounddevice.play_calls[0] - - assert fake_sounddevice.check_calls == [ - { - "device": "override", - "channels": 1, - "dtype": "float32", - "samplerate": 16000, - } - ] - assert isinstance(audio, numpy.ndarray) - assert audio.dtype == numpy.float32 - assert settings == { - "samplerate": 16000, - "device": "override", - "blocking": True, - "mapping": [1], - "latency": "low", - } - - -def test_say_audio_perform_uses_device_fallback(monkeypatch): - fake_sounddevice = FakeSoundDevice() - monkeypatch.setattr(say_audio_module.importlib, "import_module", lambda name: fake_sounddevice) - - instruction = SayAudio(audio=[0.1], sample_rate=48000) - instruction.perform(device="fallback") - - assert fake_sounddevice.check_calls == [ - { - "device": "fallback", - "channels": 1, - "dtype": "float32", - "samplerate": 48000, - } - ] - - -def test_say_audio_perform_raises_without_microphone_device(monkeypatch): - fake_sounddevice = FakeSoundDevice() - monkeypatch.setattr(say_audio_module.importlib, "import_module", lambda name: fake_sounddevice) - - instruction = SayAudio(audio=[0.1], sample_rate=48000) - - with pytest.raises(ValueError, match="microphone_device cannot be None"): - instruction.perform() - - -def test_say_audio_file_validation(): - with pytest.raises(TypeError, match="path must be str"): - SayAudioFile(1) - - with pytest.raises(ValueError, match="path cannot be empty"): - SayAudioFile("") - - with pytest.raises(ValueError, match="file does not exist"): - SayAudioFile("missing.wav") - - -def test_say_audio_file_reads_audio_and_delegates(monkeypatch): - calls = [] - - class FakeSayAudio: - def __init__(self, **kwargs): - calls.append(("init", kwargs)) - - def perform(self, *args, **kwargs): - calls.append(("perform", args, kwargs)) - - monkeypatch.setattr(say_audio_file_module, "SayAudio", FakeSayAudio) - monkeypatch.setattr(say_audio_file_module.pathlib.Path, "is_file", lambda self: True) - monkeypatch.setattr( - say_audio_file_module.wave, - "open", - lambda path, mode: FakeWaveReader(numpy.array([0, 255], dtype=numpy.uint8)), - ) - - instruction = SayAudioFile( - "audio.wav", - microphone_device="microphone", - blocking=False, - mapping=[1], - ) - instruction.perform("arg", key="value") - - kwargs = calls[0][1] - - assert kwargs["sample_rate"] == 8000 - assert kwargs["microphone_device"] == "microphone" - assert kwargs["blocking"] is False - assert kwargs["mapping"] == [1] - assert kwargs["audio"].dtype == numpy.float32 - assert numpy.allclose( - kwargs["audio"], - numpy.array([-1.0, 127 / 128], dtype=numpy.float32), - ) - assert calls[1] == ("perform", ("arg",), {"key": "value"}) diff --git a/tests/tests_instructions/test_ocr.py b/tests/tests_instructions/test_ocr.py deleted file mode 100644 index be6a632..0000000 --- a/tests/tests_instructions/test_ocr.py +++ /dev/null @@ -1,296 +0,0 @@ -import sys -import types -from types import SimpleNamespace - -import numpy -import pytest -from appwindows.geometry import Point - - -def _install_optional_dependency_stubs(): - if "torch" not in sys.modules: - torch = types.ModuleType("torch") - torch.device = lambda value: value - - class _Hub: - @staticmethod - def load(**kwargs): - class _Model: - def to(self, device): - self.device = device - - def apply_tts(self, **settings): - class _Audio: - def detach(self): - return self - - def cpu(self): - return self - - def numpy(self): - return numpy.array([], dtype=numpy.float32) - - return _Audio() - - return _Model(), None - - torch.hub = _Hub() - sys.modules["torch"] = torch - - if "ChatTTS" not in sys.modules: - chattts = types.ModuleType("ChatTTS") - - class _Chat: - class InferCodeParams: - def __init__(self, spk_emb=None): - self.spk_emb = spk_emb - - def load(self, **kwargs): - pass - - def sample_random_speaker(self): - return "speaker" - - def infer(self, text, params_infer_code=None, **kwargs): - return [numpy.array([], dtype=numpy.float32)] - - chattts.Chat = _Chat - sys.modules["ChatTTS"] = chattts - - -_install_optional_dependency_stubs() - -import apparser.instructions.ocr.click_on_text as click_on_text_module -import apparser.instructions.ocr.move_to_text as move_to_text_module -import apparser.instructions.ocr.plot_text as plot_text_module -import apparser.instructions.ocr.text_getter as text_getter_module -from apparser.exceptions import TextNotFoundException -from apparser.geometry import RelativelyPoint -from apparser.instructions.ocr.click_on_text import ClickOnText -from apparser.instructions.ocr.move_to_text import MoveToText -from apparser.instructions.ocr.plot_text import PlotAllText, _Painter -from apparser.instructions.ocr.print_all_text import PrintAllText -from apparser.instructions.ocr.text_getter import GetText -from apparser.text_readers.models.text_data import TextData -from tests.utils.readers import FakeTextReader -from tests.utils.ui import InteractionUi - - -class FakeImage: - def __init__(self, array): - self.array = array - self.crop_calls = [] - self.show_calls = 0 - - def crop(self, box): - self.crop_calls.append(box) - return self - - def show(self): - self.show_calls += 1 - - @property - def __array_interface__(self): - return self.array.__array_interface__ - - def __array__(self, dtype=None, copy=None): - return self.array - - -def test_click_on_text_perform_order(monkeypatch): - calls = [] - - class FakeMoveToText: - def __init__(self, *args, **kwargs): - calls.append(("move_init", args, kwargs)) - - def perform(self, ui, ai): - calls.append(("move_perform", ui, ai)) - - class FakeSleep: - def __init__(self, sleep_time): - calls.append(("sleep_init", sleep_time)) - - def perform(self, ui, ai): - calls.append(("sleep_perform", ui, ai)) - - class FakeMouseClick: - def __init__(self, click_type): - calls.append(("click_init", click_type)) - - def perform(self, ui, ai): - calls.append(("click_perform", ui, ai)) - - monkeypatch.setattr(click_on_text_module, "MoveToText", FakeMoveToText) - monkeypatch.setattr(click_on_text_module, "Sleep", FakeSleep) - monkeypatch.setattr(click_on_text_module, "MouseClick", FakeMouseClick) - - ui = InteractionUi() - ai = FakeTextReader() - ClickOnText("text", sleep_time_before_move=0.3).perform(ui, ai) - - assert calls[0][0] == "move_init" - assert calls[1] == ("sleep_init", 0.3) - assert calls[2] == ("move_perform", ui, ai) - assert calls[3] == ("sleep_perform", ui, ai) - assert calls[4][0] == "click_init" - assert calls[5] == ("click_perform", ui, ai) - - -def test_move_to_text_find_text_and_property(monkeypatch): - monkeypatch.setattr( - move_to_text_module.fuzz, - "token_sort_ratio", - lambda needed, text: {"wrong": 10, "best": 95}[text], - ) - instruction = MoveToText("needle") - texts = [ - TextData("wrong", [Point(0, 0)]), - TextData("best", [Point(1, 1)]), - ] - - found, rating = instruction.find_text(texts) - - assert instruction.text == "needle" - assert found == texts[1] - assert rating == 95 - - -def test_move_to_text_perform_success(monkeypatch): - move_calls = [] - getter = SimpleNamespace( - global_answer=[ - TextData( - "target", - [Point(0, 0), Point(10, 0), Point(10, 20), Point(0, 20)], - ) - ], - perform=lambda ui, ai: None, - ) - ui = InteractionUi(relative_point=Point(2, 3)) - - class FakeMouseMove: - def __init__(self, point): - move_calls.append(("init", point)) - - def perform(self, ui): - move_calls.append(("perform", ui)) - - monkeypatch.setattr(move_to_text_module.fuzz, "token_sort_ratio", lambda needed, text: 100) - monkeypatch.setattr(move_to_text_module, "MouseMove", FakeMouseMove) - - instruction = MoveToText( - "target", - min_similarity=0.9, - offset=RelativelyPoint(0.5, 0.5), - text_getter=getter, - ) - instruction.perform(ui, FakeTextReader()) - - assert len(ui.global_calls) == 1 - assert isinstance(ui.global_calls[0], RelativelyPoint) - assert ui.global_calls[0].x == 0.5 - assert ui.global_calls[0].y == 0.5 - assert ui.local_calls == [Point(2, 3)] - assert move_calls == [("init", Point(7, 13)), ("perform", ui)] - - -def test_move_to_text_perform_raises_when_similarity_is_low(monkeypatch): - getter = SimpleNamespace( - global_answer=[ - TextData("target", [Point(0, 0), Point(1, 0), Point(1, 1), Point(0, 1)]) - ], - perform=lambda ui, ai: None, - ) - monkeypatch.setattr(move_to_text_module.fuzz, "token_sort_ratio", lambda needed, text: 0) - - with pytest.raises(TextNotFoundException): - MoveToText("target", min_similarity=1.0, text_getter=getter).perform( - InteractionUi(), - FakeTextReader(), - ) - - -def test_painter_draws_coordinates_and_lines(): - calls = [] - draw = SimpleNamespace( - rectangle=lambda shape, outline, width: calls.append( - ("rectangle", shape, outline, width) - ), - text=lambda position, text, fill: calls.append( - ("text", position, text, fill) - ), - ) - painter = _Painter(draw, (1, 2, 3, 4)) - data = [TextData("word", [Point(5, -20), Point(15, -20), Point(15, -5), Point(5, -5)])] - - painter.draw(data) - - assert calls == [ - ("text", (65, -15), "word", (1, 2, 3, 4)), - ("rectangle", [(5, -20), (15, -5)], (1, 2, 3, 4), 1), - ] - - -def test_plot_all_text_perform(monkeypatch): - draw_calls = [] - image = FakeImage(numpy.array([[1, 2], [3, 4]], dtype=numpy.uint8)) - getter = SimpleNamespace( - local_answer=[TextData("word", [Point(0, 0), Point(10, 0), Point(10, 10), Point(0, 10)])], - screenshot=image.array, - perform=lambda ui, ai: None, - ) - - class FakeDraw: - def rectangle(self, shape, outline, width): - draw_calls.append(("rectangle", shape, outline, width)) - - def text(self, position, text, fill): - draw_calls.append(("text", position, text, fill)) - - monkeypatch.setattr(plot_text_module.Image, "fromarray", lambda screenshot: image) - monkeypatch.setattr(plot_text_module.ImageDraw, "Draw", lambda screenshot: FakeDraw()) - - PlotAllText(text_getter=getter, color_rgba=(1, 2, 3, 4)).perform( - InteractionUi(), - FakeTextReader(), - ) - - assert draw_calls == [ - ("text", (60, 10), "word", (1, 2, 3, 4)), - ("rectangle", [(0, 0), (10, 10)], (1, 2, 3, 4), 1), - ] - assert image.show_calls == 1 - - -def test_print_all_text_perform(monkeypatch): - printed = [] - getter = SimpleNamespace( - global_answer=[TextData("word", [Point(1, 2), Point(3, 4)])], - perform=lambda ui, ai: None, - ) - monkeypatch.setattr("builtins.print", lambda line: printed.append(line)) - - PrintAllText(text_getter=getter).perform(InteractionUi(), FakeTextReader()) - - expected_line = f'text: "word", coordinates: {Point(1, 2)} {Point(3, 4)} ' - assert printed == [expected_line] - - -def test_get_text_perform_and_reload_behaviour(): - screenshot = FakeImage(numpy.array([[1, 2], [3, 4]], dtype=numpy.uint8)) - ai = FakeTextReader([TextData("word", [Point(1, 1), Point(2, 2)])]) - ui = InteractionUi(screenshot.array) - instruction = GetText(Point(5, 6), Point(10, 12), reload_every_try=False) - - original_fromarray = text_getter_module.Image.fromarray - text_getter_module.Image.fromarray = lambda image: screenshot - instruction.perform(ui, ai) - instruction.perform(ui, ai) - text_getter_module.Image.fromarray = original_fromarray - - assert screenshot.crop_calls == [(5, 6, 10, 12)] - assert len(ai.calls) == 1 - assert instruction.local_answer == [TextData("word", [Point(1, 1), Point(2, 2)])] - assert instruction.global_answer == [TextData("word", [Point(6, 7), Point(7, 8)])] - assert instruction.screenshot is screenshot diff --git a/tests/tests_instructions/test_speak.py b/tests/tests_instructions/test_speak.py deleted file mode 100644 index f5618b9..0000000 --- a/tests/tests_instructions/test_speak.py +++ /dev/null @@ -1,144 +0,0 @@ -import sys -import types - -import numpy -import pytest - - -def _install_optional_dependency_stubs(): - if "torch" not in sys.modules: - torch = types.ModuleType("torch") - torch.device = lambda value: value - - class _Hub: - @staticmethod - def load(**kwargs): - class _Model: - def to(self, device): - self.device = device - - def apply_tts(self, **settings): - class _Audio: - def detach(self): - return self - - def cpu(self): - return self - - def numpy(self): - return numpy.array([], dtype=numpy.float32) - - return _Audio() - - return _Model(), None - - torch.hub = _Hub() - sys.modules["torch"] = torch - - if "ChatTTS" not in sys.modules: - chattts = types.ModuleType("ChatTTS") - - class _Chat: - class InferCodeParams: - def __init__(self, spk_emb=None): - self.spk_emb = spk_emb - - def load(self, **kwargs): - pass - - def sample_random_speaker(self): - return "speaker" - - def infer(self, text, params_infer_code=None, **kwargs): - return [numpy.array([], dtype=numpy.float32)] - - chattts.Chat = _Chat - sys.modules["ChatTTS"] = chattts - - -_install_optional_dependency_stubs() - -import apparser.instructions.speak.play_text as play_text_module -import apparser.instructions.speak.say_text as say_text_module -from apparser.instructions.speak.play_text import PlayTextAudio -from apparser.instructions.speak.say_text import SayTextAudio -from apparser.speakers import BaseSpeaker -from tests.utils.ui import InteractionUi - - -class FakeSpeaker(BaseSpeaker): - def __init__(self, audio): - self.audio = numpy.asarray(audio, dtype=numpy.float32) - self.calls = [] - - def speak(self, text: str) -> numpy.ndarray: - self.calls.append(text) - return self.audio - - -@pytest.mark.parametrize("instruction_class", [PlayTextAudio, SayTextAudio]) -def test_speak_instruction_validation(instruction_class): - with pytest.raises(TypeError, match="text must be a string"): - instruction_class(1) - - with pytest.raises(ValueError, match="text cannot be empty"): - instruction_class("") - - -def test_play_text_audio_perform(monkeypatch): - calls = [] - - class FakePlayAudio: - def __init__(self, **kwargs): - calls.append(("init", kwargs)) - - def perform(self, *args, **kwargs): - calls.append(("perform", args, kwargs)) - - monkeypatch.setattr(play_text_module, "PlayAudio", FakePlayAudio) - - speaker = FakeSpeaker([0.1, 0.2]) - instruction = PlayTextAudio("hello", sample_rate=24000, device="speaker", blocking=False) - instruction.perform(InteractionUi(), speaker, "arg", key="value") - - kwargs = calls[0][1] - - assert speaker.calls == ["hello"] - assert calls[0][0] == "init" - assert numpy.allclose(kwargs["audio"], numpy.array([0.1, 0.2], dtype=numpy.float32)) - assert kwargs["sample_rate"] == 24000 - assert kwargs["device"] == "speaker" - assert kwargs["blocking"] is False - assert calls[1] == ("perform", ("arg",), {"key": "value"}) - - -def test_say_text_audio_perform(monkeypatch): - calls = [] - - class FakeSayAudio: - def __init__(self, **kwargs): - calls.append(("init", kwargs)) - - def perform(self, *args, **kwargs): - calls.append(("perform", args, kwargs)) - - monkeypatch.setattr(say_text_module, "SayAudio", FakeSayAudio) - - speaker = FakeSpeaker([0.1, 0.2]) - instruction = SayTextAudio( - "hello", - sample_rate=24000, - microphone_device="microphone", - blocking=False, - ) - instruction.perform(InteractionUi(), speaker, "arg", key="value") - - kwargs = calls[0][1] - - assert speaker.calls == ["hello"] - assert calls[0][0] == "init" - assert numpy.allclose(kwargs["audio"], numpy.array([0.1, 0.2], dtype=numpy.float32)) - assert kwargs["sample_rate"] == 24000 - assert kwargs["microphone_device"] == "microphone" - assert kwargs["blocking"] is False - assert calls[1] == ("perform", ("arg",), {"key": "value"}) diff --git a/tests/tests_instructions/test_ui.py b/tests/tests_instructions/test_ui.py deleted file mode 100644 index 29f3117..0000000 --- a/tests/tests_instructions/test_ui.py +++ /dev/null @@ -1,144 +0,0 @@ -import sys -import types - -import numpy -import pytest -from appwindows.geometry import Point, Size - - -def _install_optional_dependency_stubs(): - if "torch" not in sys.modules: - torch = types.ModuleType("torch") - torch.device = lambda value: value - - class _Hub: - @staticmethod - def load(**kwargs): - class _Model: - def to(self, device): - self.device = device - - def apply_tts(self, **settings): - class _Audio: - def detach(self): - return self - - def cpu(self): - return self - - def numpy(self): - return numpy.array([], dtype=numpy.float32) - - return _Audio() - - return _Model(), None - - torch.hub = _Hub() - sys.modules["torch"] = torch - - if "ChatTTS" not in sys.modules: - chattts = types.ModuleType("ChatTTS") - - class _Chat: - class InferCodeParams: - def __init__(self, spk_emb=None): - self.spk_emb = spk_emb - - def load(self, **kwargs): - pass - - def sample_random_speaker(self): - return "speaker" - - def infer(self, text, params_infer_code=None, **kwargs): - return [numpy.array([], dtype=numpy.float32)] - - chattts.Chat = _Chat - sys.modules["ChatTTS"] = chattts - - -_install_optional_dependency_stubs() - -import apparser.instructions.default.click as default_click_module -from apparser.geometry import RelativelyPoint -from apparser.instructions.ui.click import MouseClickTo -from apparser.instructions.ui.mouse_move import MouseMove -from apparser.instructions.ui.move_window import WindowMove -from apparser.instructions.ui.resize_window import WindowResize -from apparser.instructions.ui.to_window import WindowToBackground, WindowToForeground -from apparser.key_codes import LeftClick -from apparser.movers.base import BaseMover -from tests.utils.ui import InteractionUi - - -class DummyMover(BaseMover): - def __init__(self): - self.points = [] - - def move(self, position: Point): - self.points.append(position) - -def test_mouse_click_to_validation_and_perform(monkeypatch): - ui = InteractionUi() - mover = DummyMover() - clicks = [] - monkeypatch.setattr(default_click_module.mouse, "click", lambda: clicks.append("clicked")) - - instruction = MouseClickTo(Point(3, 4), LeftClick(), mover) - instruction.perform(ui) - - with pytest.raises(ValueError, match="coordinates must be Point or RelativelyPoint"): - MouseClickTo("coordinates") - - assert mover.points == [Point(3, 4)] - assert clicks == ["clicked"] - - -def test_mouse_move_validation_and_perform(): - mover = DummyMover() - ui = InteractionUi() - - with pytest.raises(TypeError, match="coordinates must be Point or RelativelyPoint"): - MouseMove("coordinates", mover) - - with pytest.raises(TypeError, match="mover must be Mover"): - MouseMove(Point(1, 2), "mover") - - instruction = MouseMove(RelativelyPoint(0.1, 0.2), mover) - instruction.perform(ui) - - assert ui.global_calls == [instruction._MouseMove__coordinates] - assert mover.points == [Point(9, 8)] - - -def test_move_window_validation_and_perform(): - ui = InteractionUi() - - with pytest.raises(TypeError, match="position must be of type Point"): - WindowMove("position") - - instruction = WindowMove(Point(5, 6)) - instruction.perform(ui) - - assert ui.window.calls == [("move", Point(5, 6))] - - -def test_resize_window_validation_and_perform(): - ui = InteractionUi() - - with pytest.raises(TypeError, match="size must be of type Size"): - WindowResize("size") - - instruction = WindowResize(Size(20, 30)) - instruction.perform(ui) - - assert ui.window.calls == [("resize", Size(20, 30))] - - -def test_to_window_instructions(): - ui = InteractionUi() - - WindowToForeground().perform(ui) - WindowToBackground().perform(ui) - - assert ui.window.calls == [("to_foreground",), ("to_background",)] diff --git a/tests/tests_instructions/test_utils.py b/tests/tests_instructions/test_utils.py deleted file mode 100644 index 16b1da4..0000000 --- a/tests/tests_instructions/test_utils.py +++ /dev/null @@ -1,102 +0,0 @@ -import sys -import types - -import numpy -import pytest - - -def _install_optional_dependency_stubs(): - if "torch" not in sys.modules: - torch = types.ModuleType("torch") - torch.device = lambda value: value - - class _Hub: - @staticmethod - def load(**kwargs): - class _Model: - def to(self, device): - self.device = device - - def apply_tts(self, **settings): - class _Audio: - def detach(self): - return self - - def cpu(self): - return self - - def numpy(self): - return numpy.array([], dtype=numpy.float32) - - return _Audio() - - return _Model(), None - - torch.hub = _Hub() - sys.modules["torch"] = torch - - if "ChatTTS" not in sys.modules: - chattts = types.ModuleType("ChatTTS") - - class _Chat: - class InferCodeParams: - def __init__(self, spk_emb=None): - self.spk_emb = spk_emb - - def load(self, **kwargs): - pass - - def sample_random_speaker(self): - return "speaker" - - def infer(self, text, params_infer_code=None, **kwargs): - return [numpy.array([], dtype=numpy.float32)] - - chattts.Chat = _Chat - sys.modules["ChatTTS"] = chattts - - -_install_optional_dependency_stubs() - -import apparser.instructions.utils.get_by_name as get_by_name_module -from apparser.instructions.ocr.click_on_text import ClickOnText -from apparser.instructions.default.press import PressKey -from apparser.instructions.utils.get_by_id import get_instruction_by_id -from apparser.instructions.utils.get_by_name import get_instruction_by_name - - -def test_get_instruction_by_name(monkeypatch): - class FakePressKey: - pass - - class FakeClickOnText: - pass - - monkeypatch.setattr( - get_by_name_module, - "_get_all_instructions", - lambda: [FakePressKey, FakeClickOnText], - ) - - assert get_instruction_by_name("FakePressKey") is FakePressKey - assert get_instruction_by_name("FakeClickOnText") is FakeClickOnText - - from apparser.exceptions import InstructionWithNameNotFoundException - with pytest.raises(InstructionWithNameNotFoundException): - get_instruction_by_name("None") - - -def test_get_instruction_by_id_validation(): - with pytest.raises(TypeError, match="id must be an integer"): - get_instruction_by_id("1") - - with pytest.raises(ValueError, match="id must be >= 0"): - get_instruction_by_id(-1) - - -def test_get_instruction_by_name_validation(): - with pytest.raises(TypeError, match="id must be an str"): - get_instruction_by_name(1) - - with pytest.raises(ValueError, match="name is empty"): - get_instruction_by_name("") diff --git a/tests/utils/__init__.py b/tests/utils/__init__.py index c30a3e2..99e52da 100644 --- a/tests/utils/__init__.py +++ b/tests/utils/__init__.py @@ -1 +1,61 @@ -"""Shared helpers for the test suite.""" +from tests.utils.audio_tools import create_temp_audio_path, create_wave_file +from tests.utils.external_stubs import ( + chattts_stub, + easyocr_stub, + install_external_stubs, + pyautogui_stub, + paddleocr_stub, + reset_external_stubs, + screeninfo_stub, + sounddevice_stub, + thefuzz_stub, + torch_stub, + ultralytics_stub, +) +from tests.utils.fakes import ( + FakeChangesChecker, + FakeCvHandlers, + FakeCvReader, + FakeDebugger, + FakeInstruction, + FakeIntAttributeInstruction, + FakeOcrInstruction, + FakeSpeaker, + FakeSpeakInstruction, + FakeStrAttributeInstruction, + FakeTextReader, + FakeUi, + FakeWindow, + make_instruction_type, +) + +__all__ = [ + "FakeChangesChecker", + "FakeCvHandlers", + "FakeCvReader", + "FakeDebugger", + "FakeInstruction", + "FakeIntAttributeInstruction", + "FakeOcrInstruction", + "FakeSpeaker", + "FakeSpeakInstruction", + "FakeStrAttributeInstruction", + "FakeTextReader", + "FakeUi", + "FakeWindow", + "make_instruction_type", + "chattts_stub", + "create_temp_audio_path", + "create_wave_file", + "easyocr_stub", + "install_external_stubs", + "keyboard_stub", + "pyautogui_stub", + "paddleocr_stub", + "reset_external_stubs", + "screeninfo_stub", + "sounddevice_stub", + "thefuzz_stub", + "torch_stub", + "ultralytics_stub", +] diff --git a/tests/utils/audio_tools.py b/tests/utils/audio_tools.py new file mode 100644 index 0000000..b74fd0b --- /dev/null +++ b/tests/utils/audio_tools.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +import uuid +import wave +from pathlib import Path + + +def create_wave_file( + path: Path, + frames: bytes, + sample_width: int, + channels: int = 1, + sample_rate: int = 16_000, +) -> Path: + path.parent.mkdir(parents=True, exist_ok=True) + with wave.open(str(path), "wb") as file: + file.setnchannels(channels) + file.setsampwidth(sample_width) + file.setframerate(sample_rate) + file.writeframes(frames) + return path + + +def create_temp_audio_path(name: str) -> Path: + base_dir = Path(__file__).resolve().parents[1] / "_tmp" + base_dir.mkdir(parents=True, exist_ok=True) + return base_dir / f"{uuid.uuid4().hex}_{name}" diff --git a/tests/utils/cv.py b/tests/utils/cv.py deleted file mode 100644 index cce6884..0000000 --- a/tests/utils/cv.py +++ /dev/null @@ -1,55 +0,0 @@ -"""Reusable CV doubles for tests.""" - -from types import SimpleNamespace - -import numpy - -from apparser.core.ui.base import BaseUi -from apparser.cv.models import CvBox - - -class CvUi(BaseUi): - """Minimal UI implementation for CV tests.""" - - def __init__(self, screenshot=None): - self._screenshot = ( - numpy.array([[1, 2], [3, 4]]) - if screenshot is None - else screenshot - ) - self._window = SimpleNamespace(name="window") - - def point_to_global(self, coordinates): - return coordinates - - def point_to_local(self, coordinates): - return coordinates - - def get_screenshot(self): - return self._screenshot - - @property - def window(self): - return self._window - - -def make_cv_box( - class_name: str = "cat", - class_id: int = 1, - x: int = 0, - y: int = 0, - width: int = 1, - height: int = 1, - ui: BaseUi | None = None, -) -> CvBox: - """Create a CV box with a ui UI stub.""" - - return CvBox( - class_name, - class_id, - x, - y, - width, - height, - CvUi() if ui is None else ui, - ) diff --git a/tests/utils/external_stubs.py b/tests/utils/external_stubs.py new file mode 100644 index 0000000..a12aa1a --- /dev/null +++ b/tests/utils/external_stubs.py @@ -0,0 +1,50 @@ +from __future__ import annotations + +import sys +from tests.utils.stubs.audio.sounddevice_stub import SoundDeviceStub +from tests.utils.stubs.display.screeninfo_stub import ScreenInfoStub +from tests.utils.stubs.input.pyautogui import PyAutoGuiFake +from tests.utils.stubs.ml.chattts_stub import ChatTTSStub +from tests.utils.stubs.ml.torch_stub import TorchStub +from tests.utils.stubs.text.easy_ocr_stub import EasyOcrStub +from tests.utils.stubs.text.paddle_ocr_stub import PaddleOcrStub +from tests.utils.stubs.text.thefuzz_stub import TheFuzzStub +from tests.utils.stubs.vision.ultralytics_stub import UltralyticsStub + + +pyautogui_stub = PyAutoGuiFake() +screeninfo_stub = ScreenInfoStub() +thefuzz_stub = TheFuzzStub() +ultralytics_stub = UltralyticsStub() +sounddevice_stub = SoundDeviceStub() +easyocr_stub = EasyOcrStub() +paddleocr_stub = PaddleOcrStub() +torch_stub = TorchStub() +chattts_stub = ChatTTSStub() + + +def install_external_stubs() -> None: + sys.modules["pyautogui"] = pyautogui_stub + sys.modules["screeninfo"] = screeninfo_stub + sys.modules["thefuzz"] = thefuzz_stub + sys.modules["ultralytics"] = ultralytics_stub + sys.modules["sounddevice"] = sounddevice_stub + sys.modules["easyocr"] = easyocr_stub + sys.modules["paddleocr"] = paddleocr_stub + sys.modules["torch"] = torch_stub + sys.modules["ChatTTS"] = chattts_stub + + +def reset_external_stubs() -> None: + for module in [ + pyautogui_stub, + screeninfo_stub, + thefuzz_stub, + ultralytics_stub, + sounddevice_stub, + easyocr_stub, + paddleocr_stub, + torch_stub, + chattts_stub, + ]: + module.reset() diff --git a/tests/utils/fakes/__init__.py b/tests/utils/fakes/__init__.py new file mode 100644 index 0000000..8236e18 --- /dev/null +++ b/tests/utils/fakes/__init__.py @@ -0,0 +1,37 @@ +from tests.utils.fakes.backends.fake_speaker import FakeSpeaker +from tests.utils.fakes.backends.fake_text_reader import FakeTextReader +from tests.utils.fakes.cv.fake_changes_checker import FakeChangesChecker +from tests.utils.fakes.cv.fake_cv_handlers import FakeCvHandlers +from tests.utils.fakes.cv.fake_cv_reader import FakeCvReader +from tests.utils.fakes.instructions.fake_debugger import FakeDebugger +from tests.utils.fakes.instructions.fake_instruction import FakeInstruction +from tests.utils.fakes.instructions.fake_int_attribute_instruction import ( + FakeIntAttributeInstruction, +) +from tests.utils.fakes.instructions.fake_ocr_instruction import FakeOcrInstruction +from tests.utils.fakes.instructions.fake_speak_instruction import FakeSpeakInstruction +from tests.utils.fakes.instructions.fake_str_attribute_instruction import ( + FakeStrAttributeInstruction, +) +from tests.utils.fakes.instructions.instruction_factory import make_instruction_type +from tests.utils.fakes.ui.fake_ui import FakeUi +from tests.utils.fakes.ui.fake_window import FakeWindow +from tests.utils.fakes.ui.fake_window_points import FakeWindowPoints + +__all__ = [ + "FakeChangesChecker", + "FakeCvHandlers", + "FakeCvReader", + "FakeDebugger", + "FakeInstruction", + "FakeIntAttributeInstruction", + "FakeOcrInstruction", + "FakeSpeaker", + "FakeSpeakInstruction", + "FakeStrAttributeInstruction", + "FakeTextReader", + "FakeUi", + "FakeWindow", + "FakeWindowPoints", + "make_instruction_type", +] diff --git a/tests/utils/fakes/backends/__init__.py b/tests/utils/fakes/backends/__init__.py new file mode 100644 index 0000000..6507eed --- /dev/null +++ b/tests/utils/fakes/backends/__init__.py @@ -0,0 +1,4 @@ +from tests.utils.fakes.backends.fake_speaker import FakeSpeaker +from tests.utils.fakes.backends.fake_text_reader import FakeTextReader + +__all__ = ["FakeSpeaker", "FakeTextReader"] diff --git a/tests/utils/fakes/backends/fake_speaker.py b/tests/utils/fakes/backends/fake_speaker.py new file mode 100644 index 0000000..72a56b0 --- /dev/null +++ b/tests/utils/fakes/backends/fake_speaker.py @@ -0,0 +1,22 @@ +from __future__ import annotations + +import numpy + +from tests.utils.external_stubs import install_external_stubs + + +install_external_stubs() + +from apparser.speakers.base import BaseSpeaker + + +class FakeSpeaker(BaseSpeaker): + def __init__(self, result: tuple[numpy.ndarray, int] | None = None) -> None: + self.result = ( + result if result is not None else (numpy.asarray([], dtype=numpy.float32), 0) + ) + self.calls: list[str] = [] + + def speak(self, text: str) -> tuple[numpy.ndarray, int]: + self.calls.append(text) + return self.result diff --git a/tests/utils/fakes/backends/fake_text_reader.py b/tests/utils/fakes/backends/fake_text_reader.py new file mode 100644 index 0000000..252f640 --- /dev/null +++ b/tests/utils/fakes/backends/fake_text_reader.py @@ -0,0 +1,22 @@ +from __future__ import annotations + +from typing import Any + +import numpy + +from tests.utils.external_stubs import install_external_stubs + + +install_external_stubs() + +from apparser.text_readers.base import BaseTextReader + + +class FakeTextReader(BaseTextReader): + def __init__(self, result: list[Any] | None = None) -> None: + self.result = result or [] + self.images: list[numpy.ndarray] = [] + + def read_image(self, image: numpy.ndarray) -> list[Any]: + self.images.append(image) + return self.result diff --git a/tests/utils/fakes/cv/__init__.py b/tests/utils/fakes/cv/__init__.py new file mode 100644 index 0000000..f88958a --- /dev/null +++ b/tests/utils/fakes/cv/__init__.py @@ -0,0 +1,5 @@ +from tests.utils.fakes.cv.fake_changes_checker import FakeChangesChecker +from tests.utils.fakes.cv.fake_cv_handlers import FakeCvHandlers +from tests.utils.fakes.cv.fake_cv_reader import FakeCvReader + +__all__ = ["FakeChangesChecker", "FakeCvHandlers", "FakeCvReader"] diff --git a/tests/utils/fakes/cv/fake_changes_checker.py b/tests/utils/fakes/cv/fake_changes_checker.py new file mode 100644 index 0000000..8eefaac --- /dev/null +++ b/tests/utils/fakes/cv/fake_changes_checker.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from typing import Any + + +class FakeChangesChecker: + def __init__(self, results: list[list[Any]] | None = None) -> None: + self.results = results or [] + self.calls: list[Any] = [] + + def check(self, data: Any) -> list[Any]: + self.calls.append(data) + return self.results.pop(0) diff --git a/tests/utils/fakes/cv/fake_cv_handlers.py b/tests/utils/fakes/cv/fake_cv_handlers.py new file mode 100644 index 0000000..c9d9ef6 --- /dev/null +++ b/tests/utils/fakes/cv/fake_cv_handlers.py @@ -0,0 +1,17 @@ +from __future__ import annotations + +from typing import Any + + +class FakeCvHandlers: + def __init__(self) -> None: + self.calls: list[dict[str, Any]] = [] + + def call(self, event: type[Any], changed_data: Any, *args: Any) -> None: + self.calls.append( + { + "event": event, + "changed_data": changed_data, + "args": args, + } + ) diff --git a/tests/utils/fakes/cv/fake_cv_reader.py b/tests/utils/fakes/cv/fake_cv_reader.py new file mode 100644 index 0000000..1358cd6 --- /dev/null +++ b/tests/utils/fakes/cv/fake_cv_reader.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +from typing import Any + +from tests.utils.external_stubs import install_external_stubs + + +install_external_stubs() + +from apparser.core.ui.base import BaseUi +from apparser.cv.readers.base import CvReader + + +class FakeCvReader(CvReader): + def __init__(self, results: list[Any] | None = None) -> None: + self.results = results or [] + self.calls: list[BaseUi] = [] + + def read(self, ui: BaseUi) -> Any: + self.calls.append(ui) + return self.results.pop(0) diff --git a/tests/utils/fakes/instructions/__init__.py b/tests/utils/fakes/instructions/__init__.py new file mode 100644 index 0000000..9dd89ef --- /dev/null +++ b/tests/utils/fakes/instructions/__init__.py @@ -0,0 +1,21 @@ +from tests.utils.fakes.instructions.fake_debugger import FakeDebugger +from tests.utils.fakes.instructions.fake_instruction import FakeInstruction +from tests.utils.fakes.instructions.fake_int_attribute_instruction import ( + FakeIntAttributeInstruction, +) +from tests.utils.fakes.instructions.fake_ocr_instruction import FakeOcrInstruction +from tests.utils.fakes.instructions.fake_speak_instruction import FakeSpeakInstruction +from tests.utils.fakes.instructions.fake_str_attribute_instruction import ( + FakeStrAttributeInstruction, +) +from tests.utils.fakes.instructions.instruction_factory import make_instruction_type + +__all__ = [ + "FakeDebugger", + "FakeInstruction", + "FakeIntAttributeInstruction", + "FakeOcrInstruction", + "FakeSpeakInstruction", + "FakeStrAttributeInstruction", + "make_instruction_type", +] diff --git a/tests/utils/fakes/instructions/fake_debugger.py b/tests/utils/fakes/instructions/fake_debugger.py new file mode 100644 index 0000000..e204bf4 --- /dev/null +++ b/tests/utils/fakes/instructions/fake_debugger.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +from typing import Any + +from tests.utils.external_stubs import install_external_stubs + + +install_external_stubs() + +from apparser.instructions.base import BaseInstruction +from apparser.instructions.debuggers.base import BaseDebugger + + +class FakeDebugger(BaseDebugger): + def __init__(self, call_inner: bool = True) -> None: + self.call_inner = call_inner + self.clear_calls = 0 + self.try_calls: list[dict[str, Any]] = [] + + def clear_context(self) -> None: + self.clear_calls += 1 + + def try_perform( + self, + instruction: BaseInstruction, + *args: Any, + **kwargs: Any, + ) -> None: + self.try_calls.append( + { + "instruction": instruction, + "args": args, + "kwargs": kwargs, + } + ) + if self.call_inner: + instruction.perform(*args, **kwargs) diff --git a/tests/utils/fakes/instructions/fake_instruction.py b/tests/utils/fakes/instructions/fake_instruction.py new file mode 100644 index 0000000..8e4f53a --- /dev/null +++ b/tests/utils/fakes/instructions/fake_instruction.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +from typing import Any + +from tests.utils.external_stubs import install_external_stubs + + +install_external_stubs() + +from apparser.instructions.base import BaseInstruction + + +class FakeInstruction(BaseInstruction): + def __init__( + self, + instruction_id: int = 1, + raised_exception: Exception | None = None, + ) -> None: + self.instruction_id = instruction_id + self.raised_exception = raised_exception + self.calls: list[dict[str, Any]] = [] + + @property + def id(self) -> int: + return self.instruction_id + + def perform(self, *args: Any, **kwargs: Any) -> None: + self.calls.append({"args": args, "kwargs": kwargs}) + if self.raised_exception is not None: + raise self.raised_exception diff --git a/tests/utils/fakes/instructions/fake_int_attribute_instruction.py b/tests/utils/fakes/instructions/fake_int_attribute_instruction.py new file mode 100644 index 0000000..77bc432 --- /dev/null +++ b/tests/utils/fakes/instructions/fake_int_attribute_instruction.py @@ -0,0 +1,12 @@ +from tests.utils.external_stubs import install_external_stubs +from tests.utils.fakes.instructions.fake_instruction import FakeInstruction + + +install_external_stubs() + +from apparser.core.ui.base import BaseUi + + +class FakeIntAttributeInstruction(FakeInstruction): + def perform(self, ui: BaseUi, number: int) -> None: + super().perform(ui, number=number) diff --git a/tests/utils/fakes/instructions/fake_ocr_instruction.py b/tests/utils/fakes/instructions/fake_ocr_instruction.py new file mode 100644 index 0000000..7193371 --- /dev/null +++ b/tests/utils/fakes/instructions/fake_ocr_instruction.py @@ -0,0 +1,21 @@ +from typing import Any + +from tests.utils.external_stubs import install_external_stubs +from tests.utils.fakes.instructions.fake_instruction import FakeInstruction + + +install_external_stubs() + +from apparser.core.ui.base import BaseUi +from apparser.text_readers.base import BaseTextReader + + +class FakeOcrInstruction(FakeInstruction): + def perform( + self, + ui: BaseUi, + text_reader: BaseTextReader, + *args: Any, + **kwargs: Any, + ) -> None: + super().perform(ui, text_reader, *args, **kwargs) diff --git a/tests/utils/fakes/instructions/fake_speak_instruction.py b/tests/utils/fakes/instructions/fake_speak_instruction.py new file mode 100644 index 0000000..4aa273b --- /dev/null +++ b/tests/utils/fakes/instructions/fake_speak_instruction.py @@ -0,0 +1,20 @@ +from typing import Any + +from tests.utils.external_stubs import install_external_stubs +from tests.utils.fakes.instructions.fake_instruction import FakeInstruction + + +install_external_stubs() + +from apparser.core.ui.base import BaseUi +from apparser.speakers.base import BaseSpeaker + + +class FakeSpeakInstruction(FakeInstruction): + def perform( + self, + speaker: BaseSpeaker, + *args: Any, + **kwargs: Any, + ) -> None: + super().perform(speaker, *args, **kwargs) diff --git a/tests/utils/fakes/instructions/fake_str_attribute_instruction.py b/tests/utils/fakes/instructions/fake_str_attribute_instruction.py new file mode 100644 index 0000000..5563cf4 --- /dev/null +++ b/tests/utils/fakes/instructions/fake_str_attribute_instruction.py @@ -0,0 +1,12 @@ +from tests.utils.external_stubs import install_external_stubs +from tests.utils.fakes.instructions.fake_instruction import FakeInstruction + + +install_external_stubs() + +from apparser.core.ui.base import BaseUi + + +class FakeStrAttributeInstruction(FakeInstruction): + def perform(self, ui: BaseUi, name: str) -> None: + super().perform(ui, name=name) diff --git a/tests/utils/fakes/instructions/instruction_factory.py b/tests/utils/fakes/instructions/instruction_factory.py new file mode 100644 index 0000000..be11d49 --- /dev/null +++ b/tests/utils/fakes/instructions/instruction_factory.py @@ -0,0 +1,23 @@ +from __future__ import annotations + +from typing import Any + +from tests.utils.external_stubs import install_external_stubs + + +install_external_stubs() + +from apparser.instructions.base import BaseInstruction + + +def make_instruction_type(name: str, instruction_id: int) -> type[BaseInstruction]: + class GeneratedInstruction(BaseInstruction): + @property + def id(self) -> int: + return instruction_id + + def perform(self, *args: Any, **kwargs: Any) -> None: + return None + + GeneratedInstruction.__name__ = name + return GeneratedInstruction diff --git a/tests/utils/fakes/ui/__init__.py b/tests/utils/fakes/ui/__init__.py new file mode 100644 index 0000000..0df22e2 --- /dev/null +++ b/tests/utils/fakes/ui/__init__.py @@ -0,0 +1,5 @@ +from tests.utils.fakes.ui.fake_ui import FakeUi +from tests.utils.fakes.ui.fake_window import FakeWindow +from tests.utils.fakes.ui.fake_window_points import FakeWindowPoints + +__all__ = ["FakeUi", "FakeWindow", "FakeWindowPoints"] diff --git a/tests/utils/fakes/ui/fake_ui.py b/tests/utils/fakes/ui/fake_ui.py new file mode 100644 index 0000000..3c5f0e8 --- /dev/null +++ b/tests/utils/fakes/ui/fake_ui.py @@ -0,0 +1,48 @@ +from __future__ import annotations + +import numpy +from appwindows.geometry import Point, Size + +from tests.utils.external_stubs import install_external_stubs +from tests.utils.fakes.ui.fake_window import FakeWindow + + +install_external_stubs() + +from apparser.core.ui.base import BaseUi +from apparser.geometry import RelativelyPoint + + +class FakeUi(BaseUi): + def __init__( + self, + offset: Point | None = None, + screenshot: numpy.ndarray | None = None, + window: FakeWindow | None = None, + relative_size: Size | None = None, + ) -> None: + self.offset = offset or Point(0, 0) + self._screenshot = ( + screenshot + if screenshot is not None + else numpy.zeros((20, 20, 3), dtype=numpy.uint8) + ) + self._window = window or FakeWindow() + self.relative_size = relative_size or Size(100, 100) + + def point_to_global(self, coordinates: Point | RelativelyPoint) -> Point: + if isinstance(coordinates, RelativelyPoint): + x = round(coordinates.x * self.relative_size.width) + y = round(coordinates.y * self.relative_size.height) + return Point(self.offset.x + x, self.offset.y + y) + return Point(self.offset.x + coordinates.x, self.offset.y + coordinates.y) + + def point_to_local(self, coordinates: Point) -> Point: + return Point(coordinates.x - self.offset.x, coordinates.y - self.offset.y) + + def get_screenshot(self) -> numpy.ndarray: + return self._screenshot + + @property + def window(self) -> FakeWindow: + return self._window diff --git a/tests/utils/fakes/ui/fake_window.py b/tests/utils/fakes/ui/fake_window.py new file mode 100644 index 0000000..8575d33 --- /dev/null +++ b/tests/utils/fakes/ui/fake_window.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +import numpy +from appwindows.geometry import Point, Size + +from tests.utils.fakes.ui.fake_window_points import FakeWindowPoints + + +class FakeWindow: + def __init__( + self, + left_top: Point | None = None, + size: Size | None = None, + screenshot: numpy.ndarray | None = None, + process_id: int | None = None, + ) -> None: + self.left_top = left_top or Point(0, 0) + self.size = size or Size(100, 100) + self.screenshot = ( + screenshot + if screenshot is not None + else numpy.zeros((10, 10, 3), dtype=numpy.uint8) + ) + self.move_calls: list[Point] = [] + self.resize_calls: list[Size] = [] + self.to_background_calls = 0 + self.to_foreground_calls = 0 + self.close_calls = 0 + self.process_id = process_id or 0 + + def get_points(self) -> FakeWindowPoints: + return FakeWindowPoints(left_top=self.left_top) + + def get_size(self) -> Size: + return self.size + + def get_screenshot(self) -> numpy.ndarray: + return self.screenshot + + def get_process_id(self) -> int: + return self.process_id + + def move(self, position: Point) -> None: + self.move_calls.append(position) + + def resize(self, size: Size) -> None: + self.resize_calls.append(size) + + def to_background(self) -> None: + self.to_background_calls += 1 + + def to_foreground(self) -> None: + self.to_foreground_calls += 1 + + def close(self) -> None: + self.close_calls += 1 diff --git a/tests/utils/fakes/ui/fake_window_points.py b/tests/utils/fakes/ui/fake_window_points.py new file mode 100644 index 0000000..1adf72f --- /dev/null +++ b/tests/utils/fakes/ui/fake_window_points.py @@ -0,0 +1,8 @@ +from dataclasses import dataclass + +from appwindows.geometry import Point + + +@dataclass +class FakeWindowPoints: + left_top: Point diff --git a/tests/utils/instructions.py b/tests/utils/instructions.py deleted file mode 100644 index a7f3cc1..0000000 --- a/tests/utils/instructions.py +++ /dev/null @@ -1,117 +0,0 @@ -import sys -import types - -import numpy - - -def _install_optional_dependency_stubs(): - if "torch" not in sys.modules: - torch = types.ModuleType("torch") - torch.device = lambda value: value - - class _Hub: - @staticmethod - def load(**kwargs): - class _Model: - def to(self, device): - self.device = device - - def apply_tts(self, **settings): - class _Audio: - def detach(self): - return self - - def cpu(self): - return self - - def numpy(self): - return numpy.array([], dtype=numpy.float32) - - return _Audio() - - return _Model(), None - - torch.hub = _Hub() - sys.modules["torch"] = torch - - if "ChatTTS" not in sys.modules: - chattts = types.ModuleType("ChatTTS") - - class _Chat: - class InferCodeParams: - def __init__(self, spk_emb=None): - self.spk_emb = spk_emb - - def load(self, **kwargs): - pass - - def sample_random_speaker(self): - return "speaker" - - def infer(self, text, params_infer_code=None, **kwargs): - return [numpy.array([], dtype=numpy.float32)] - - chattts.Chat = _Chat - sys.modules["ChatTTS"] = chattts - - -_install_optional_dependency_stubs() - -from apparser.instructions.ocr.base import OCRInstruction -from apparser.instructions.ui.base import UiInstruction - - -class DummyInstruction(UiInstruction): - def __init__( - self, - calls=None, - label: str = "instruction", - instruction_id: int = 0, - error: Exception | None = None, - ): - self.calls = [] if calls is None else calls - self.label = label - self.instruction_id = instruction_id - self.error = error - - @property - def id(self) -> int: - return self.instruction_id - - @property - def name(self) -> str: - return self.__class__.__name__ - - def perform(self, ui, *args, **kwargs): - if self.error is not None: - raise self.error - - self.calls.append((self.label, ui, args, kwargs)) - - -class DummyAiInstruction(OCRInstruction): - def __init__( - self, - calls=None, - label: str = "ai_instruction", - instruction_id: int = 1, - error: Exception | None = None, - ): - self.calls = [] if calls is None else calls - self.label = label - self.instruction_id = instruction_id - self.error = error - - @property - def id(self) -> int: - return self.instruction_id - - @property - def name(self) -> str: - return self.__class__.__name__ - - def perform(self, ui, ai, *args, **kwargs): - if self.error is not None: - raise self.error - - self.calls.append((self.label, ui, ai, args, kwargs)) diff --git a/tests/utils/readers.py b/tests/utils/readers.py deleted file mode 100644 index 45216f8..0000000 --- a/tests/utils/readers.py +++ /dev/null @@ -1,15 +0,0 @@ -"""Reusable text-reader doubles for tests.""" - -from apparser.text_readers.base import BaseTextReader - - -class FakeTextReader(BaseTextReader): - """Return predefined OCR results and record read calls.""" - - def __init__(self, result=None): - self.result = [] if result is None else result - self.calls = [] - - def read_image(self, image): - self.calls.append(image) - return self.result diff --git a/tests/utils/stubs/__init__.py b/tests/utils/stubs/__init__.py new file mode 100644 index 0000000..dd60d52 --- /dev/null +++ b/tests/utils/stubs/__init__.py @@ -0,0 +1,41 @@ +from tests.utils.stubs.audio.sounddevice_stub import SoundDeviceStub +from tests.utils.stubs.display.screeninfo_stub import ScreenInfoStub +from tests.utils.stubs.input.keyboard_stub import KeyboardStub +from tests.utils.stubs.input.pyautogui import PyAutoGuiFake +from tests.utils.stubs.ml.chat_stub import ChatStub +from tests.utils.stubs.ml.chattts_stub import ChatTTSStub +from tests.utils.stubs.ml.fake_torch_hub import FakeTorchHub +from tests.utils.stubs.ml.fake_torch_model import FakeTorchModel +from tests.utils.stubs.ml.fake_torch_tensor import FakeTorchTensor +from tests.utils.stubs.ml.infer_code_params import InferCodeParams +from tests.utils.stubs.ml.torch_stub import TorchStub +from tests.utils.stubs.text.easy_ocr_reader_stub import EasyOcrReaderStub +from tests.utils.stubs.text.easy_ocr_stub import EasyOcrStub +from tests.utils.stubs.text.fuzz_namespace import FuzzNamespace +from tests.utils.stubs.text.paddle_ocr_reader_stub import PaddleOcrReaderStub +from tests.utils.stubs.text.paddle_ocr_stub import PaddleOcrStub +from tests.utils.stubs.text.thefuzz_stub import TheFuzzStub +from tests.utils.stubs.vision.stub_yolo import StubYolo +from tests.utils.stubs.vision.ultralytics_stub import UltralyticsStub + +__all__ = [ + "ChatStub", + "ChatTTSStub", + "EasyOcrReaderStub", + "EasyOcrStub", + "FakeTorchHub", + "FakeTorchModel", + "FakeTorchTensor", + "FuzzNamespace", + "InferCodeParams", + "KeyboardStub", + "PyAutoGuiFake", + "PaddleOcrReaderStub", + "PaddleOcrStub", + "ScreenInfoStub", + "SoundDeviceStub", + "StubYolo", + "TheFuzzStub", + "TorchStub", + "UltralyticsStub", +] diff --git a/tests/utils/stubs/audio/__init__.py b/tests/utils/stubs/audio/__init__.py new file mode 100644 index 0000000..f3ac702 --- /dev/null +++ b/tests/utils/stubs/audio/__init__.py @@ -0,0 +1,3 @@ +from tests.utils.stubs.audio.sounddevice_stub import SoundDeviceStub + +__all__ = ["SoundDeviceStub"] diff --git a/tests/utils/stubs/audio/sounddevice_stub.py b/tests/utils/stubs/audio/sounddevice_stub.py new file mode 100644 index 0000000..76eb4b9 --- /dev/null +++ b/tests/utils/stubs/audio/sounddevice_stub.py @@ -0,0 +1,22 @@ +from __future__ import annotations + +from types import ModuleType +from typing import Any + +import numpy + + +class SoundDeviceStub(ModuleType): + def __init__(self) -> None: + super().__init__("sounddevice") + self.reset() + + def reset(self) -> None: + self.check_output_settings_calls: list[dict[str, Any]] = [] + self.play_calls: list[dict[str, Any]] = [] + + def check_output_settings(self, **kwargs: Any) -> None: + self.check_output_settings_calls.append(kwargs) + + def play(self, audio: numpy.ndarray, **kwargs: Any) -> None: + self.play_calls.append({"audio": audio, **kwargs}) diff --git a/tests/utils/stubs/display/__init__.py b/tests/utils/stubs/display/__init__.py new file mode 100644 index 0000000..dcd5a38 --- /dev/null +++ b/tests/utils/stubs/display/__init__.py @@ -0,0 +1,3 @@ +from tests.utils.stubs.display.screeninfo_stub import ScreenInfoStub + +__all__ = ["ScreenInfoStub"] diff --git a/tests/utils/stubs/display/screeninfo_stub.py b/tests/utils/stubs/display/screeninfo_stub.py new file mode 100644 index 0000000..df916ef --- /dev/null +++ b/tests/utils/stubs/display/screeninfo_stub.py @@ -0,0 +1,15 @@ +from __future__ import annotations + +from types import ModuleType, SimpleNamespace + + +class ScreenInfoStub(ModuleType): + def __init__(self) -> None: + super().__init__("screeninfo") + self.reset() + + def reset(self) -> None: + self.monitors = [SimpleNamespace(width=1920, height=1080)] + + def get_monitors(self) -> list[SimpleNamespace]: + return self.monitors diff --git a/tests/utils/stubs/input/__init__.py b/tests/utils/stubs/input/__init__.py new file mode 100644 index 0000000..a8a8e72 --- /dev/null +++ b/tests/utils/stubs/input/__init__.py @@ -0,0 +1,4 @@ +from tests.utils.stubs.input.keyboard_stub import KeyboardStub +from tests.utils.stubs.input.pyautogui import PyAutoGuiFake + +__all__ = ["KeyboardStub", "PyAutoGuiFake"] diff --git a/tests/utils/stubs/input/keyboard_stub.py b/tests/utils/stubs/input/keyboard_stub.py new file mode 100644 index 0000000..c75257e --- /dev/null +++ b/tests/utils/stubs/input/keyboard_stub.py @@ -0,0 +1,10 @@ +from __future__ import annotations + +from types import ModuleType + + +class KeyboardStub(ModuleType): + def __init__(self) -> None: + super().__init__("keyboard") + self.reset() + diff --git a/tests/utils/stubs/input/pyautogui.py b/tests/utils/stubs/input/pyautogui.py new file mode 100644 index 0000000..b6bb2ab --- /dev/null +++ b/tests/utils/stubs/input/pyautogui.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +from types import ModuleType +from typing import Any + + +class PyAutoGuiFake(ModuleType): + def __init__(self) -> None: + super().__init__("pyautogui") + self.reset() + + def reset(self) -> None: + self.move_calls: list[dict[str, Any]] = [] + self.click_calls = 0 + self.mouse_down_calls: list[str] = [] + self.mouse_up_calls: list[str] = [] + self._position = (0, 0) + self.send_calls: list[str] = [] + self.write_calls: list[tuple[str, float]] = [] + self.press_calls: list[str] = [] + self.release_calls: list[str] = [] + + def moveTo( + self, + x: int, + y: int, + duration: float = 0, + ) -> None: + self.move_calls.append( + { + "x": x, + "y": y, + "duration": duration, + } + ) + self._position = (x, y) + + def click(self, button: str) -> None: + self.click_calls += 1 + + def mouseDown(self, button: str) -> None: + self.mouse_down_calls.append(button) + + def mouseUp(self, button: str) -> None: + self.mouse_up_calls.append(button) + + def position(self) -> tuple[int, int]: + return self._position + + def write(self, text: str, interval: float) -> None: + self.write_calls.append((text, interval)) + + def press(self, key: str) -> None: + self.send_calls.append(key) + + def keyDown(self, key: str) -> None: + self.press_calls.append(key) + + def keyUp(self, key: str) -> None: + self.release_calls.append(key) + + def release(self, key: str) -> None: + self.release_calls.append(key) diff --git a/tests/utils/stubs/ml/__init__.py b/tests/utils/stubs/ml/__init__.py new file mode 100644 index 0000000..6d997e6 --- /dev/null +++ b/tests/utils/stubs/ml/__init__.py @@ -0,0 +1,17 @@ +from tests.utils.stubs.ml.chat_stub import ChatStub +from tests.utils.stubs.ml.chattts_stub import ChatTTSStub +from tests.utils.stubs.ml.fake_torch_hub import FakeTorchHub +from tests.utils.stubs.ml.fake_torch_model import FakeTorchModel +from tests.utils.stubs.ml.fake_torch_tensor import FakeTorchTensor +from tests.utils.stubs.ml.infer_code_params import InferCodeParams +from tests.utils.stubs.ml.torch_stub import TorchStub + +__all__ = [ + "ChatStub", + "ChatTTSStub", + "FakeTorchHub", + "FakeTorchModel", + "FakeTorchTensor", + "InferCodeParams", + "TorchStub", +] diff --git a/tests/utils/stubs/ml/chat_stub.py b/tests/utils/stubs/ml/chat_stub.py new file mode 100644 index 0000000..744a9ca --- /dev/null +++ b/tests/utils/stubs/ml/chat_stub.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +from typing import Any + +import numpy + +from tests.utils.stubs.ml.infer_code_params import InferCodeParams + + +class ChatStub: + InferCodeParams = InferCodeParams + instances: list["ChatStub"] = [] + default_infer_result: list[numpy.ndarray] = [] + default_random_speaker = "random-speaker" + + def __init__(self) -> None: + self.load_calls: list[dict[str, Any]] = [] + self.infer_calls: list[dict[str, Any]] = [] + self.sample_random_speaker_calls = 0 + self.__class__.instances.append(self) + + def load(self, **kwargs: Any) -> None: + self.load_calls.append(kwargs) + + def sample_random_speaker(self) -> str: + self.sample_random_speaker_calls += 1 + return self.default_random_speaker + + def infer( + self, + text: str, + params_infer_code: Any = None, + **settings: Any, + ) -> list[numpy.ndarray]: + self.infer_calls.append( + { + "text": text, + "params_infer_code": params_infer_code, + "settings": settings, + } + ) + return self.default_infer_result diff --git a/tests/utils/stubs/ml/chattts_stub.py b/tests/utils/stubs/ml/chattts_stub.py new file mode 100644 index 0000000..8f894a2 --- /dev/null +++ b/tests/utils/stubs/ml/chattts_stub.py @@ -0,0 +1,15 @@ +from types import ModuleType + +from tests.utils.stubs.ml.chat_stub import ChatStub + + +class ChatTTSStub(ModuleType): + def __init__(self) -> None: + super().__init__("ChatTTS") + self.reset() + + def reset(self) -> None: + ChatStub.instances = [] + ChatStub.default_infer_result = [] + ChatStub.default_random_speaker = "random-speaker" + self.Chat = ChatStub diff --git a/tests/utils/stubs/ml/fake_torch_hub.py b/tests/utils/stubs/ml/fake_torch_hub.py new file mode 100644 index 0000000..47873b6 --- /dev/null +++ b/tests/utils/stubs/ml/fake_torch_hub.py @@ -0,0 +1,17 @@ +from __future__ import annotations + +from typing import Any, TYPE_CHECKING + +from tests.utils.stubs.ml.fake_torch_model import FakeTorchModel + +if TYPE_CHECKING: + from tests.utils.stubs.ml.torch_stub import TorchStub + + +class FakeTorchHub: + def __init__(self, module: "TorchStub") -> None: + self.module = module + + def load(self, **kwargs: Any) -> tuple[FakeTorchModel, None]: + self.module.hub_load_calls.append(kwargs) + return self.module.hub_model, None diff --git a/tests/utils/stubs/ml/fake_torch_model.py b/tests/utils/stubs/ml/fake_torch_model.py new file mode 100644 index 0000000..a6f8a2d --- /dev/null +++ b/tests/utils/stubs/ml/fake_torch_model.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +from typing import Any + +from tests.utils.stubs.ml.fake_torch_tensor import FakeTorchTensor + + +class FakeTorchModel: + def __init__(self) -> None: + self.to_calls: list[Any] = [] + self.apply_tts_calls: list[dict[str, Any]] = [] + self.result = FakeTorchTensor([]) + + def to(self, device: Any) -> None: + self.to_calls.append(device) + + def apply_tts(self, **kwargs: Any) -> FakeTorchTensor: + self.apply_tts_calls.append(kwargs) + return self.result diff --git a/tests/utils/stubs/ml/fake_torch_tensor.py b/tests/utils/stubs/ml/fake_torch_tensor.py new file mode 100644 index 0000000..d253afc --- /dev/null +++ b/tests/utils/stubs/ml/fake_torch_tensor.py @@ -0,0 +1,17 @@ +from __future__ import annotations + +import numpy + + +class FakeTorchTensor: + def __init__(self, values: list[float] | numpy.ndarray) -> None: + self.values = numpy.asarray(values, dtype=numpy.float32) + + def detach(self) -> "FakeTorchTensor": + return self + + def cpu(self) -> "FakeTorchTensor": + return self + + def numpy(self) -> numpy.ndarray: + return self.values diff --git a/tests/utils/stubs/ml/infer_code_params.py b/tests/utils/stubs/ml/infer_code_params.py new file mode 100644 index 0000000..9be802a --- /dev/null +++ b/tests/utils/stubs/ml/infer_code_params.py @@ -0,0 +1,6 @@ +from typing import Any + + +class InferCodeParams: + def __init__(self, spk_emb: Any = None) -> None: + self.spk_emb = spk_emb diff --git a/tests/utils/stubs/ml/torch_stub.py b/tests/utils/stubs/ml/torch_stub.py new file mode 100644 index 0000000..8450429 --- /dev/null +++ b/tests/utils/stubs/ml/torch_stub.py @@ -0,0 +1,22 @@ +from __future__ import annotations + +from types import ModuleType + +from tests.utils.stubs.ml.fake_torch_hub import FakeTorchHub +from tests.utils.stubs.ml.fake_torch_model import FakeTorchModel + + +class TorchStub(ModuleType): + def __init__(self) -> None: + super().__init__("torch") + self.reset() + + def reset(self) -> None: + self.device_calls: list[str] = [] + self.hub_load_calls: list[dict[str, object]] = [] + self.hub_model = FakeTorchModel() + self.hub = FakeTorchHub(self) + + def device(self, value: str) -> str: + self.device_calls.append(value) + return f"device:{value}" diff --git a/tests/utils/stubs/text/__init__.py b/tests/utils/stubs/text/__init__.py new file mode 100644 index 0000000..a725aa0 --- /dev/null +++ b/tests/utils/stubs/text/__init__.py @@ -0,0 +1,15 @@ +from tests.utils.stubs.text.easy_ocr_reader_stub import EasyOcrReaderStub +from tests.utils.stubs.text.easy_ocr_stub import EasyOcrStub +from tests.utils.stubs.text.fuzz_namespace import FuzzNamespace +from tests.utils.stubs.text.paddle_ocr_reader_stub import PaddleOcrReaderStub +from tests.utils.stubs.text.paddle_ocr_stub import PaddleOcrStub +from tests.utils.stubs.text.thefuzz_stub import TheFuzzStub + +__all__ = [ + "EasyOcrReaderStub", + "EasyOcrStub", + "FuzzNamespace", + "PaddleOcrReaderStub", + "PaddleOcrStub", + "TheFuzzStub", +] diff --git a/tests/utils/stubs/text/easy_ocr_reader_stub.py b/tests/utils/stubs/text/easy_ocr_reader_stub.py new file mode 100644 index 0000000..defe96b --- /dev/null +++ b/tests/utils/stubs/text/easy_ocr_reader_stub.py @@ -0,0 +1,20 @@ +from __future__ import annotations + +from typing import Any + +import numpy + + +class EasyOcrReaderStub: + instances: list["EasyOcrReaderStub"] = [] + + def __init__(self, lang_list: list[str], **settings: Any) -> None: + self.lang_list = lang_list + self.settings = settings + self.predicted: list[Any] = [] + self.read_calls: list[dict[str, Any]] = [] + self.__class__.instances.append(self) + + def readtext(self, image: numpy.ndarray, **settings: Any) -> list[Any]: + self.read_calls.append({"image": image, "settings": settings}) + return self.predicted diff --git a/tests/utils/stubs/text/easy_ocr_stub.py b/tests/utils/stubs/text/easy_ocr_stub.py new file mode 100644 index 0000000..af4dbac --- /dev/null +++ b/tests/utils/stubs/text/easy_ocr_stub.py @@ -0,0 +1,13 @@ +from types import ModuleType + +from tests.utils.stubs.text.easy_ocr_reader_stub import EasyOcrReaderStub + + +class EasyOcrStub(ModuleType): + def __init__(self) -> None: + super().__init__("easyocr") + self.reset() + + def reset(self) -> None: + EasyOcrReaderStub.instances = [] + self.Reader = EasyOcrReaderStub diff --git a/tests/utils/stubs/text/fuzz_namespace.py b/tests/utils/stubs/text/fuzz_namespace.py new file mode 100644 index 0000000..72bbfc8 --- /dev/null +++ b/tests/utils/stubs/text/fuzz_namespace.py @@ -0,0 +1,4 @@ +class FuzzNamespace: + @staticmethod + def token_sort_ratio(first: str, second: str) -> int: + return 100 if first == second else 0 diff --git a/tests/utils/stubs/text/paddle_ocr_reader_stub.py b/tests/utils/stubs/text/paddle_ocr_reader_stub.py new file mode 100644 index 0000000..99917b6 --- /dev/null +++ b/tests/utils/stubs/text/paddle_ocr_reader_stub.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +from typing import Any + +import numpy + + +class PaddleOcrReaderStub: + instances: list["PaddleOcrReaderStub"] = [] + + def __init__(self, lang: str = "en", **settings: Any) -> None: + self.lang = lang + self.settings = settings + self.predict_result: list[Any] = [] + self.ocr_result: list[Any] = [] + self.predict_calls: list[dict[str, Any]] = [] + self.ocr_calls: list[dict[str, Any]] = [] + self.__class__.instances.append(self) + + def predict(self, image: numpy.ndarray, **settings: Any) -> list[Any]: + self.predict_calls.append({"image": image, "settings": settings}) + return self.predict_result + + def ocr(self, image: numpy.ndarray, **settings: Any) -> list[Any]: + self.ocr_calls.append({"image": image, "settings": settings}) + return self.ocr_result diff --git a/tests/utils/stubs/text/paddle_ocr_stub.py b/tests/utils/stubs/text/paddle_ocr_stub.py new file mode 100644 index 0000000..313644d --- /dev/null +++ b/tests/utils/stubs/text/paddle_ocr_stub.py @@ -0,0 +1,13 @@ +from types import ModuleType + +from tests.utils.stubs.text.paddle_ocr_reader_stub import PaddleOcrReaderStub + + +class PaddleOcrStub(ModuleType): + def __init__(self) -> None: + super().__init__("paddleocr") + self.reset() + + def reset(self) -> None: + PaddleOcrReaderStub.instances = [] + self.PaddleOCR = PaddleOcrReaderStub diff --git a/tests/utils/stubs/text/thefuzz_stub.py b/tests/utils/stubs/text/thefuzz_stub.py new file mode 100644 index 0000000..3bf2ff5 --- /dev/null +++ b/tests/utils/stubs/text/thefuzz_stub.py @@ -0,0 +1,12 @@ +from types import ModuleType + +from tests.utils.stubs.text.fuzz_namespace import FuzzNamespace + + +class TheFuzzStub(ModuleType): + def __init__(self) -> None: + super().__init__("thefuzz") + self.reset() + + def reset(self) -> None: + self.fuzz = FuzzNamespace() diff --git a/tests/utils/stubs/vision/__init__.py b/tests/utils/stubs/vision/__init__.py new file mode 100644 index 0000000..da5bd93 --- /dev/null +++ b/tests/utils/stubs/vision/__init__.py @@ -0,0 +1,4 @@ +from tests.utils.stubs.vision.stub_yolo import StubYolo +from tests.utils.stubs.vision.ultralytics_stub import UltralyticsStub + +__all__ = ["StubYolo", "UltralyticsStub"] diff --git a/tests/utils/stubs/vision/stub_yolo.py b/tests/utils/stubs/vision/stub_yolo.py new file mode 100644 index 0000000..7c3cb30 --- /dev/null +++ b/tests/utils/stubs/vision/stub_yolo.py @@ -0,0 +1,16 @@ +from __future__ import annotations + +from types import SimpleNamespace +from typing import Any + + +class StubYolo: + def __init__(self, model: object) -> None: + self.model_path = model + self.model = SimpleNamespace(names={}) + self.track_calls: list[dict[str, Any]] = [] + self.track_result = [SimpleNamespace(boxes=[])] + + def track(self, **kwargs: Any) -> list[Any]: + self.track_calls.append(kwargs) + return self.track_result diff --git a/tests/utils/stubs/vision/ultralytics_stub.py b/tests/utils/stubs/vision/ultralytics_stub.py new file mode 100644 index 0000000..a16df53 --- /dev/null +++ b/tests/utils/stubs/vision/ultralytics_stub.py @@ -0,0 +1,12 @@ +from types import ModuleType + +from tests.utils.stubs.vision.stub_yolo import StubYolo + + +class UltralyticsStub(ModuleType): + def __init__(self) -> None: + super().__init__("ultralytics") + self.reset() + + def reset(self) -> None: + self.YOLO = StubYolo diff --git a/tests/utils/ui.py b/tests/utils/ui.py deleted file mode 100644 index e1d0a42..0000000 --- a/tests/utils/ui.py +++ /dev/null @@ -1,53 +0,0 @@ -"""Reusable UI doubles for tests.""" - -from appwindows.geometry import Point - -from apparser.core.ui.base import BaseUi -from apparser.geometry import RelativelyPoint - - -class RecordingWindow: - """Track window interactions.""" - - def __init__(self): - self.calls = [] - - def to_foreground(self): - self.calls.append(("to_foreground",)) - - def to_background(self): - self.calls.append(("to_background",)) - - def move(self, position): - self.calls.append(("move", position)) - - def resize(self, size): - self.calls.append(("resize", size)) - - -class InteractionUi(BaseUi): - """Simple UI stub for instruction and algorithm tests.""" - - def __init__(self, screenshot=None, relative_point: Point | None = None): - self._window = RecordingWindow() - self._screenshot = screenshot - self._relative_point = Point(9, 8) if relative_point is None else relative_point - self.global_calls = [] - self.local_calls = [] - - def point_to_global(self, coordinates): - self.global_calls.append(coordinates) - if isinstance(coordinates, RelativelyPoint): - return self._relative_point - return coordinates - - def point_to_local(self, coordinates): - self.local_calls.append(coordinates) - return coordinates - - def get_screenshot(self): - return self._screenshot - - @property - def window(self): - return self._window