diff --git a/.codacy.yaml b/.codacy.yaml new file mode 100644 index 0000000..f00fa9c --- /dev/null +++ b/.codacy.yaml @@ -0,0 +1,3 @@ +--- +exclude_paths: + - "tests/test_*.py" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d682c66..89b1344 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,19 +25,24 @@ jobs: run: | python -m pip install --upgrade pip python -m pip install -r requirements.txt - python -m pip install pylint ruff pytest - name: Run pylint run: | - pylint pymacrorecorder main.py + pylint pymacrorecorder tests main.py - name: Run ruff run: | - ruff check pymacrorecorder main.py + ruff check pymacrorecorder tests main.py - # - name: Run pytest - # run: | - # pytest + - name: Run pytest with coverage + run: | + pytest --cov=pymacrorecorder --cov-report=xml --cov-report=term + + - name: Upload coverage to Codacy (Coverage Variation & Diff Coverage) + uses: codacy/codacy-coverage-reporter-action@v1 + with: + project-token: ${{ secrets.CODACY_PROJECT_TOKEN }} + coverage-reports: coverage.xml build-binaries: name: Build binaries (${{ matrix.os }}) @@ -64,7 +69,6 @@ jobs: run: | python -m pip install --upgrade pip python -m pip install -r requirements.txt - python -m pip install pyinstaller - name: Build binary (Linux via manylinux) if: runner.os != 'Windows' @@ -81,5 +85,5 @@ jobs: - name: Upload artifact uses: actions/upload-artifact@v4 with: - name: PyMacroRecorder-${{ runner.os }}${{ matrix.extension }} + name: PyMacroRecorder-${{ runner.os }} path: dist/PyMacroRecorder${{ matrix.extension }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8e6e160..d164fd0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -31,7 +31,6 @@ jobs: run: | python -m pip install --upgrade pip python -m pip install -r requirements.txt - python -m pip install pyinstaller - name: Build binary (Linux via manylinux) if: runner.os != 'Windows' @@ -48,24 +47,32 @@ jobs: - name: Upload artifact uses: actions/upload-artifact@v4 with: - name: PyMacroRecorder-${{ runner.os }}${{ matrix.extension }} + name: PyMacroRecorder-${{ runner.os }} path: dist/PyMacroRecorder${{ matrix.extension }} release: name: Publish Release runs-on: ubuntu-latest needs: build + timeout-minutes: 15 steps: - name: Download artifacts uses: actions/download-artifact@v4 with: path: ./artifacts - - name: Create GitHub Release + - name: Upload Linux binary uses: softprops/action-gh-release@v2 with: - files: | - artifacts/**/PyMacroRecorder - artifacts/**/PyMacroRecorder.exe + files: artifacts/PyMacroRecorder-Linux/PyMacroRecorder + overwrite_files: true + env: + GITHUB_TOKEN: ${{ secrets.GH_PAT }} + + - name: Upload Windows binary + uses: softprops/action-gh-release@v2 + with: + files: artifacts/PyMacroRecorder-Windows/PyMacroRecorder.exe + overwrite_files: true env: GITHUB_TOKEN: ${{ secrets.GH_PAT }} diff --git a/build-manylinux.sh b/build-manylinux.sh index f237371..5ed702d 100644 --- a/build-manylinux.sh +++ b/build-manylinux.sh @@ -53,7 +53,7 @@ source venv/bin/activate echo "=== Installing deps ===" pip install --upgrade pip setuptools wheel -pip install -r requirements.txt pyinstaller +pip install -r requirements.txt # Ensure PyInstaller finds libpython export LD_LIBRARY_PATH="$(python -c 'import sysconfig; print(sysconfig.get_config_var("LIBDIR"))'):$LD_LIBRARY_PATH" diff --git a/requirements.txt b/requirements.txt index 662140d..b4744a8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,3 +5,4 @@ pyinstaller-hooks-contrib>=2026.0 pylint>=2.15.0 ruff>=0.0.241 pytest>=7.2.0 +pytest-cov>=4.0.0 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..63c3bec --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,621 @@ +"""Fixtures and fakes for headless tests (simulated pynput). + +This module provides fake implementations of tkinter and pynput classes +to enable headless testing without requiring actual GUI or input devices. +All fake classes simulate the interface of their real counterparts while +maintaining testability and deterministic behavior. +""" +# pylint: disable=too-few-public-methods + +import sys +import types +from enum import Enum +from pathlib import Path + + +class _FakeWidget: + """Fake Tkinter widget for headless testing. + + Simulates basic Tkinter widget behavior including geometry management, + event binding, and configuration without requiring an actual GUI. + """ + + def __init__(self, *_args, **_kwargs) -> None: + """Initializes the fake widget. + + :param _args: Positional arguments (ignored) + :param _kwargs: Keyword arguments (ignored) + """ + self._state = None + + def pack(self, *_args, **_kwargs) -> None: + """Simulates the pack geometry manager. + + :param _args: Positional arguments (ignored) + :param _kwargs: Keyword arguments (ignored) + :return: None + :rtype: None + """ + return None + + def grid(self, *_args, **_kwargs) -> None: + """Simulates the grid geometry manager. + + :param _args: Positional arguments (ignored) + :param _kwargs: Keyword arguments (ignored) + :return: None + :rtype: None + """ + return None + + def bind(self, *_args, **_kwargs) -> None: + """Simulates event binding to the widget. + + :param _args: Positional arguments (ignored) + :param _kwargs: Keyword arguments (ignored) + :return: None + :rtype: None + """ + return None + + def configure(self, **kwargs) -> None: + """Simulates widget configuration. + + :param kwargs: Configuration options (state is tracked) + :type kwargs: dict + :return: None + :rtype: None + """ + self._state = kwargs.get("state", self._state) + + def insert(self, *_args, **_kwargs) -> None: + """Simulates inserting content into the widget. + + :param _args: Positional arguments (ignored) + :param _kwargs: Keyword arguments (ignored) + :return: None + :rtype: None + """ + return None + + def see(self, *_args, **_kwargs) -> None: + """Simulates scrolling the widget to make content visible. + + :param _args: Positional arguments (ignored) + :param _kwargs: Keyword arguments (ignored) + :return: None + :rtype: None + """ + return None + + def column(self, *_args, **_kwargs) -> None: + """Simulates column configuration for tree widgets. + + :param _args: Positional arguments (ignored) + :param _kwargs: Keyword arguments (ignored) + :return: None + :rtype: None + """ + return None + + def heading(self, *_args, **_kwargs) -> None: + """Simulates heading configuration for tree widgets. + + :param _args: Positional arguments (ignored) + :param _kwargs: Keyword arguments (ignored) + :return: None + :rtype: None + """ + return None + + def delete(self, *_args, **_kwargs) -> None: + """Simulates deletion of items from the widget. + + :param _args: Positional arguments (ignored) + :param _kwargs: Keyword arguments (ignored) + :return: None + :rtype: None + """ + return None + + def get_children(self) -> list: + """Returns fake children list. + + :return: Empty list of children + :rtype: list + """ + return [] + + def selection(self) -> list: + """Returns fake selection. + + :return: Empty list representing no selection + :rtype: list + """ + return [] + + def index(self, _item) -> int: + """Returns fake item index. + + :param _item: Item to get index for (ignored) + :return: Always returns 0 + :rtype: int + """ + return 0 + + +class _FakeTk(_FakeWidget): + """Fake Tkinter root window for headless testing. + + Extends _FakeWidget with root window specific methods like mainloop, + title, geometry, and icon management. + """ + + def after(self, *_args, **_kwargs) -> None: + """Simulates scheduling a callback after a delay. + + :param _args: Positional arguments (ignored) + :param _kwargs: Keyword arguments (ignored) + :return: None + :rtype: None + """ + return None + + def mainloop(self) -> None: + """Simulates the Tkinter main event loop. + + :return: None + :rtype: None + """ + return None + + def title(self, *_args) -> None: + """Simulates setting the window title. + + :param _args: Title string (ignored) + :return: None + :rtype: None + """ + return None + + def geometry(self, *_args) -> None: + """Simulates setting the window geometry. + + :param _args: Geometry string (ignored) + :return: None + :rtype: None + """ + return None + + def iconphoto(self, *_args, **_kwargs) -> None: + """Simulates setting the window icon from a photo image. + + :param _args: Positional arguments (ignored) + :param _kwargs: Keyword arguments (ignored) + :return: None + :rtype: None + """ + return None + + def iconbitmap(self, *_args, **_kwargs) -> None: + """Simulates setting the window icon from a bitmap file. + + :param _args: Positional arguments (ignored) + :param _kwargs: Keyword arguments (ignored) + :return: None + :rtype: None + """ + return None + + +class _FakeStringVar: + """Fake Tkinter StringVar for headless testing. + + Simulates a Tkinter StringVar variable that holds a string value. + """ + + def __init__(self, value: str = "") -> None: + """Initializes the fake StringVar. + + :param value: Initial string value + :type value: str + """ + self._value = value + + def get(self) -> str: + """Returns the stored string value. + + :return: Current string value + :rtype: str + """ + return self._value + + def set(self, value: str) -> None: + """Sets the string value. + + :param value: New string value to set + :type value: str + :return: None + :rtype: None + """ + self._value = value + + +class _FakePhotoImage: + """Fake Tkinter PhotoImage for headless testing. + + Simulates a Tkinter PhotoImage without actually loading images. + """ + + def __init__(self, **_kwargs) -> None: + """Initializes the fake photo image. + + :param _kwargs: Keyword arguments (ignored) + """ + + +class _FakeEvent: + """Fake Tkinter Event for headless testing. + + Simulates a Tkinter event object without actual event data. + """ + + +def _setup_fake_tkinter_modules(): + """Configures fake tkinter modules for headless tests. + + Sets up fake implementations of tkinter, tkinter.ttk, tkinter.filedialog, + and tkinter.messagebox in sys.modules to enable testing without a display. + + :return: None + :rtype: None + """ + _fake_ttk = types.SimpleNamespace( + Frame=_FakeWidget, + LabelFrame=_FakeWidget, + Button=_FakeWidget, + Entry=_FakeWidget, + Treeview=_FakeWidget, + Label=_FakeWidget, + ) + _fake_filedialog = types.SimpleNamespace( + asksaveasfilename=lambda **_kwargs: "", + askopenfilename=lambda **_kwargs: "", + ) + _fake_messagebox = types.SimpleNamespace( + showwarning=lambda *_args, **_kwargs: None, + showerror=lambda *_args, **_kwargs: None, + ) + _fake_tk = types.SimpleNamespace( + Tk=_FakeTk, + Label=_FakeWidget, + Text=_FakeWidget, + Event=_FakeEvent, + PhotoImage=_FakePhotoImage, + StringVar=_FakeStringVar, + ttk=_fake_ttk, + filedialog=_fake_filedialog, + messagebox=_fake_messagebox, + ) + sys.modules.setdefault("tkinter", _fake_tk) + sys.modules.setdefault("tkinter.ttk", _fake_ttk) + sys.modules.setdefault("tkinter.filedialog", _fake_filedialog) + sys.modules.setdefault("tkinter.messagebox", _fake_messagebox) + + +def _check_tkinter_available(): + """Checks if tkinter is available and configures fakes if necessary. + + Attempts to import tkinter, and if it fails (e.g., headless environment), + sets up fake tkinter modules instead. + + :return: None + :rtype: None + """ + try: + import tkinter as _tkinter # pylint: disable=import-outside-toplevel + del _tkinter + except (ImportError, ModuleNotFoundError): + _setup_fake_tkinter_modules() + + +_check_tkinter_available() + + +class _FakeKey: + """Fake pynput Key for headless testing. + + Represents a special keyboard key (e.g., ctrl, alt, shift). + """ + + def __init__(self, name: str) -> None: + """Initializes the fake key. + + :param name: Name of the special key + :type name: str + """ + self.name = name + + +class _FakeKeyEnum: + """Fake pynput Key enumeration for headless testing. + + Provides access to special keys like ctrl, alt, shift, enter. + """ + + _map = { + "ctrl": _FakeKey("ctrl"), + "alt": _FakeKey("alt"), + "shift": _FakeKey("shift"), + "enter": _FakeKey("enter"), + } + + ctrl = _map["ctrl"] + alt = _map["alt"] + shift = _map["shift"] + enter = _map["enter"] + + def __class_getitem__(cls, name: str): + """Gets a special key by name. + + :param name: Name of the special key + :type name: str + :return: The corresponding fake key + :rtype: _FakeKey + """ + return cls._map[name] + + +class _FakeKeyCode: + """Fake pynput KeyCode for headless testing. + + Represents a keyboard key with either a character or virtual key code. + """ + + def __init__(self, char: str | None = None, vk: int | None = None) -> None: + """Initializes the fake key code. + + :param char: Character representation of the key + :type char: str | None + :param vk: Virtual key code + :type vk: int | None + """ + self.char = char + self.vk = vk + + @classmethod + def from_char(cls, char: str): + """Creates a fake key code from a character. + + :param char: Character to create key from + :type char: str + :return: Fake key code instance + :rtype: _FakeKeyCode + """ + return cls(char=char, vk=None) + + @classmethod + def from_vk(cls, vk: int): + """Creates a fake key code from a virtual key code. + + :param vk: Virtual key code + :type vk: int + :return: Fake key code instance + :rtype: _FakeKeyCode + """ + return cls(char=None, vk=vk) + + +class _FakeHotKey: + """Fake pynput HotKey parser for headless testing. + + Validates hotkey combination strings. + """ + + @staticmethod + def parse(combo_str: str) -> None: + """Parses and validates a hotkey combination string. + + :param combo_str: Hotkey combination string (e.g., "+c") + :type combo_str: str + :return: None + :rtype: None + :raises ValueError: If the hotkey format is invalid + """ + if not combo_str or "+" not in combo_str: + raise ValueError("Invalid hotkey") + for part in combo_str.split("+"): + if part.startswith("<") and part.endswith(">"): + continue + if len(part) == 1: + continue + raise ValueError("Invalid hotkey") + + +class _BaseListener: + """Fake base listener for headless testing. + + Simulates pynput listener behavior without actual input monitoring. + """ + + def __init__(self, **_kwargs) -> None: + """Initializes the fake listener. + + :param _kwargs: Keyword arguments (ignored) + """ + self.started = False + + def start(self) -> None: + """Simulates listener start. + + :return: None + :rtype: None + """ + self.started = True + + def stop(self) -> None: + """Simulates listener stop. + + :return: None + :rtype: None + """ + self.started = False + + def join(self) -> None: + """Simulates joining the listener thread. + + :return: None + :rtype: None + """ + return None + + +class _FakeGlobalHotKeys: + """Fake pynput GlobalHotKeys listener for headless testing. + + Simulates global hotkey monitoring without actual keyboard hooks. + """ + + def __init__(self, mapping: dict) -> None: + """Initializes the fake global hotkeys listener. + + :param mapping: Dictionary mapping hotkey strings to callbacks + :type mapping: dict + """ + self.mapping = mapping + self.started = False + + def start(self) -> None: + """Simulates global hotkeys listener start. + + :return: None + :rtype: None + """ + self.started = True + + def stop(self) -> None: + """Simulates global hotkeys listener stop. + + :return: None + :rtype: None + """ + self.started = False + + +class _FakeKeyboardController: + """Fake pynput keyboard controller for headless testing. + + Tracks simulated key presses and releases without actual keyboard events. + """ + + def __init__(self) -> None: + """Initializes the fake keyboard controller. + + Records of pressed and released keys are stored in lists. + """ + self.pressed = [] + self.released = [] + + def press(self, key) -> None: + """Simulates a key press. + + :param key: Key to press + :return: None + :rtype: None + """ + self.pressed.append(key) + + def release(self, key) -> None: + """Simulates a key release. + + :param key: Key to release + :return: None + :rtype: None + """ + self.released.append(key) + + +class _FakeMouseButton(Enum): + """Fake pynput mouse button enumeration for headless testing. + + Provides mouse button constants (left, right). + """ + + left = 1 # pylint: disable=invalid-name + right = 2 # pylint: disable=invalid-name + + +class _FakeMouseController: + """Fake pynput mouse controller for headless testing. + + Tracks simulated mouse actions without actual mouse events. + """ + + def __init__(self) -> None: + """Initializes the fake mouse controller. + + Tracks position, pressed buttons, released buttons, and scroll events. + """ + self.position = (0, 0) + self.pressed = [] + self.released = [] + self.scrolled = [] + + def press(self, button) -> None: + """Simulates a mouse button press. + + :param button: Mouse button to press + :return: None + :rtype: None + """ + self.pressed.append(button) + + def release(self, button) -> None: + """Simulates a mouse button release. + + :param button: Mouse button to release + :return: None + :rtype: None + """ + self.released.append(button) + + def scroll(self, dx: int, dy: int) -> None: + """Simulates a mouse scroll. + + :param dx: Horizontal scroll delta + :type dx: int + :param dy: Vertical scroll delta + :type dy: int + :return: None + :rtype: None + """ + self.scrolled.append((dx, dy)) + + +_fake_keyboard = types.SimpleNamespace( + Key=_FakeKeyEnum, + KeyCode=_FakeKeyCode, + HotKey=_FakeHotKey, + Listener=_BaseListener, + GlobalHotKeys=_FakeGlobalHotKeys, + Controller=_FakeKeyboardController, +) + +_fake_mouse = types.SimpleNamespace( + Button=_FakeMouseButton, + Listener=_BaseListener, + Controller=_FakeMouseController, +) + +_fake_pynput = types.SimpleNamespace( + keyboard=_fake_keyboard, + mouse=_fake_mouse, +) + +sys.modules.setdefault("pynput", _fake_pynput) +sys.modules.setdefault("pynput.keyboard", _fake_keyboard) +sys.modules.setdefault("pynput.mouse", _fake_mouse) + +_project_root = Path(__file__).resolve().parents[1] +if str(_project_root) not in sys.path: + sys.path.insert(0, str(_project_root)) diff --git a/tests/test_app.py b/tests/test_app.py new file mode 100644 index 0000000..47e4e25 --- /dev/null +++ b/tests/test_app.py @@ -0,0 +1,632 @@ +"""Headless tests for Tkinter app utilities. + +This module contains comprehensive tests for the App class and its methods, +using fake widgets and components to enable testing without a GUI. +All tests verify app behavior including logging, hotkey handling, macro +management, recording, playback, and file operations. +""" +# pylint: disable=too-few-public-methods +# pylint: disable=protected-access + +from pathlib import Path +from types import SimpleNamespace + +import pymacrorecorder.app as app_module +from pymacrorecorder.app import App +from pymacrorecorder.models import Macro, MacroEvent + + +class FakeText: + """Fake text widget for testing. + + Simulates a Tkinter Text widget with state tracking and content storage. + """ + + def __init__(self) -> None: + """Initializes the fake text widget. + + Sets up internal state tracking and line storage. + """ + self.state = None + self.lines = [] + + def configure(self, **kwargs) -> None: + """Configures the text widget. + + :param kwargs: Configuration options, may include 'state' + :type kwargs: dict + :return: None + :rtype: None + """ + self.state = kwargs.get("state", self.state) + + def insert(self, _where: str, text: str) -> None: + """Inserts text into the widget. + + :param _where: Position to insert at (ignored) + :type _where: str + :param text: Text content to insert + :type text: str + :return: None + :rtype: None + """ + self.lines.append(text) + + def see(self, _where: str) -> None: + """Scrolls to view position. + + :param _where: Position to scroll to (ignored) + :type _where: str + :return: None + :rtype: None + """ + return None + + +class FakeLabel: + """Fake label widget for testing. + + Simulates a Tkinter Label widget with text tracking. + """ + + def __init__(self) -> None: + """Initializes the fake label widget. + + Sets up internal text storage. + """ + self.text = "" + + def configure(self, **kwargs) -> None: + """Configures the label widget. + + :param kwargs: Configuration options, may include 'text' + :type kwargs: dict + :return: None + :rtype: None + """ + self.text = kwargs.get("text", self.text) + + +class FakeButton: + """Fake button widget for testing. + + Simulates a Tkinter Button widget with state tracking. + """ + + def __init__(self) -> None: + """Initializes the fake button widget. + + Sets up internal state storage. + """ + self.state = None + + def configure(self, **kwargs) -> None: + """Configures the button widget. + + :param kwargs: Configuration options, may include 'state' + :type kwargs: dict + :return: None + :rtype: None + """ + self.state = kwargs.get("state", self.state) + + +class FakeTreeview: + """Fake treeview widget for testing. + + Simulates a Tkinter Treeview widget with item and selection management. + """ + + def __init__(self) -> None: + """Initializes the fake treeview widget. + + Sets up internal item and selection storage. + """ + self._items = [] + self._selection = [] + + def get_children(self): + """Returns list of child item IDs. + + :return: List of item IDs + :rtype: list + """ + return [item["id"] for item in self._items] + + def delete(self, item_id: str) -> None: + """Deletes an item from the treeview. + + :param item_id: ID of item to delete + :type item_id: str + :return: None + :rtype: None + """ + self._items = [item for item in self._items if item["id"] != item_id] + + def insert(self, _parent: str, _index: str, values): + """Inserts a new item into the treeview. + + :param _parent: Parent item ID (ignored) + :type _parent: str + :param _index: Position index (ignored) + :type _index: str + :param values: Values for the new item + :return: ID of the inserted item + :rtype: str + """ + item_id = f"item{len(self._items)}" + self._items.append({"id": item_id, "values": values}) + return item_id + + def selection(self): + """Returns the current selection. + + :return: List of selected item IDs + :rtype: list + """ + return list(self._selection) + + def set_selection(self, ids) -> None: + """Sets the current selection. + + :param ids: List of item IDs to select + :return: None + :rtype: None + """ + self._selection = list(ids) + + def index(self, item_id: str) -> int: + """Returns the index of the specified item. + + :param item_id: ID of item to find + :type item_id: str + :return: Index of the item + :rtype: int + """ + return [item["id"] for item in self._items].index(item_id) + + +class FakeRepeatVar: + """Fake StringVar for repeat count testing. + + Simulates a StringVar that holds the repeat count value. + """ + + def __init__(self, value: str) -> None: + """Initializes the fake repeat variable. + + :param value: Initial repeat count value + :type value: str + """ + self._value = value + + def get(self) -> str: + """Returns the repeat value. + + :return: Current repeat count as string + :rtype: str + """ + return self._value + + +class FakePlayer: + """Fake player for testing macro playback. + + Tracks play and stop calls without actual playback. + """ + + def __init__(self) -> None: + """Initializes the fake player. + + Sets up tracking for play and stop calls. + """ + self.play_calls = [] + self.stop_calls = 0 + + def play(self, macro: Macro, repeats: int) -> None: + """Records a play call. + + :param macro: Macro to play + :type macro: Macro + :param repeats: Number of times to repeat + :type repeats: int + :return: None + :rtype: None + """ + self.play_calls.append((macro, repeats)) + + def stop(self) -> None: + """Records a stop call. + + :return: None + :rtype: None + """ + self.stop_calls += 1 + + +class FakeRecorder: + """Fake recorder for testing macro recording. + + Simulates recording behavior with predefined events. + """ + + def __init__(self, events=None) -> None: + """Initializes the fake recorder. + + :param events: Predefined events to return on stop + :type events: list or None + """ + self.started = False + self.ignored = None + self._events = events or [] + + def start(self, ignored_hotkeys) -> None: + """Simulates starting recording. + + :param ignored_hotkeys: Hotkeys to ignore during recording + :return: None + :rtype: None + """ + self.started = True + self.ignored = ignored_hotkeys + + def stop(self): + """Simulates stopping recording and returns events. + + :return: List of recorded events + :rtype: list + """ + return list(self._events) + + +def _make_app() -> App: + """Creates an App instance without initialization. + + Creates a bare App instance using __new__ to bypass __init__, + allowing tests to set up only the necessary attributes. + + :return: Uninitialized App instance + :rtype: App + """ + app = App.__new__(App) + return app + + +def test_app_log_writes_text() -> None: + """Tests that app log writes text to the log widget. + + Verifies that the _log method correctly inserts text into the + log_text widget with proper newline formatting. + + :return: None + :rtype: None + """ + app = _make_app() + app.log_text = FakeText() + + App._log(app, "hello") + + assert "hello\n" in app.log_text.lines + + +def test_refresh_hotkey_labels_sets_text() -> None: + """Tests that hotkey labels are refreshed with correct text. + + Verifies that _refresh_hotkey_labels updates label text to show + formatted hotkey combinations or "(none)" for empty hotkeys. + + :return: None + :rtype: None + """ + app = _make_app() + app.hotkeys = {"start_record": ["", "r"], "stop_record": []} + app.hotkey_labels = {"start_record": FakeLabel(), "stop_record": FakeLabel()} + + App._refresh_hotkey_labels(app) + + assert app.hotkey_labels["start_record"].text == "+r" + assert app.hotkey_labels["stop_record"].text == "(none)" + + +def test_handle_hotkey_calls_actions() -> None: + """Tests that hotkey handler calls the correct actions. + + Verifies that _handle_hotkey dispatches to the appropriate app + methods based on the action name. + + :return: None + :rtype: None + """ + app = _make_app() + called = [] + app.start_recording = lambda: called.append("start_record") + app.stop_recording = lambda: called.append("stop_record") + app.start_playback = lambda: called.append("start_macro") + app.stop_playback = lambda: called.append("stop_macro") + app.save_macro = lambda: called.append("save_macro") + app.load_macro = lambda: called.append("load_macro") + + for action in [ + "start_record", + "stop_record", + "start_macro", + "stop_macro", + "save_macro", + "load_macro", + ]: + App._handle_hotkey(app, action) + + assert called == [ + "start_record", + "stop_record", + "start_macro", + "stop_macro", + "save_macro", + "load_macro", + ] + + +def test_populate_preview_inserts_rows() -> None: + """Tests that populate preview inserts rows for macro events. + + Verifies that _populate_preview correctly populates the treeview + with rows representing each event in the macro. + + :return: None + :rtype: None + """ + app = _make_app() + app.preview = FakeTreeview() + macro = Macro( + name="demo", + events=[ + MacroEvent("key_down", {"key": "a"}, 0), + MacroEvent("mouse_move", {"x": 1, "y": 2}, 10), + ], + ) + + App._populate_preview(app, macro) + + assert len(app.preview.get_children()) == 2 + assert app.preview._items[0]["values"][1] == "key_down" + + +def test_populate_preview_clears_on_none() -> None: + """Tests that populate preview clears when given None. + + Verifies that _populate_preview removes all items when called with + None, effectively clearing the preview. + + :return: None + :rtype: None + """ + app = _make_app() + app.preview = FakeTreeview() + app.preview.insert("", "end", values=(1, "x", "y", 0)) + + App._populate_preview(app, None) + + assert app.preview.get_children() == [] + + +def test_can_delete_events_logs_when_empty() -> None: + """Tests that can_delete_events logs when no macro is loaded. + + Verifies that _can_delete_events returns False and logs an + appropriate message when current_macro is None. + + :return: None + :rtype: None + """ + app = _make_app() + logs = [] + app._log = logs.append + app.current_macro = None + + assert App._can_delete_events(app) is False + assert logs == ["No macro to edit"] + + +def test_perform_deletion_updates_events() -> None: + """Tests that perform_deletion correctly removes events. + + Verifies that _perform_deletion removes the specified events from + the current macro and updates the preview accordingly. + + :return: None + :rtype: None + """ + app = _make_app() + app.preview = FakeTreeview() + app.current_macro = Macro( + name="demo", + events=[ + MacroEvent("key_down", {"key": "a"}, 0), + MacroEvent("key_up", {"key": "a"}, 0), + MacroEvent("mouse_move", {"x": 1, "y": 2}, 5), + ], + ) + item_ids = [ + app.preview.insert("", "end", values=(1, "key_down", "key=a", 0)), + app.preview.insert("", "end", values=(2, "key_up", "key=a", 0)), + app.preview.insert("", "end", values=(3, "mouse_move", "x=1", 5)), + ] + deleted = [] + populated = [] + + def record_deleted(count: int) -> None: + """Records deletion count. + + :param count: Number of items deleted + :type count: int + :return: None + :rtype: None + """ + deleted.append(count) + + def record_populated(macro: Macro | None) -> None: + """Records populated macro. + + :param macro: Macro that was populated + :type macro: Macro | None + :return: None + :rtype: None + """ + populated.append(macro) + + app._log_deletion_result = record_deleted + app._populate_preview = record_populated + + App._perform_deletion(app, [item_ids[0], item_ids[2]]) + + assert len(app.current_macro.events) == 1 + assert app.current_macro.events[0].event_type == "key_up" + assert deleted == [2] + assert populated[-1] is app.current_macro + + +def test_start_recording_and_stop_recording() -> None: + """Tests start and stop recording functionality. + + Verifies that start_recording and stop_recording properly manage + button states, recorder lifecycle, and macro creation from events. + + :return: None + :rtype: None + """ + app = _make_app() + app.start_rec_btn = FakeButton() + app.stop_rec_btn = FakeButton() + app.preview = FakeTreeview() + app.hotkeys = {"start_record": ["", "r"]} + app.recorder = FakeRecorder(events=[MacroEvent("key_down", {"key": "a"}, 0)]) + app._populate_preview = lambda macro: setattr(app, "preview_macro", macro) + + App.start_recording(app) + App.stop_recording(app) + + assert app.start_rec_btn.state == "normal" + assert app.stop_rec_btn.state == "disabled" + assert app.recorder.started is True + assert app.current_macro is not None + assert app.preview_macro is app.current_macro + + +def test_start_playback_validation_and_success(monkeypatch) -> None: + """Tests playback validation and successful playback. + + Verifies that start_playback validates macro existence and repeat + count before initiating playback, and handles errors appropriately. + + :param monkeypatch: Pytest monkeypatch fixture + :return: None + :rtype: None + """ + app = _make_app() + warnings = [] + errors = [] + monkeypatch.setattr(app_module, "messagebox", SimpleNamespace( + showwarning=lambda _t, msg: warnings.append(msg), + showerror=lambda _t, msg: errors.append(msg), + )) + + app.current_macro = None + App.start_playback(app) + assert warnings == ["No macro loaded"] + + app.current_macro = Macro(name="demo", events=[MacroEvent("key_down", {"key": "a"}, 0)]) + app.repeat_var = FakeRepeatVar("-1") + App.start_playback(app) + assert errors[-1] == "Repeat count must be >= 0" + + app.repeat_var = FakeRepeatVar("2") + app.start_play_btn = FakeButton() + app.stop_play_btn = FakeButton() + app.player = FakePlayer() + + App.start_playback(app) + + assert app.start_play_btn.state == "disabled" + assert app.stop_play_btn.state == "normal" + assert app.player.play_calls[-1][1] == 2 + + +def test_save_macro_flow(monkeypatch, tmp_path: Path) -> None: + """Tests the complete save macro flow. + + Verifies that save_macro validates macro existence, prompts for + file path, updates macro name, and saves to CSV correctly. + + :param monkeypatch: Pytest monkeypatch fixture + :param tmp_path: Pytest temporary directory fixture + :type tmp_path: Path + :return: None + :rtype: None + """ + app = _make_app() + warnings = [] + monkeypatch.setattr(app_module, "messagebox", SimpleNamespace( + showwarning=lambda _t, msg: warnings.append(msg), + )) + app.current_macro = None + App.save_macro(app) + assert warnings == ["No macro to save"] + + macro = Macro(name="demo", events=[MacroEvent("key_down", {"key": "a"}, 0)]) + app.current_macro = macro + logs = [] + app._log = logs.append + save_calls = [] + target = tmp_path / "demo.csv" + monkeypatch.setattr(app_module, "filedialog", SimpleNamespace( + asksaveasfilename=lambda **_kwargs: str(target), + )) + monkeypatch.setattr( + app_module, + "save_macro_to_csv", + lambda path, _macro: save_calls.append(path), + ) + + App.save_macro(app) + + assert macro.name == "demo" + assert save_calls == [target] + assert "saved" in logs[-1] + + +def test_load_macro_flow(monkeypatch, tmp_path: Path) -> None: + """Tests the complete load macro flow. + + Verifies that load_macro prompts for file path, loads from CSV, + handles empty files, and updates the current macro and preview. + + :param monkeypatch: Pytest monkeypatch fixture + :param tmp_path: Pytest temporary directory fixture + :type tmp_path: Path + :return: None + :rtype: None + """ + app = _make_app() + errors = [] + monkeypatch.setattr(app_module, "messagebox", SimpleNamespace( + showerror=lambda _t, msg: errors.append(msg), + )) + monkeypatch.setattr(app_module, "filedialog", SimpleNamespace( + askopenfilename=lambda **_kwargs: str(tmp_path / "demo.csv"), + )) + monkeypatch.setattr(app_module, "load_macros_from_csv", lambda _path: []) + + App.load_macro(app) + assert errors == ["No macro found"] + + macro = Macro(name="demo", events=[MacroEvent("key_down", {"key": "a"}, 0)]) + monkeypatch.setattr(app_module, "load_macros_from_csv", lambda _path: [macro]) + logs = [] + app._log = logs.append + app._populate_preview = lambda _macro: logs.append("preview") + + App.load_macro(app) + + assert app.current_macro is macro + assert "preview" in logs + assert "loaded" in logs[-1] diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..e43c04a --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,107 @@ +"""Configuration tests (load/save). + +This module tests the configuration loading and saving functionality, +including default value handling, JSON validation, hotkey normalization, +and configuration persistence. +""" + +import json +from pathlib import Path + +from pymacrorecorder import config + + + +def test_load_config_defaults_when_missing(monkeypatch, tmp_path: Path) -> None: + """Returns default values if the config file is missing. + + Verifies that load_config returns DEFAULT_HOTKEYS when the + configuration file does not exist. + + :param monkeypatch: Pytest monkeypatch fixture + :param tmp_path: Pytest temporary directory fixture + :type tmp_path: Path + :return: None + :rtype: None + """ + cfg_path = tmp_path / "config.json" + monkeypatch.setattr(config, "_config_path", lambda: cfg_path) + + data = config.load_config() + + assert data["hotkeys"] == config.DEFAULT_HOTKEYS + + + +def test_load_config_invalid_json(monkeypatch, tmp_path: Path) -> None: + """Returns default values if the JSON is invalid. + + Verifies that load_config handles malformed JSON gracefully by + returning default configuration values. + + :param monkeypatch: Pytest monkeypatch fixture + :param tmp_path: Pytest temporary directory fixture + :type tmp_path: Path + :return: None + :rtype: None + """ + cfg_path = tmp_path / "config.json" + cfg_path.write_text("{broken", encoding="utf-8") + monkeypatch.setattr(config, "_config_path", lambda: cfg_path) + + data = config.load_config() + + assert data["hotkeys"] == config.DEFAULT_HOTKEYS + + + +def test_load_config_sanitizes_hotkeys(monkeypatch, tmp_path: Path) -> None: + """Normalizes and validates hotkeys read from the file. + + Verifies that load_config normalizes hotkey case, validates format, + and replaces invalid hotkeys with defaults. + + :param monkeypatch: Pytest monkeypatch fixture + :param tmp_path: Pytest temporary directory fixture + :type tmp_path: Path + :return: None + :rtype: None + """ + cfg_path = tmp_path / "config.json" + payload = { + "hotkeys": { + "start_record": ["", "R"], + "stop_record": ["bad"], + "save_macro": "not-a-list", + } + } + cfg_path.write_text(json.dumps(payload), encoding="utf-8") + monkeypatch.setattr(config, "_config_path", lambda: cfg_path) + + data = config.load_config() + + assert data["hotkeys"]["start_record"] == ["", "r"] + assert data["hotkeys"]["stop_record"] == config.DEFAULT_HOTKEYS["stop_record"] + assert data["hotkeys"]["save_macro"] == config.DEFAULT_HOTKEYS["save_macro"] + + + +def test_save_config_normalizes(monkeypatch, tmp_path: Path) -> None: + """Normalizes hotkeys before writing to disk. + + Verifies that save_config normalizes hotkey case before persisting + the configuration to the JSON file. + + :param monkeypatch: Pytest monkeypatch fixture + :param tmp_path: Pytest temporary directory fixture + :type tmp_path: Path + :return: None + :rtype: None + """ + cfg_path = tmp_path / "config.json" + monkeypatch.setattr(config, "_config_path", lambda: cfg_path) + + config.save_config({"hotkeys": {"start_record": ["", "R"]}}) + + saved = json.loads(cfg_path.read_text(encoding="utf-8")) + assert saved["hotkeys"]["start_record"] == ["", "r"] diff --git a/tests/test_hotkeys.py b/tests/test_hotkeys.py new file mode 100644 index 0000000..2bd811c --- /dev/null +++ b/tests/test_hotkeys.py @@ -0,0 +1,265 @@ +"""Hotkey manager tests. + +This module tests the hotkey management system, including combo filtering, +listener lifecycle, hotkey capture, and event dispatching functionality. +""" +# pylint: disable=too-few-public-methods +# pylint: disable=protected-access + +from types import SimpleNamespace + +import pymacrorecorder.hotkeys as hotkeys_module +from pymacrorecorder.hotkeys import HotkeyManager, capture_hotkey_blocking + + +def test_hotkey_manager_filters_and_starts() -> None: + """Validates combo filtering and dispatcher execution. + + Verifies that HotkeyManager filters invalid hotkey combinations, + starts the listener with valid combos, and dispatches events correctly. + + :return: None + :rtype: None + """ + dispatched = [] + manager = HotkeyManager( + mapping={ + "start": ["", "r"], + "bad": ["x"], + "invalid": ["", "xx"], + }, + dispatcher=dispatched.append, + ) + + manager.start() + + assert manager._listener is not None + manager._listener.mapping["+r"]() + assert dispatched == ["start"] + + +def test_hotkey_manager_update_to_empty() -> None: + """Does not start listener when the map is empty. + + Verifies that HotkeyManager does not create a listener when + provided with an empty hotkey mapping. + + :return: None + :rtype: None + """ + manager = HotkeyManager(mapping={}, dispatcher=lambda _a: None) + + manager.start() + + assert manager._listener is None + + +def test_hotkey_manager_stop_clears_listener() -> None: + """Stops the listener and sets it back to None. + + Verifies that stop() properly terminates the listener and + clears the internal listener reference. + + :return: None + :rtype: None + """ + manager = HotkeyManager(mapping={"start": ["", "r"]}, dispatcher=lambda _a: None) + + manager.start() + assert manager._listener is not None + + manager.stop() + + assert manager._listener is None + + +def test_hotkey_manager_update_restarts_listener() -> None: + """Restarts with a new valid map. + + Verifies that update() stops the old listener and starts a new + one with the updated hotkey mapping. + + :return: None + :rtype: None + """ + manager = HotkeyManager(mapping={"start": ["", "r"]}, dispatcher=lambda _a: None) + + manager.start() + assert manager._listener is not None + + manager.update({"save": ["", "s"]}) + + assert manager._listener is not None + assert "+s" in manager._listener.mapping + + +def test_capture_hotkey_blocking_returns_combo(monkeypatch) -> None: + """Captures a valid combination and normalizes it. + + Verifies that capture_hotkey_blocking correctly captures keypresses, + normalizes the combination, and returns it after key release. + + :param monkeypatch: Pytest monkeypatch fixture + :return: None + :rtype: None + """ + + class FakeEvent: + """Fake threading event for testing. + + Simulates threading.Event for coordinating key capture completion. + """ + + def __init__(self) -> None: + """Initializes the fake event. + + Sets up the event flag tracking. + """ + self._set = False + + def set(self) -> None: + """Sets the event flag. + + :return: None + :rtype: None + """ + self._set = True + + def wait(self, _timeout: int | None = None, **_kwargs) -> bool: + """Waits for the event. + + :param _timeout: Timeout in seconds (ignored) + :type _timeout: int | None + :param _kwargs: Additional arguments (ignored) + :return: Always True + :rtype: bool + """ + return True + + class FakeListener: + """Fake keyboard listener for testing. + + Simulates pynput keyboard listener for capturing key events. + """ + + def __init__(self, on_press, on_release) -> None: + """Initializes the fake listener. + + :param on_press: Callback for key press events + :param on_release: Callback for key release events + """ + self._on_press = on_press + self._on_release = on_release + + def start(self) -> None: + """Starts the listener and simulates key events. + + Simulates pressing Ctrl+R for testing purposes. + + :return: None + :rtype: None + """ + self._on_press(hotkeys_module.keyboard.Key.ctrl) + self._on_press(hotkeys_module.keyboard.KeyCode.from_char("r")) + self._on_release(hotkeys_module.keyboard.KeyCode.from_char("r")) + + def stop(self) -> None: + """Stops the listener. + + :return: None + :rtype: None + """ + return None + + def join(self) -> None: + """Joins the listener thread. + + :return: None + :rtype: None + """ + return None + + monkeypatch.setattr(hotkeys_module, "threading", SimpleNamespace(Event=FakeEvent)) + monkeypatch.setattr(hotkeys_module.keyboard, "Listener", FakeListener) + + combo = capture_hotkey_blocking(min_keys=2, timeout=1) + + assert combo == ["", "r"] + + +def test_capture_hotkey_blocking_returns_none_when_too_short(monkeypatch) -> None: + """Returns None if the combination is too short. + + Verifies that capture_hotkey_blocking returns None when the + captured key combination has fewer keys than the required minimum. + + :param monkeypatch: Pytest monkeypatch fixture + :return: None + :rtype: None + """ + + class FakeEvent: + """Fake threading event for testing. + + Simulates threading.Event for coordinating key capture completion. + """ + + def wait(self, _timeout: int | None = None, **_kwargs) -> bool: + """Waits for the event. + + :param _timeout: Timeout in seconds (ignored) + :type _timeout: int | None + :param _kwargs: Additional arguments (ignored) + :return: Always True + :rtype: bool + """ + return True + + class FakeListener: + """Fake keyboard listener for testing. + + Simulates pynput keyboard listener with insufficient key presses. + """ + + def __init__(self, on_press, on_release) -> None: + """Initializes the fake listener. + + :param on_press: Callback for key press events + :param on_release: Callback for key release events + """ + self._on_press = on_press + self._on_release = on_release + + def start(self) -> None: + """Starts the listener and simulates key events. + + Simulates pressing only 'x' key (insufficient for min_keys=2). + + :return: None + :rtype: None + """ + self._on_press(hotkeys_module.keyboard.KeyCode.from_char("x")) + self._on_release(hotkeys_module.keyboard.KeyCode.from_char("x")) + + def stop(self) -> None: + """Stops the listener. + + :return: None + :rtype: None + """ + return None + + def join(self) -> None: + """Joins the listener thread. + + :return: None + :rtype: None + """ + return None + + monkeypatch.setattr(hotkeys_module, "threading", SimpleNamespace(Event=FakeEvent)) + monkeypatch.setattr(hotkeys_module.keyboard, "Listener", FakeListener) + + combo = capture_hotkey_blocking(min_keys=2, timeout=1) + + assert combo is None diff --git a/tests/test_models.py b/tests/test_models.py new file mode 100644 index 0000000..588d613 --- /dev/null +++ b/tests/test_models.py @@ -0,0 +1,21 @@ +"""Macro model tests. + +This module tests the Macro and MacroEvent data models, including +macro validation and utility methods. +""" + +from pymacrorecorder.models import Macro, MacroEvent + + +def test_macro_is_empty() -> None: + """Verifies whether a macro is empty or not. + + Tests the is_empty() method to correctly identify macros with + no events as empty and macros with events as non-empty. + + :return: None + :rtype: None + """ + assert Macro(name="empty").is_empty() is True + macro = Macro(name="filled", events=[MacroEvent("key_down", {"key": "a"}, 0)]) + assert macro.is_empty() is False diff --git a/tests/test_player.py b/tests/test_player.py new file mode 100644 index 0000000..dec283c --- /dev/null +++ b/tests/test_player.py @@ -0,0 +1,71 @@ +"""Macro player tests. + +This module tests the Player class, including event application, +completion callbacks, and stop functionality. +""" +# pylint: disable=protected-access + +from pymacrorecorder.models import Macro, MacroEvent +from pymacrorecorder.player import Player + + +def test_player_applies_events_and_completes(monkeypatch) -> None: + """Applies events and calls completion callback. + + Verifies that the Player correctly applies all event types + (key_down, key_up, mouse_move, mouse_scroll, mouse_click) and + triggers the on_completion callback when finished. + + :param monkeypatch: Pytest monkeypatch fixture + :return: None + :rtype: None + """ + player = Player() + completed = {"called": False} + player.on_completion = lambda: completed.update({"called": True}) + + macro = Macro( + name="demo", + events=[ + MacroEvent("key_down", {"key": "a"}, 0), + MacroEvent("key_up", {"key": "a"}, 0), + MacroEvent("mouse_move", {"x": 10, "y": 20}, 0), + MacroEvent("mouse_scroll", {"x": 0, "y": 0, "dx": 1, "dy": -1}, 0), + MacroEvent("mouse_click", {"x": 5, "y": 6, "button": "left", "action": "press"}, 0), + MacroEvent("mouse_click", {"x": 5, "y": 6, "button": "left", "action": "release"}, 0), + ], + ) + + monkeypatch.setattr("pymacrorecorder.player.time.sleep", lambda _s: None) + player._run(macro, repeats=1) + + assert completed["called"] is True + assert player._keyboard.pressed + assert player._keyboard.released + assert player._mouse.scrolled + + +def test_player_stop_prevents_completion(monkeypatch) -> None: + """Does not trigger completion if stop is requested. + + Verifies that the Player respects the stop event and does not + call the on_completion callback when stopped prematurely. + + :param monkeypatch: Pytest monkeypatch fixture + :return: None + :rtype: None + """ + player = Player() + completed = {"called": False} + player.on_completion = lambda: completed.update({"called": True}) + + macro = Macro( + name="demo", + events=[MacroEvent("key_down", {"key": "a"}, 0)], + ) + + player._stop_event.set() + monkeypatch.setattr("pymacrorecorder.player.time.sleep", lambda _s: None) + player._run(macro, repeats=1) + + assert completed["called"] is False diff --git a/tests/test_recorder.py b/tests/test_recorder.py new file mode 100644 index 0000000..fa71d8e --- /dev/null +++ b/tests/test_recorder.py @@ -0,0 +1,146 @@ +"""Recorder tests (without UI). + +This module tests the Recorder class functionality, including event +recording, delay calculation, hotkey filtering, and event type handling. +""" +# pylint: disable=protected-access + +import itertools + +from pynput import keyboard + +from pymacrorecorder.recorder import Recorder + + + +def test_recorder_records_events_with_delay(monkeypatch) -> None: + """Records events with calculated delay. + + Verifies that the Recorder correctly captures key press and release + events with accurate delay calculations based on timestamps. + + :param monkeypatch: Pytest monkeypatch fixture + :return: None + :rtype: None + """ + times = itertools.chain([100.0, 100.1, 100.3], itertools.repeat(100.3)) + monkeypatch.setattr("pymacrorecorder.recorder.time.time", lambda: next(times)) + + recorder = Recorder() + recorder.start(ignored_hotkeys=[]) + + key = keyboard.KeyCode.from_char("a") + recorder._on_key_press(key) + recorder._on_key_release(key) + + events = recorder.stop() + + assert len(events) == 2 + assert events[0].event_type == "key_down" + assert events[0].delay_ms == 0 + assert events[1].event_type == "key_up" + assert events[1].delay_ms == 200 + + + +def test_recorder_stop_without_start() -> None: + """Returns an empty list if stopped without starting. + + Verifies that calling stop() without a prior start() returns + an empty event list. + + :return: None + :rtype: None + """ + recorder = Recorder() + assert not recorder.stop() + + + +def test_recorder_ignores_when_hotkey_pressed() -> None: + """Ignores events during a hotkey to ignore. + + Verifies that the Recorder correctly filters out events when + an ignored hotkey combination is being pressed. + + :return: None + :rtype: None + """ + recorder = Recorder() + recorder.start(ignored_hotkeys=[["", "r"]]) + + recorder._on_key_press(keyboard.Key["ctrl"]) + recorder._on_key_press(keyboard.KeyCode.from_char("r")) + recorder._on_click(10, 10, None, True) + + events = recorder.stop() + + assert len(events) == 1 + assert events[0].event_type == "key_down" + + +def test_recorder_scroll_and_move_events(monkeypatch) -> None: + """Records scroll and move events. + + Verifies that the Recorder correctly captures mouse scroll and + move events with proper payload formatting. + + :param monkeypatch: Pytest monkeypatch fixture + :return: None + :rtype: None + """ + monkeypatch.setattr("pymacrorecorder.recorder.time.time", lambda: 100.0) + recorder = Recorder() + recorder.start(ignored_hotkeys=[]) + + recorder._on_scroll(10, 20, 1, -1) + recorder._on_move(30, 40) + + events = recorder.stop() + + assert len(events) == 2 + assert events[0].event_type == "mouse_scroll" + assert events[0].payload == {"x": 10, "y": 20, "dx": 1, "dy": -1} + assert events[1].event_type == "mouse_move" + assert events[1].payload == {"x": 30, "y": 40} + + + +def test_recorder_ignores_release_when_hotkey_active() -> None: + """Does not add key_up event when hotkey is active. + + Verifies that the Recorder ignores key release events when + they are part of an active ignored hotkey combination. + + :return: None + :rtype: None + """ + recorder = Recorder() + recorder.start(ignored_hotkeys=[["", "r"]]) + + recorder._on_key_press(keyboard.Key["ctrl"]) + recorder._on_key_press(keyboard.KeyCode.from_char("r")) + recorder._on_key_release(keyboard.KeyCode.from_char("r")) + + events = recorder.stop() + + assert len(events) == 1 + assert events[0].event_type == "key_down" + + + +def test_recorder_no_events_when_not_running() -> None: + """Does not produce events if the recorder is not started. + + Verifies that event callbacks do not record events when the + Recorder has not been started. + + :return: None + :rtype: None + """ + recorder = Recorder() + + recorder._on_move(1, 2) + recorder._on_scroll(1, 2, 3, 4) + + assert not recorder.stop() diff --git a/tests/test_storage.py b/tests/test_storage.py new file mode 100644 index 0000000..c590803 --- /dev/null +++ b/tests/test_storage.py @@ -0,0 +1,79 @@ +"""Macro CSV persistence tests. + +This module tests the storage functionality for saving and loading +macros to/from CSV files, including error handling for missing or +invalid files. +""" + +from pathlib import Path + +from pymacrorecorder.models import Macro, MacroEvent +from pymacrorecorder.storage import load_macros_from_csv, save_macro_to_csv + + + +def test_save_and_load_macro(tmp_path: Path) -> None: + """Saves then reloads a CSV macro. + + Verifies that a macro can be successfully saved to CSV and then + loaded back with all events and metadata intact. + + :param tmp_path: Pytest temporary directory fixture + :type tmp_path: Path + :return: None + :rtype: None + """ + macro = Macro( + name="demo", + events=[ + MacroEvent(event_type="key_down", payload={"key": "a"}, delay_ms=0), + MacroEvent(event_type="mouse_move", payload={"x": 10, "y": 20}, delay_ms=12), + ], + ) + path = tmp_path / "demo.csv" + + save_macro_to_csv(path, macro) + loaded = load_macros_from_csv(path) + + assert len(loaded) == 1 + assert loaded[0].name == "demo" + assert len(loaded[0].events) == 2 + assert loaded[0].events[0].payload == {"key": "a"} + assert loaded[0].events[1].payload == {"x": 10, "y": 20} + + + +def test_load_macro_missing_file(tmp_path: Path) -> None: + """Returns an empty list if the file is missing. + + Verifies that load_macros_from_csv gracefully handles missing + files by returning an empty list. + + :param tmp_path: Pytest temporary directory fixture + :type tmp_path: Path + :return: None + :rtype: None + """ + missing = tmp_path / "missing.csv" + assert not load_macros_from_csv(missing) + + + +def test_load_macro_invalid_payload(tmp_path: Path) -> None: + """Returns an empty list if the CSV payload is invalid. + + Verifies that load_macros_from_csv handles malformed CSV data + (e.g., invalid JSON payload) by returning an empty list. + + :param tmp_path: Pytest temporary directory fixture + :type tmp_path: Path + :return: None + :rtype: None + """ + path = tmp_path / "broken.csv" + path.write_text( + "id,event_type,payload,delay_ms\n1,key_down,{,0\n", + encoding="utf-8", + ) + + assert not load_macros_from_csv(path) diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 0000000..6290410 --- /dev/null +++ b/tests/test_utils.py @@ -0,0 +1,120 @@ +"""Keyboard/mouse utility tests. + +This module tests utility functions for keyboard and mouse handling, +including key/button conversions, label normalization, hotkey parsing, +and combination matching. +""" + +from pynput import keyboard, mouse + +from pymacrorecorder import utils + + +def test_key_to_str_with_keycode_char() -> None: + """Converts a KeyCode with char to a label. + + Verifies that key_to_str correctly converts a KeyCode with a + character attribute to its string representation. + + :return: None + :rtype: None + """ + key = keyboard.KeyCode.from_char("a") + assert utils.key_to_str(key) == "a" + + +def test_key_to_str_with_keycode_vk() -> None: + """Converts a VK KeyCode to a label. + + Verifies that key_to_str correctly converts a KeyCode with only + a virtual key code to a formatted string. + + :return: None + :rtype: None + """ + key = keyboard.KeyCode(vk=65) + assert utils.key_to_str(key) == "" + + +def test_key_to_str_with_special_key() -> None: + """Converts a special key to a label. + + Verifies that key_to_str correctly converts special keys + (e.g., Key.enter) to their formatted string representation. + + :return: None + :rtype: None + """ + assert utils.key_to_str(keyboard.Key.enter) == "" + + +def test_normalize_label_variants() -> None: + """Normalizes different label formats. + + Verifies that normalize_label correctly converts labels to + lowercase and translates virtual key codes to characters. + + :return: None + :rtype: None + """ + assert utils.normalize_label("A") == "a" + assert utils.normalize_label("") == "" + assert utils.normalize_label("") == "2" + assert utils.normalize_label("") == "a" + + +def test_is_parseable_hotkey() -> None: + """Validates a parseable hotkey combination. + + Verifies that is_parseable_hotkey correctly identifies valid + and invalid hotkey combination strings. + + :return: None + :rtype: None + """ + assert utils.is_parseable_hotkey("+c") is True + assert utils.is_parseable_hotkey("not+a+hotkey") is False + + +def test_str_to_key_variants() -> None: + """Converts a label to Key/KeyCode. + + Verifies that str_to_key correctly converts various string + representations to Key or KeyCode objects. + + :return: None + :rtype: None + """ + assert utils.str_to_key("").char == " " + assert utils.str_to_key("") == keyboard.Key.ctrl + assert utils.str_to_key("").vk == 65 + assert utils.str_to_key("x").char == "x" + assert utils.str_to_key("bad").char == "b" + + +def test_str_to_button_variants() -> None: + """Converts a label to mouse button. + + Verifies that str_to_button correctly converts button name + strings to mouse.Button enum values. + + :return: None + :rtype: None + """ + assert utils.str_to_button("left") == mouse.Button.left + assert utils.str_to_button("invalid") == mouse.Button.left + + +def test_format_and_match_helpers() -> None: + """Tests format and match helper functions. + + Verifies that format_combo, combos_as_sets, and + pressed_matches_hotkey work correctly together for hotkey matching. + + :return: None + :rtype: None + """ + combo = ["", "c"] + assert utils.format_combo(combo) == "+c" + hotkeys = utils.combos_as_sets([combo]) + assert utils.pressed_matches_hotkey({"", "c"}, hotkeys) is True