diff --git a/bec_widgets/utils/bec_plugin_manager/create/widget.py b/bec_widgets/utils/bec_plugin_manager/create/widget.py index befa0bb4..cfb5f8f7 100644 --- a/bec_widgets/utils/bec_plugin_manager/create/widget.py +++ b/bec_widgets/utils/bec_plugin_manager/create/widget.py @@ -22,7 +22,7 @@ def _commit_added_widget(repo: Path, name: str): logger.info(f"Committing new widget {name}") -def _widget_exists(widget_list: list[dict[str, str]], name: str): +def _widget_exists(widget_list: list[dict[str, str | bool]], name: str): return name in [w["name"] for w in widget_list] diff --git a/bec_widgets/utils/bec_plugin_manager/edit_ui.py b/bec_widgets/utils/bec_plugin_manager/edit_ui.py index a841eb89..384fb1ee 100644 --- a/bec_widgets/utils/bec_plugin_manager/edit_ui.py +++ b/bec_widgets/utils/bec_plugin_manager/edit_ui.py @@ -16,6 +16,7 @@ from watchdog.events import ( ) from watchdog.observers import Observer +from bec_widgets.utils.bec_designer import open_designer from bec_widgets.utils.bec_plugin_helper import get_all_plugin_widgets from bec_widgets.utils.plugin_utils import get_custom_classes @@ -57,6 +58,7 @@ class RecompileHandler(FileSystemEventHandler): if code == 0: logger.success("updating imports...") self._update_imports() + logger.success("done!") def _update_imports(self): with open(self.out_file, "r+") as f: @@ -82,13 +84,6 @@ class RecompileHandler(FileSystemEventHandler): def open_and_watch_ui_editor(widget_name: str): logger.info(f"Opening the editor for {widget_name}... ") - - try: - from bec_widgets.utils.bec_designer import open_designer - except ImportError: - logger.error("BEC Widgets must be installed to use the UI editor tool") - exit(127) - repo = Path(plugin_repo_path()) widget_dir = repo / repo.name / "bec_widgets" / "widgets" / widget_name ui_file = widget_dir / f"{widget_name}.ui" diff --git a/pyproject.toml b/pyproject.toml index b9f291d9..41e75f84 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,6 +39,7 @@ dev = [ "pytest~=8.0", "pytest-cov~=6.1.1", "watchdog~=6.0", + "pre_commit~=4.2", ] [project.urls] diff --git a/tests/unit_tests/test_plugin_creator.py b/tests/unit_tests/test_plugin_creator.py new file mode 100644 index 00000000..b2cf6852 --- /dev/null +++ b/tests/unit_tests/test_plugin_creator.py @@ -0,0 +1,252 @@ +import os +import subprocess +import time +from pathlib import Path +from time import sleep +from types import SimpleNamespace +from unittest.mock import MagicMock, call, patch + +import copier +import pytest +from bec_lib.utils.plugin_manager import main +from bec_lib.utils.plugin_manager._util import _goto_dir +from typer.testing import CliRunner + +from bec_widgets.utils.bec_plugin_manager.create.widget import _commit_added_widget, _widget_exists + +PLUGIN_REPO = "https://github.com/bec-project/plugin_copier_template.git" + +REPLACEMENT_UI_CONTENTS = """ + + testWidget6 + + + + 0 + 0 + 539 + 287 + + + + + + 30 + 0 + 361 + 125 + + + + + + + Waveform + QWidget +
waveform
+
+
+ + +
+""" + + +@pytest.fixture +def runner(): + return CliRunner() + + +def test_app_has_widget_commands(runner: CliRunner): + result = runner.invoke(main._app, ["create", "--help"]) + assert "widget" in result.output + + +def test_create_widget_takes_name(runner: CliRunner): + result = runner.invoke(main._app, ["create", "widget"]) + assert "Missing argument 'NAME'." in result.output + + +@patch("bec_widgets.utils.bec_plugin_manager.create.widget.logger") +@patch("bec_widgets.utils.bec_plugin_manager.create.widget.make_commit") +@patch("bec_widgets.utils.bec_plugin_manager.create.widget.git_stage_files") +def test_make_commit(stage: MagicMock, commit: MagicMock, logger: MagicMock): + repo = Path("test_path") + _commit_added_widget(repo, "test") + assert stage.call_count == 2 + stage.assert_has_calls( + [ + call(repo, [".copier-answers.yml"]), + call(repo / repo.name / "bec_widgets" / "widgets" / "test", []), + ] + ) + commit.assert_called_with(repo, "plugin-manager added new widget: test") + logger.info.assert_called_with("Committing new widget test") + + +def test_widget_exists_function(): + assert not _widget_exists([], "test_widget") + assert _widget_exists([{"name": "test_widget", "use_ui": True}], "test_widget") + + +def test_editor_cb(runner): + result = runner.invoke(main._app, ["create", "widget", "test", "--no-use-ui", "--open-editor"]) + assert result.exit_code == 2 + assert "Invalid value" in result.output + assert "Can only open" in result.output + + +class TestAddWidgetVariants: + @pytest.fixture(scope="class", autouse=True) + def setup_env(self, tmp_path_factory: pytest.TempPathFactory): + TestAddWidgetVariants._tmp_plugin_dir = tmp_path_factory.mktemp("test_plugin") + + @pytest.fixture(scope="function", autouse=True) + def cleanup_repo(self): + yield + subprocess.run(["git", "reset", "--hard"], stdout=subprocess.PIPE, stderr=subprocess.PIPE) + + @pytest.fixture(scope="class") + def git_repo(self): + project = TestAddWidgetVariants._tmp_plugin_dir / "test_plugin" + with _goto_dir(TestAddWidgetVariants._tmp_plugin_dir): + subprocess.run(["git", "clone", PLUGIN_REPO]) + os.makedirs(project) + with _goto_dir(project): + subprocess.run(["git", "init", "-b", "main"]) + subprocess.run(["git", "config", "user.email", "test"]) + subprocess.run(["git", "config", "user.name", "test"]) + copier.run_copy( + str(TestAddWidgetVariants._tmp_plugin_dir / "plugin_copier_template"), + str(project), + defaults=True, + data={ + "project_name": "test_plugin", + "widget_plugins_input": [{"name": "test_widget", "use_ui": True}], + }, + unsafe=True, + ) + yield project + + @patch("bec_widgets.utils.bec_plugin_manager.create.widget.plugin_repo_path") + def test_add_widget_with_ui(self, plugin_repo_path, runner: CliRunner, git_repo: Path): + plugin_repo_path.return_value = str(git_repo) + result = runner.invoke( + main._app, ["create", "widget", "test_widget_2", "--use-ui", "--no-open-editor"] + ) + assert result.exit_code == 0, result.output + widget_dir = git_repo / "test_plugin" / "bec_widgets" / "widgets" / "test_widget_2" + assert os.path.isdir(widget_dir) + assert os.path.isfile(widget_dir / "test_widget_2.py") + assert os.path.isfile(widget_dir / "test_widget_2.ui") + assert os.path.isfile(widget_dir / "test_widget_2_ui.py") + + @patch("bec_widgets.utils.bec_plugin_manager.create.widget.plugin_repo_path") + def test_add_widget_without_ui(self, plugin_repo_path, runner: CliRunner, git_repo: Path): + plugin_repo_path.return_value = str(git_repo) + result = runner.invoke( + main._app, ["create", "widget", "test_widget_3", "--no-use-ui", "--no-open-editor"] + ) + assert result.exit_code == 0, result.output + widget_dir = git_repo / "test_plugin" / "bec_widgets" / "widgets" / "test_widget_3" + assert os.path.isdir(widget_dir) + assert os.path.isfile(widget_dir / "test_widget_3.py") + assert not os.path.isfile(widget_dir / "test_widget_3.ui") + assert not os.path.isfile(widget_dir / "test_widget_3_ui.py") + + @patch("bec_widgets.utils.bec_plugin_manager.create.widget.logger") + @patch("bec_widgets.utils.bec_plugin_manager.create.widget.plugin_repo_path") + def test_no_add_widget_dupe_name( + self, plugin_repo_path, logger, runner: CliRunner, git_repo: Path + ): + plugin_repo_path.return_value = str(git_repo) + result = runner.invoke( + main._app, ["create", "widget", "test_widget", "--no-use-ui", "--no-open-editor"] + ) + assert result.exit_code == -1, result.output + assert "already exists!" in logger.error.mock_calls[0].args[0] + + @patch("bec_widgets.utils.bec_plugin_manager.create.widget.logger") + @patch("bec_widgets.utils.bec_plugin_manager.create.widget.plugin_repo_path") + def test_no_add_widget_bad_name( + self, plugin_repo_path, logger, runner: CliRunner, git_repo: Path + ): + plugin_repo_path.return_value = str(git_repo) + result = runner.invoke( + main._app, ["create", "widget", "12345", "--no-use-ui", "--no-open-editor"] + ) + assert result.exit_code == -1, result.output + assert "not a valid name for a widget" in logger.error.mock_calls[0].args[0] + + @patch("bec_widgets.utils.bec_plugin_manager.create.widget.copier") + @patch("bec_widgets.utils.bec_plugin_manager.create.widget.logger") + @patch("bec_widgets.utils.bec_plugin_manager.create.widget.plugin_repo_path") + def test_copier_error_logged( + self, plugin_repo_path, logger, copier, runner: CliRunner, git_repo: Path + ): + class CopierFailure(Exception): ... + + copier.run_update.side_effect = CopierFailure + plugin_repo_path.return_value = str(git_repo) + result = runner.invoke( + main._app, ["create", "widget", "test_widget_4", "--no-use-ui", "--no-open-editor"] + ) + assert result.exit_code == -1, result.output + assert "CopierFailure" in logger.error.mock_calls[0].args[0] + + @patch("bec_widgets.utils.bec_plugin_manager.create.widget.open_and_watch_ui_editor") + @patch("bec_widgets.utils.bec_plugin_manager.create.widget.plugin_repo_path") + def test_editor_opened_on_success( + self, plugin_repo_path, open_editor, runner: CliRunner, git_repo: Path + ): + plugin_repo_path.return_value = str(git_repo) + runner.invoke(main._app, ["create", "widget", "TeSt_wiDgeT_5", "--use-ui", "--open-editor"]) + open_editor.assert_called_with("test_widget_5") + + @patch("bec_widgets.utils.bec_plugin_manager.edit_ui.logger") + @patch("bec_widgets.utils.bec_plugin_manager.edit_ui.open_designer") + @patch("bec_widgets.utils.bec_plugin_manager.edit_ui.plugin_repo_path") + @patch("bec_widgets.utils.bec_plugin_manager.create.widget.plugin_repo_path") + def test_widget_editor_watcher( + self, + plugin_repo_path, + plugin_repo_path_2, + open_designer, + logger: MagicMock, + runner: CliRunner, + git_repo: Path, + ): + plugin_repo_path.return_value = str(git_repo) + plugin_repo_path_2.return_value = str(git_repo) + + widget_dir = git_repo / "test_plugin" / "bec_widgets" / "widgets" / "test_widget_6" + widget_ui_file = widget_dir / "test_widget_6.ui" + compiled_widget_ui_file = widget_dir / "test_widget_6_ui.py" + + test_collector = SimpleNamespace() + + def test_function(args: list[str]): + test_collector.ui_file = args[0] + with open(compiled_widget_ui_file) as f: + test_collector.initial_compiled_ui_contents = f.read() + with open(args[0], "w") as f: + f.write(REPLACEMENT_UI_CONTENTS) + start = time.monotonic() + while call("done!") not in logger.success.call_args_list: + time.sleep(0.05) + if time.monotonic() - start > 5: + raise TimeoutError("Waiting for recompilation timed out.") + with open(compiled_widget_ui_file) as f: + test_collector.final_compiled_ui_contents = f.read() + + open_designer.side_effect = test_function + result = runner.invoke( + main._app, ["create", "widget", "test_widget_6", "--use-ui", "--open-editor"] + ) + + assert result.exit_code == 0, result.output + assert test_collector.ui_file == str(widget_ui_file) + assert ( + test_collector.initial_compiled_ui_contents != test_collector.final_compiled_ui_contents + ) + assert "" in test_collector.final_compiled_ui_contents