diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..4e9c62f --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,35 @@ +name: Tests + +on: + pull_request: + branches: + - main + - develop + - release + +jobs: + pytest: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Install UV + run: curl -LsSf https://astral.sh/uv/install.sh | sh + - name: Install system dependencies for OpenCV / DearPyGui + run: | + sudo apt-get update + sudo apt-get install -y \ + libgl1 \ + libglib2.0-0 \ + libsm6 \ + libxext6 \ + libxrender1 \ + libxrandr2 \ + libxinerama1 \ + libxcursor1 \ + libxi6 \ + libgles2-mesa-dev + - name: Sync project (with dev group) + run: uv sync --group dev + - name: Run tests + run: uv run pytest -ra diff --git a/CHANGELOG.md b/CHANGELOG.md index 349a996..074c023 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,9 @@ # Changelog -## v0.0.5 +## v0.0.6 -- Updates to the Onboarding UI -- Added cross-platform support for listing cameras. +- Added `CONTRIBUTING.md`to project. +- Updated license document with full text. +- Fixed crash on Onboarding cancel. + diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..e952a0c --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,73 @@ +# Contributing to PowerMouse + +Contributions are welcome, and they are greatly appreciated! Every little bit +helps, and credit will always be given. + +## Types of Contributions + +### Report Bugs + +If you are reporting a bug, please include: + +* Your operating system name and version. +* Any details about your local setup that might be helpful in troubleshooting. +* Detailed steps to reproduce the bug. + +### Fix Bugs + +Look through the GitHub issues for bugs. Anything tagged with "bug" and "help +wanted" is open to whoever wants to implement it. + +### Implement Features + +Look through the GitHub issues for features. Anything tagged with "enhancement" +and "help wanted" is open to whoever wants to implement it. + +### Write Documentation + +You can never have enough documentation! Please feel free to contribute to any +part of the documentation, such as the official docs, docstrings, or even +on the web in blog posts, articles, and such. + +### Submit Feedback + +If you are proposing a feature: + +* Explain in detail how it would work. +* Keep the scope as narrow as possible, to make it easier to implement. +* Remember that this is a volunteer-driven project, and that contributions + are welcome :) + +## Get Started! + +Ready to contribute? Here's how to set up `phitodeep` for local development. + +1. Clone the repository from GitHub. + +2. You will need to install [UV](https://docs.astral.sh/uv/getting-started/installation/). It is a modern Python environment and project management tool. You will grow to love it, trust me. + +3. Simply run `uv run pytest` in the root directory of your cloned repository and it should automatically create your virtual environment with all the necessary dependencies, followed by running the project test suite. + +4. Use `git` (or similar) to create a branch for local development and make your changes: + + ```console + $ git checkout -b name-of-your-bugfix-or-feature + ``` + +5. When you're done making changes, check that your changes conform to any code formatting requirements and pass any tests. + +6. Commit your changes and open a pull request against the current release branch in development. + +## Pull Request Guidelines + +Before you submit a pull request, check that it meets these guidelines: + +1. The pull request should include additional tests if appropriate. +2. If the pull request adds functionality, the docs should be updated. +3. The pull request should work for all currently supported operating systems and versions of Python. + +## Code of Conduct + +Please note that the `PowerMouse` project is released with a +Code of Conduct. By contributing to this project you agree to abide by its terms. + diff --git a/LICENSE b/LICENSE index b9adbf7..f73bbe2 100644 --- a/LICENSE +++ b/LICENSE @@ -1,15 +1,201 @@ -Apache Software License 2.0 (Apache-2.0) + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ -Copyright (c) 2026, Ralph Dugue + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at + 1. Definitions. -http://www.apache.org/licenses/LICENSE-2.0 + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for describing the origin of the Work and + reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2026 Ralph Dugue + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/pyproject.toml b/pyproject.toml index 19452cb..f4a4869 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "PowerMouse" -version = "0.0.5" +version = "0.0.6" description = "Hands-free mouse control using face tracking" authors = [ { name = "Ralph Dugue", email = "ralph@phito.dev" } diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..0bc5130 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +testpaths = tests +addopts = -ra diff --git a/src/powermouse/main.py b/src/powermouse/main.py index 9305e41..bb5ff0f 100644 --- a/src/powermouse/main.py +++ b/src/powermouse/main.py @@ -1,6 +1,7 @@ # pyright: reportGeneralTypeIssues=false, reportArgumentType=false from __future__ import annotations +import sys import threading import time @@ -33,7 +34,12 @@ def main() -> None: # First-run onboarding when no profiles exist. if not profile_manager.list_profiles(): - run_onboarding(profile_manager, device_manager) + created = run_onboarding(profile_manager, device_manager) + if created is None: + # User cancelled / closed the onboarding window. Exit cleanly with + # status 0 and no stderr output -- on Briefcase Windows MSI builds + # the stub treats stderr writes during shutdown as a crash. + sys.exit(0) try: active_profile = profile_manager.get_active_profile() @@ -41,7 +47,7 @@ def main() -> None: # Profiles exist but none is active; pick the first one and activate it. profiles = profile_manager.list_profiles() if not profiles: - raise SystemExit("No profiles available after onboarding; exiting.") + sys.exit(0) first = profiles[0] first.is_active = True active_profile = profile_manager.update_profile(first.profile_id, first) diff --git a/src/powermouse/widgets/onboarding.py b/src/powermouse/widgets/onboarding.py index b2642eb..28834d9 100644 --- a/src/powermouse/widgets/onboarding.py +++ b/src/powermouse/widgets/onboarding.py @@ -51,9 +51,16 @@ def __init__( # -- entry point --------------------------------------------------- - def run(self) -> Profile: - """Display the dialog and return the created profile. Raises SystemExit if the - user closes the viewport without completing.""" + def run(self) -> Optional[Profile]: + """Display the dialog and return the created profile, or ``None`` if the + user cancelled / closed the window without completing. + + We deliberately do *not* raise ``SystemExit`` here: on Briefcase Windows + MSI builds the app runs without an attached console, so writing the + ``SystemExit`` message to ``sys.stderr`` during interpreter shutdown is + treated as a crash by the stub and produces a "the application has + crashed" dialog. Returning ``None`` lets ``main()`` exit cleanly. + """ dpg.create_context() try: dpg.create_viewport(title="PowerMouse Setup", width=760, height=560) @@ -69,10 +76,11 @@ def run(self) -> Profile: if self._created is not None: break finally: - dpg.destroy_context() + try: + dpg.destroy_context() + except Exception: # noqa: BLE001 - best-effort cleanup on shutdown + pass - if self._created is None: - raise SystemExit("Onboarding cancelled; no profile was created.") return self._created # -- DPG tree ------------------------------------------------------ @@ -220,6 +228,7 @@ def _set_status(self, message: str) -> None: def run_onboarding( profile_manager: SqlAlchemyProfileManager, device_manager: DeviceManager -) -> Profile: - """Convenience helper: run the dialog once and return the created profile.""" +) -> Optional[Profile]: + """Convenience helper: run the dialog once and return the created profile, + or ``None`` if the user cancelled.""" return OnboardingDialog(profile_manager, device_manager).run() diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ + diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..b3253b3 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,173 @@ +"""Shared pytest fixtures. + +These fixtures use the real ``powermouse.adapters`` implementations where +practical so the tests exercise the same surface the application does. +""" +from __future__ import annotations + +import threading +from typing import List + +import numpy as np +import pytest + +from powermouse.adapters.profile import SqlAlchemyProfileManager +from powermouse.domain.controllers.camera import CameraController +from powermouse.domain.controllers.devices import DeviceManager +from powermouse.domain.controllers.inference import InferenceController +from powermouse.domain.controllers.mouse import MouseController +from powermouse.domain.models.camera import Camera, FaceTrackerSettings +from powermouse.domain.models.gesture import GestureEvent +from powermouse.domain.models.mouse import ClickInterface, MouseEvent +from powermouse.domain.models.profile import Profile + + +# --------------------------------------------------------------------------- +# Domain-model fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture +def blank_frame() -> np.ndarray: + return np.zeros((48, 64, 3), dtype=np.uint8) + + +@pytest.fixture +def camera(blank_frame) -> Camera: + return Camera( + name="Test Camera", + id="0", + fps=30.0, + current_frame=blank_frame, + frame_width=blank_frame.shape[1], + frame_height=blank_frame.shape[0], + ) + + +@pytest.fixture +def face_tracker_settings(camera) -> FaceTrackerSettings: + return FaceTrackerSettings(camera=camera) + + +@pytest.fixture +def sample_profile(face_tracker_settings) -> Profile: + return Profile( + profile_id=0, + name="Default", + face_tracker_settings=face_tracker_settings, + is_active=True, + click_interfaces={ClickInterface.GESTURE: True}, + ) + + +# --------------------------------------------------------------------------- +# Adapter fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture +def profile_manager() -> SqlAlchemyProfileManager: + """SqlAlchemyProfileManager backed by an in-memory SQLite DB.""" + return SqlAlchemyProfileManager(db_url="sqlite:///:memory:") + + +@pytest.fixture +def populated_profile_manager(profile_manager, sample_profile) -> SqlAlchemyProfileManager: + profile_manager.create_profile(sample_profile) + return profile_manager + + +# --------------------------------------------------------------------------- +# Test doubles for controllers used by use cases / widgets. +# --------------------------------------------------------------------------- + + +class FakeCameraController(CameraController): + def __init__(self, camera: Camera, frames: List[np.ndarray] | None = None): + super().__init__(camera) + self._frames = list(frames) if frames else [camera.current_frame] + self._idx = 0 + self.start_calls = 0 + self.stop_calls = 0 + self.update_calls = 0 + + def start_stream(self) -> None: + self.start_calls += 1 + + def stop_stream(self) -> None: + self.stop_calls += 1 + + def update_frame(self) -> None: + self.update_calls += 1 + frame = self._frames[self._idx % len(self._frames)] + self._idx += 1 + self.camera.update_frame(frame) + + +class FakeInferenceController(InferenceController): + def __init__( + self, + cursor: tuple[int, int] = (10, 20), + gestures: List[GestureEvent] | None = None, + ): + super().__init__() + self._cursor = cursor + self._gestures: List[GestureEvent] = list(gestures or []) + self.process_calls: list[tuple[np.ndarray, int]] = [] + self.start_calls = 0 + self.stop_calls = 0 + + def start(self) -> None: + self.start_calls += 1 + + def stop(self) -> None: + self.stop_calls += 1 + + def process_frame(self, frame_bgr, timestamp_ms: int) -> None: + self.process_calls.append((frame_bgr, timestamp_ms)) + + def get_cursor_position(self) -> tuple[int, int]: + return self._cursor + + def detect_gesture(self): + if not self._gestures: + return None + return self._gestures.pop(0) + + +class RecordingMouseController(MouseController): + def __init__(self) -> None: + self.events: list[MouseEvent] = [] + self._lock = threading.Lock() + + def handle_event(self, mouse: MouseEvent) -> None: + with self._lock: + self.events.append(mouse) + + +class FakeDeviceManager(DeviceManager): + def __init__(self, cameras: List[Camera]): + self._cameras = cameras + + def get_devices(self) -> List[Camera]: + return list(self._cameras) + + +@pytest.fixture +def fake_camera_controller(camera) -> FakeCameraController: + return FakeCameraController(camera) + + +@pytest.fixture +def fake_inference_controller() -> FakeInferenceController: + return FakeInferenceController() + + +@pytest.fixture +def recording_mouse_controller() -> RecordingMouseController: + return RecordingMouseController() + + +@pytest.fixture +def fake_device_manager(camera) -> FakeDeviceManager: + return FakeDeviceManager([camera]) diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/tests/integration/__init__.py @@ -0,0 +1 @@ + diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py new file mode 100644 index 0000000..1551b26 --- /dev/null +++ b/tests/integration/conftest.py @@ -0,0 +1,25 @@ +"""Integration-test fixtures that boot a headless DearPyGui context per test. + +DearPyGui can build a widget tree without a viewport; ``set_value`` / +``get_value`` round-trips work, so callbacks can be exercised by directly +invoking them with the stored values. +""" +from __future__ import annotations + +import dearpygui.dearpygui as dpg +import pytest + + +@pytest.fixture +def dpg_root(): + """Create a fresh DPG context with a parent window and tear it down.""" + dpg.create_context() + try: + with dpg.window(tag="test_root"): + pass + yield "test_root" + finally: + try: + dpg.destroy_context() + except Exception: + pass diff --git a/tests/integration/test_camera_widget.py b/tests/integration/test_camera_widget.py new file mode 100644 index 0000000..d41cbfb --- /dev/null +++ b/tests/integration/test_camera_widget.py @@ -0,0 +1,77 @@ +"""Integration tests for :class:`powermouse.widgets.camera.CameraWidget`.""" +from __future__ import annotations + +import dearpygui.dearpygui as dpg +import numpy as np + +from powermouse.widgets.camera import CameraWidget + + +def _build_widget(camera, controllers, manager) -> CameraWidget: + return CameraWidget( + camera_controller=controllers["camera"], + inference_controller=controllers["inference"], + profile_manager=manager, + current_camera=camera, + cameras=[camera], + panel_width=320, + image_width=64, + image_height=48, + ) + + +class TestCameraWidget: + def test_build_creates_texture_and_combo( + self, + dpg_root, + camera, + fake_camera_controller, + fake_inference_controller, + populated_profile_manager, + ): + widget = _build_widget( + camera, + {"camera": fake_camera_controller, "inference": fake_inference_controller}, + populated_profile_manager, + ) + widget.build(dpg_root) + assert dpg.does_item_exist(widget.TAG) + assert dpg.does_item_exist(widget.TEXTURE_TAG) + assert dpg.does_item_exist(widget.CAMERA_TAG) + + def test_update_frame_does_not_error_on_valid_frame( + self, + dpg_root, + camera, + fake_camera_controller, + fake_inference_controller, + populated_profile_manager, + ): + widget = _build_widget( + camera, + {"camera": fake_camera_controller, "inference": fake_inference_controller}, + populated_profile_manager, + ) + widget.build(dpg_root) + frame = np.full((48, 64, 3), 200, dtype=np.uint8) + # update_frame writes into the raw texture; success = no exception. + widget.update_frame(frame, 0) + assert dpg.does_item_exist(widget.TEXTURE_TAG) + + def test_update_frame_ignores_empty_input( + self, + dpg_root, + camera, + fake_camera_controller, + fake_inference_controller, + populated_profile_manager, + ): + widget = _build_widget( + camera, + {"camera": fake_camera_controller, "inference": fake_inference_controller}, + populated_profile_manager, + ) + widget.build(dpg_root) + # Must not raise on empty arrays. + widget.update_frame(np.zeros((0, 0, 3), dtype=np.uint8), 0) + widget.update_frame(None, 0) # type: ignore[arg-type] diff --git a/tests/integration/test_onboarding_widget.py b/tests/integration/test_onboarding_widget.py new file mode 100644 index 0000000..db3467a --- /dev/null +++ b/tests/integration/test_onboarding_widget.py @@ -0,0 +1,72 @@ +"""Integration tests for the first-run :class:`OnboardingDialog`. + +We cannot run the DearPyGui main loop in CI, but the dialog is split such +that ``_build``, ``_refresh_cameras`` and ``_on_create`` can be exercised +against a headless DPG context. +""" +from __future__ import annotations + +import dearpygui.dearpygui as dpg +import pytest + +from powermouse.widgets.onboarding import OnboardingDialog + + +@pytest.fixture +def dialog(profile_manager, fake_device_manager): + dpg.create_context() + dlg = OnboardingDialog(profile_manager, fake_device_manager) + try: + dlg._build() + yield dlg + finally: + try: + dpg.destroy_context() + except Exception: + pass + + +class TestOnboardingDialog: + def test_refresh_cameras_populates_combo(self, dialog, camera): + dialog._refresh_cameras() + items = dpg.get_item_configuration(dialog.CAMERA_TAG)["items"] + assert items == [f"{camera.name} (id={camera.id})"] + # Create button should now be enabled. + assert dpg.get_item_configuration(dialog.CREATE_TAG)["enabled"] is True + + def test_refresh_cameras_handles_empty_list(self, profile_manager): + from tests.conftest import FakeDeviceManager + + dpg.create_context() + try: + d = OnboardingDialog(profile_manager, FakeDeviceManager([])) + d._build() + d._refresh_cameras() + assert dpg.get_value(d.CAMERA_TAG) == "No cameras detected" + assert dpg.get_item_configuration(d.CREATE_TAG)["enabled"] is False + finally: + try: + dpg.destroy_context() + except Exception: + pass + + def test_on_create_requires_name(self, dialog, profile_manager): + dialog._refresh_cameras() + dpg.set_value(dialog.NAME_TAG, " ") + dialog._on_create() + assert dialog._created is None + assert dpg.get_value(dialog.STATUS_TAG) == "Profile name is required." + assert profile_manager.list_profiles() == [] + + def test_on_create_persists_profile(self, dialog, profile_manager, camera): + dialog._refresh_cameras() + dpg.set_value(dialog.NAME_TAG, "MyProfile") + dialog._on_create() + assert dialog._created is not None + assert dialog._created.name == "MyProfile" + assert dialog._created.is_active is True + # Profile is reachable via the manager and has the expected camera. + persisted = profile_manager.list_profiles() + assert len(persisted) == 1 + assert persisted[0].name == "MyProfile" + assert persisted[0].face_tracker_settings.camera.id == camera.id diff --git a/tests/integration/test_profiles_widget.py b/tests/integration/test_profiles_widget.py new file mode 100644 index 0000000..72c6448 --- /dev/null +++ b/tests/integration/test_profiles_widget.py @@ -0,0 +1,83 @@ +"""Integration tests for ``powermouse.widgets.profiles.ProfilesWidget``. + +The widget owns a real :class:`SqlAlchemyProfileManager` (in-memory) so we +exercise the persistence + UI-state interaction together. +""" +from __future__ import annotations + +import dearpygui.dearpygui as dpg + +from powermouse.widgets.profiles import ProfilesWidget + + +def _make_widget(manager, sink): + return ProfilesWidget(profile_manager=manager, on_selection_changed=sink.append) + + +class TestProfilesWidget: + def test_select_initial_picks_active_profile( + self, dpg_root, populated_profile_manager + ): + sink: list = [] + widget = _make_widget(populated_profile_manager, sink) + widget.build(dpg_root) + widget.select_initial() + assert widget.current is not None + assert widget.current.is_active is True + assert sink and sink[-1] is widget.current + + def test_new_profile_duplicates_current( + self, dpg_root, populated_profile_manager + ): + sink: list = [] + widget = _make_widget(populated_profile_manager, sink) + widget.build(dpg_root) + widget.select_initial() + widget._on_new() + + profiles = populated_profile_manager.list_profiles() + assert len(profiles) == 2 + copied = next(p for p in profiles if p.name.endswith("(copy)")) + # Copy must not be active and the widget should now select it. + assert copied.is_active is False + assert widget.current is not None + assert widget.current.profile_id == copied.profile_id + + def test_set_active_persists_and_reloads( + self, dpg_root, populated_profile_manager, sample_profile + ): + sink: list = [] + widget = _make_widget(populated_profile_manager, sink) + widget.build(dpg_root) + widget.select_initial() + widget._on_new() # create copy, currently selected + + widget._on_set_active() + active = populated_profile_manager.get_active_profile() + assert active.profile_id == widget.current.profile_id + # The other profile should now be inactive. + others = [ + p + for p in populated_profile_manager.list_profiles() + if p.profile_id != active.profile_id + ] + assert all(not p.is_active for p in others) + + def test_delete_removes_profile_and_falls_back( + self, dpg_root, populated_profile_manager + ): + sink: list = [] + widget = _make_widget(populated_profile_manager, sink) + widget.build(dpg_root) + widget.select_initial() + widget._on_new() # second profile, selected + delete_id = widget.current.profile_id + + widget._on_delete() + remaining_ids = { + p.profile_id for p in populated_profile_manager.list_profiles() + } + assert delete_id not in remaining_ids + # After delete with profiles remaining, selection falls back. + assert widget.current is not None + assert widget.current.profile_id in remaining_ids diff --git a/tests/integration/test_settings_widget.py b/tests/integration/test_settings_widget.py new file mode 100644 index 0000000..4c570c3 --- /dev/null +++ b/tests/integration/test_settings_widget.py @@ -0,0 +1,132 @@ +"""Integration tests for the settings widgets.""" +from __future__ import annotations + +import dearpygui.dearpygui as dpg +import pytest + +from powermouse.domain.models.mouse import ClickInterface +from powermouse.widgets.settings import ( + ClickingSettingsWidget, + SettingsWidget, + TrackingSettingsWidget, +) + + +class TestTrackingSettingsWidget: + def test_bind_populates_slider_values( + self, dpg_root, face_tracker_settings + ): + widget = TrackingSettingsWidget() + widget.build(dpg_root) + face_tracker_settings.speed = 2.5 + face_tracker_settings.smoothness = 0.75 + widget.bind(face_tracker_settings) + assert dpg.get_value(widget.SPEED_TAG) == pytest.approx(2.5) + assert dpg.get_value(widget.SMOOTH_TAG) == pytest.approx(0.75) + + def test_callback_mutates_bound_settings( + self, dpg_root, face_tracker_settings + ): + widget = TrackingSettingsWidget() + widget.build(dpg_root) + widget.bind(face_tracker_settings) + widget._on_speed(None, 3.5, None) + widget._on_sens_x(None, 2.0, None) + widget._on_sens_y(None, 0.25, None) + widget._on_smooth(None, 0.9, None) + widget._on_deadzone(None, 12, None) + assert face_tracker_settings.speed == 3.5 + assert face_tracker_settings.sensitivity == (2.0, 0.25) + assert face_tracker_settings.smoothness == 0.9 + assert face_tracker_settings.deadzone_radius_px == 12 + + def test_active_area_callback_reads_current_slider_values( + self, dpg_root, face_tracker_settings + ): + widget = TrackingSettingsWidget() + widget.build(dpg_root) + widget.bind(face_tracker_settings) + dpg.set_value(widget.AREA_X_MIN_TAG, 0.1) + dpg.set_value(widget.AREA_X_MAX_TAG, 0.9) + dpg.set_value(widget.AREA_Y_MIN_TAG, 0.2) + dpg.set_value(widget.AREA_Y_MAX_TAG, 0.8) + widget._on_area() + assert face_tracker_settings.active_area_x == pytest.approx((0.1, 0.9)) + assert face_tracker_settings.active_area_y == pytest.approx((0.2, 0.8)) + + +class TestClickingSettingsWidget: + def test_bind_populates_checkboxes_and_thresholds( + self, dpg_root, sample_profile + ): + widget = ClickingSettingsWidget() + widget.build(dpg_root) + sample_profile.face_tracker_settings.click_threshold_high = 0.7 + sample_profile.face_tracker_settings.click_threshold_low = 0.3 + widget.bind(sample_profile) + assert ( + dpg.get_value(widget._checkbox_tags[ClickInterface.GESTURE]) is True + ) + assert dpg.get_value(widget.HIGH_TAG) == pytest.approx(0.7) + assert dpg.get_value(widget.LOW_TAG) == pytest.approx(0.3) + + def test_toggle_callback_updates_profile(self, dpg_root, sample_profile): + widget = ClickingSettingsWidget() + widget.build(dpg_root) + widget.bind(sample_profile) + widget._make_on_toggle(ClickInterface.DWELL)(None, True, None) + assert sample_profile.is_click_interface_enabled(ClickInterface.DWELL) + + def test_threshold_callbacks_update_settings(self, dpg_root, sample_profile): + widget = ClickingSettingsWidget() + widget.build(dpg_root) + widget.bind(sample_profile) + widget._on_high(None, 0.85, None) + widget._on_low(None, 0.15, None) + assert sample_profile.face_tracker_settings.click_threshold_high == 0.85 + assert sample_profile.face_tracker_settings.click_threshold_low == 0.15 + + +class TestSettingsWidget: + def test_save_persists_changes( + self, dpg_root, populated_profile_manager + ): + tracking = TrackingSettingsWidget() + clicking = ClickingSettingsWidget() + saved: list = [] + widget = SettingsWidget( + profile_manager=populated_profile_manager, + tracking=tracking, + clicking=clicking, + on_saved=saved.append, + ) + widget.build(dpg_root) + profile = populated_profile_manager.list_profiles()[0] + widget.bind(profile) + # Mutate via the tracking widget callback. + tracking._on_speed(None, 4.2, None) + widget._save() + # Persisted state must reflect the change. + reloaded = populated_profile_manager.get_profile(str(profile.profile_id)) + assert reloaded.face_tracker_settings.speed == 4.2 + assert saved == [profile] + + def test_revert_restores_persisted_state( + self, dpg_root, populated_profile_manager + ): + tracking = TrackingSettingsWidget() + clicking = ClickingSettingsWidget() + widget = SettingsWidget( + profile_manager=populated_profile_manager, + tracking=tracking, + clicking=clicking, + ) + widget.build(dpg_root) + profile = populated_profile_manager.list_profiles()[0] + original_speed = profile.face_tracker_settings.speed + widget.bind(profile) + # Mutate without saving, then revert. + tracking._on_speed(None, 99.0, None) + assert profile.face_tracker_settings.speed == 99.0 + widget._revert() + assert profile.face_tracker_settings.speed == original_speed diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/tests/unit/__init__.py @@ -0,0 +1 @@ + diff --git a/tests/unit/test_controllers.py b/tests/unit/test_controllers.py new file mode 100644 index 0000000..76d3670 --- /dev/null +++ b/tests/unit/test_controllers.py @@ -0,0 +1,74 @@ +"""Unit tests for the abstract controllers in ``powermouse.domain.controllers``. + +The base classes deliberately raise ``NotImplementedError`` so concrete +adapters must implement them. The fakes in :mod:`tests.conftest` are the +test-side adapters used by the use cases. +""" +from __future__ import annotations + +import pytest + +from powermouse.domain.controllers.camera import CameraController +from powermouse.domain.controllers.devices import DeviceManager +from powermouse.domain.controllers.inference import InferenceController +from powermouse.domain.controllers.mouse import MouseController +from powermouse.domain.controllers.profile import ProfileManager +from powermouse.domain.models.mouse import MouseButton, MouseEvent, MouseEventType + + +class TestCameraControllerBase: + def test_methods_raise(self, camera): + controller = CameraController(camera) + with pytest.raises(NotImplementedError): + controller.update_frame() + with pytest.raises(NotImplementedError): + controller.start_stream() + with pytest.raises(NotImplementedError): + controller.stop_stream() + + +class TestInferenceControllerBase: + def test_process_frame_raises(self): + controller = InferenceController() + with pytest.raises(NotImplementedError): + controller.process_frame(frame_bgr=None, timestamp_ms=0) + with pytest.raises(NotImplementedError): + controller.get_cursor_position() + with pytest.raises(NotImplementedError): + controller.detect_gesture() + + +class TestMouseControllerBase: + def test_handle_event_raises(self): + with pytest.raises(NotImplementedError): + MouseController().handle_event( + MouseEvent(MouseButton.LEFT, 0, 0, MouseEventType.MOVE) + ) + + +class TestProfileManagerBase: + def test_methods_raise(self, sample_profile): + manager = ProfileManager() + with pytest.raises(NotImplementedError): + manager.create_profile(sample_profile) + with pytest.raises(NotImplementedError): + manager.list_profiles() + with pytest.raises(NotImplementedError): + manager.get_active_profile() + with pytest.raises(NotImplementedError): + manager.get_profile("1") + with pytest.raises(NotImplementedError): + manager.delete_profile(1) + with pytest.raises(NotImplementedError): + manager.update_profile(1, sample_profile) + + +class TestDeviceManagerBase: + def test_methods_raise(self): + manager = DeviceManager() + with pytest.raises(NotImplementedError): + manager.get_devices() + with pytest.raises(NotImplementedError): + manager._get_devices_linux() + with pytest.raises(NotImplementedError): + manager._get_devices_windows() diff --git a/tests/unit/test_gesture_mapping.py b/tests/unit/test_gesture_mapping.py new file mode 100644 index 0000000..2496da2 --- /dev/null +++ b/tests/unit/test_gesture_mapping.py @@ -0,0 +1,76 @@ +"""Unit tests for ``powermouse.domain.usecases.gesture_mapping``.""" +from __future__ import annotations + +from powermouse.domain.models.gesture import GestureEvent +from powermouse.domain.models.mouse import MouseButton, MouseEventType +from powermouse.domain.usecases.gesture_mapping import GestureToMouseTranslator + + +def _types(events): + return [(e.button, e.event_type) for e in events] + + +class TestGestureToMouseTranslator: + def test_left_blink_emits_left_click(self): + t = GestureToMouseTranslator() + events = t.translate(GestureEvent.LEFT_BLINK, (3, 4)) + assert _types(events) == [ + (MouseButton.LEFT, MouseEventType.BUTTON_DOWN), + (MouseButton.LEFT, MouseEventType.BUTTON_UP), + ] + assert all(e.x == 3 and e.y == 4 for e in events) + + def test_right_blink_emits_right_click(self): + t = GestureToMouseTranslator() + events = t.translate(GestureEvent.RIGHT_BLINK, (0, 0)) + assert _types(events) == [ + (MouseButton.RIGHT, MouseEventType.BUTTON_DOWN), + (MouseButton.RIGHT, MouseEventType.BUTTON_UP), + ] + + def test_left_squint_emits_double_click(self): + t = GestureToMouseTranslator() + events = t.translate(GestureEvent.LEFT_SQUINT, (0, 0)) + assert _types(events) == [ + (MouseButton.LEFT, MouseEventType.BUTTON_DOWN), + (MouseButton.LEFT, MouseEventType.BUTTON_UP), + (MouseButton.LEFT, MouseEventType.BUTTON_DOWN), + (MouseButton.LEFT, MouseEventType.BUTTON_UP), + ] + + def test_raised_eyebrows_emits_middle_click(self): + t = GestureToMouseTranslator() + events = t.translate(GestureEvent.RAISED_EYEBROWS, (0, 0)) + assert _types(events) == [ + (MouseButton.MIDDLE, MouseEventType.BUTTON_DOWN), + (MouseButton.MIDDLE, MouseEventType.BUTTON_UP), + ] + + def test_open_mouth_toggles_left_hold(self): + t = GestureToMouseTranslator() + first = t.translate(GestureEvent.OPEN_MOUTH, (1, 1)) + second = t.translate(GestureEvent.OPEN_MOUTH, (1, 1)) + assert _types(first) == [(MouseButton.LEFT, MouseEventType.BUTTON_DOWN)] + assert _types(second) == [(MouseButton.LEFT, MouseEventType.BUTTON_UP)] + + def test_right_squint_toggles_right_hold(self): + t = GestureToMouseTranslator() + first = t.translate(GestureEvent.RIGHT_SQUINT, (1, 1)) + second = t.translate(GestureEvent.RIGHT_SQUINT, (1, 1)) + assert _types(first) == [(MouseButton.RIGHT, MouseEventType.BUTTON_DOWN)] + assert _types(second) == [(MouseButton.RIGHT, MouseEventType.BUTTON_UP)] + + def test_reset_holds_releases_active_holds_only(self): + t = GestureToMouseTranslator() + # Engage both holds. + t.translate(GestureEvent.OPEN_MOUTH, (0, 0)) + t.translate(GestureEvent.RIGHT_SQUINT, (0, 0)) + events = t.reset_holds((5, 6)) + assert _types(events) == [ + (MouseButton.LEFT, MouseEventType.BUTTON_UP), + (MouseButton.RIGHT, MouseEventType.BUTTON_UP), + ] + # Cursor coordinates from reset_holds must match. + assert all(e.x == 5 and e.y == 6 for e in events) + # Calling reset_holds again with no active holds must produce nothing. + assert t.reset_holds((0, 0)) == [] diff --git a/tests/unit/test_models.py b/tests/unit/test_models.py new file mode 100644 index 0000000..6779fc6 --- /dev/null +++ b/tests/unit/test_models.py @@ -0,0 +1,105 @@ +"""Unit tests for ``powermouse.domain.models``.""" +from __future__ import annotations + +import numpy as np +import pytest + +from powermouse.domain.models.camera import Camera, FaceTrackerSettings +from powermouse.domain.models.gesture import GestureEvent, GestureEventListener +from powermouse.domain.models.mouse import ( + ClickInterface, + MouseButton, + MouseEvent, + MouseEventListener, + MouseEventType, +) +from powermouse.domain.models.profile import Profile + + +class TestCamera: + def test_update_frame_replaces_frame_and_dimensions(self, camera: Camera): + new_frame = np.zeros((100, 200, 3), dtype=np.uint8) + camera.update_frame(new_frame) + assert camera.current_frame is new_frame + assert camera.frame_width == 200 + assert camera.frame_height == 100 + + +class TestFaceTrackerSettings: + def test_defaults(self, face_tracker_settings: FaceTrackerSettings): + assert face_tracker_settings.speed == 1.0 + assert face_tracker_settings.acceleration == 1.5 + assert face_tracker_settings.sensitivity == (1.0, 1.0) + assert face_tracker_settings.smoothness == 0.5 + assert face_tracker_settings.deadzone_radius_px == 5 + assert face_tracker_settings.click_threshold_high == 0.6 + assert face_tracker_settings.click_threshold_low == 0.4 + + def test_update_mutates_attributes(self, face_tracker_settings: FaceTrackerSettings): + face_tracker_settings.update(speed=2.0, sensitivity=(2.0, 0.5)) + assert face_tracker_settings.speed == 2.0 + assert face_tracker_settings.sensitivity == (2.0, 0.5) + + def test_update_unknown_attribute_is_set(self, face_tracker_settings: FaceTrackerSettings): + # Documents current behaviour: ``update`` uses ``setattr`` blindly. + face_tracker_settings.update(extra="value") + assert getattr(face_tracker_settings, "extra") == "value" + + +class TestProfile: + def test_set_active_toggle(self, sample_profile: Profile): + sample_profile.set_active(False) + assert sample_profile.is_active is False + sample_profile.set_active(True) + assert sample_profile.is_active is True + + def test_toggle_click_interface_records_state(self, sample_profile: Profile): + sample_profile.toggle_click_interface(ClickInterface.DWELL, True) + assert sample_profile.is_click_interface_enabled(ClickInterface.DWELL) is True + + sample_profile.toggle_click_interface(ClickInterface.DWELL, False) + assert sample_profile.is_click_interface_enabled(ClickInterface.DWELL) is False + + def test_is_click_interface_defaults_false(self, sample_profile: Profile): + assert sample_profile.is_click_interface_enabled(ClickInterface.VOICE) is False + + +class TestMouseModels: + def test_mouse_button_str(self): + assert str(MouseButton.LEFT) == "left" + assert str(MouseButton.RIGHT) == "right" + assert str(MouseButton.MIDDLE) == "middle" + + def test_mouse_event_str_includes_button_and_position(self): + event = MouseEvent(MouseButton.RIGHT, 10, 20, MouseEventType.MOVE) + assert "right" in str(event) + assert "(10, 20)" in str(event) + + def test_mouse_event_listener_invokes_callback(self): + received: list[MouseEvent] = [] + listener = MouseEventListener(callback=received.append) + evt = MouseEvent(MouseButton.LEFT, 1, 2, MouseEventType.BUTTON_DOWN) + listener.on_event(evt) + assert received == [evt] + + +class TestGestureModels: + def test_gesture_event_listener_invokes_callback(self): + received: list[GestureEvent] = [] + listener = GestureEventListener(callback=received.append) + listener.on_event(GestureEvent.LEFT_BLINK) + assert received == [GestureEvent.LEFT_BLINK] + + @pytest.mark.parametrize( + "event,value", + [ + (GestureEvent.LEFT_BLINK, "left_blink"), + (GestureEvent.RIGHT_BLINK, "right_blink"), + (GestureEvent.LEFT_SQUINT, "left_squint"), + (GestureEvent.RIGHT_SQUINT, "right_squint"), + (GestureEvent.RAISED_EYEBROWS, "raised_eyebrows"), + (GestureEvent.OPEN_MOUTH, "open_mouth"), + ], + ) + def test_gesture_event_values(self, event: GestureEvent, value: str): + assert event.value == value diff --git a/tests/unit/test_track_face.py b/tests/unit/test_track_face.py new file mode 100644 index 0000000..afd1556 --- /dev/null +++ b/tests/unit/test_track_face.py @@ -0,0 +1,129 @@ +"""Unit tests for ``powermouse.domain.usecases.track_face``.""" +from __future__ import annotations + +from typing import List + +import pytest + +from powermouse.domain.models.gesture import GestureEvent +from powermouse.domain.models.mouse import MouseButton, MouseEvent, MouseEventType +from powermouse.domain.usecases import track_face +from powermouse.domain.usecases.gesture_mapping import GestureToMouseTranslator + + +@pytest.fixture +def sync_dispatch(monkeypatch): + """Replace ``track_face._dispatch`` with a synchronous call so tests can + assert on the recording mouse controller without sleeping for threads.""" + + def _direct(controller, event): + controller.handle_event(event) + + monkeypatch.setattr(track_face, "_dispatch", _direct) + return _direct + + +class TestTrackingStep: + def test_emits_move_event_each_frame( + self, + sync_dispatch, + fake_camera_controller, + fake_inference_controller, + recording_mouse_controller, + ): + translator = GestureToMouseTranslator() + captured_frames: List = [] + + def frame_processor(frame, ts): + captured_frames.append((frame, ts)) + + track_face.tracking_step( + camera_controller=fake_camera_controller, + inference_controller=fake_inference_controller, + mouse_controller=recording_mouse_controller, + gesture_translator=translator, + frame_processor=frame_processor, + ) + + # The camera should have been advanced. + assert fake_camera_controller.update_calls == 1 + # The inference controller should have been handed the frame. + assert len(fake_inference_controller.process_calls) == 1 + # Exactly one MOVE event should have been dispatched. + moves = [ + e for e in recording_mouse_controller.events if e.event_type is MouseEventType.MOVE + ] + assert len(moves) == 1 + assert (moves[0].x, moves[0].y) == fake_inference_controller.get_cursor_position() + # Frame processor must observe the same frame the camera produced. + assert captured_frames and captured_frames[0][0] is fake_camera_controller.camera.current_frame + + def test_drains_all_pending_gestures( + self, + sync_dispatch, + fake_camera_controller, + recording_mouse_controller, + ): + from tests.conftest import FakeInferenceController + + inference = FakeInferenceController( + cursor=(7, 9), + gestures=[GestureEvent.LEFT_BLINK, GestureEvent.RIGHT_BLINK], + ) + translator = GestureToMouseTranslator() + + track_face.tracking_step( + camera_controller=fake_camera_controller, + inference_controller=inference, + mouse_controller=recording_mouse_controller, + gesture_translator=translator, + frame_processor=lambda *_: None, + ) + + events = recording_mouse_controller.events + # 1 MOVE + 2 click pairs = 5 events. + assert len(events) == 5 + assert events[0].event_type is MouseEventType.MOVE + # First click pair: left. + assert events[1].button is MouseButton.LEFT + assert events[1].event_type is MouseEventType.BUTTON_DOWN + assert events[2].event_type is MouseEventType.BUTTON_UP + # Second click pair: right. + assert events[3].button is MouseButton.RIGHT + assert events[3].event_type is MouseEventType.BUTTON_DOWN + assert events[4].event_type is MouseEventType.BUTTON_UP + + +class TestUpdateCamera: + def test_stops_streams_and_persists_camera_swap( + self, + populated_profile_manager, + fake_camera_controller, + fake_inference_controller, + camera, + ): + from powermouse.domain.models.camera import Camera + import numpy as np + + new_cam = Camera( + name="Other", + id="1", + fps=15.0, + current_frame=np.zeros((4, 4, 3), dtype=np.uint8), + frame_width=4, + frame_height=4, + ) + + track_face.update_camera( + camera_controller=fake_camera_controller, + inference_controller=fake_inference_controller, + profile_manager=populated_profile_manager, + camera=new_cam, + ) + + assert fake_camera_controller.stop_calls == 1 + assert fake_inference_controller.stop_calls == 1 + active = populated_profile_manager.get_active_profile() + # The persisted profile should reference the new camera identity. + assert active.face_tracker_settings.camera.id == "1" + assert active.face_tracker_settings.camera.name == "Other" diff --git a/uv.lock b/uv.lock index e73dd97..d5cc9aa 100644 --- a/uv.lock +++ b/uv.lock @@ -1070,7 +1070,7 @@ wheels = [ [[package]] name = "powermouse" -version = "0.0.5" +version = "0.0.6" source = { editable = "." } dependencies = [ { name = "cv2-enumerate-cameras" },