From fe66fb44ad6ee80475debfe611bc48b8b7232443 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 3 Apr 2026 13:33:31 +0000 Subject: [PATCH 1/3] Initial plan From 09d902a463e79ee3923c5b3ae68709a602c61260 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 3 Apr 2026 13:41:39 +0000 Subject: [PATCH 2/3] Implement Generate Functionality: fix ProjectWorker config structure, add tests Agent-Logs-Url: https://github.com/DirektDSP/PluginConfiguratorApp/sessions/f55a91fa-1e9b-460c-be8b-fccf0d53ca9a Co-authored-by: SeamusMullan <43112447+SeamusMullan@users.noreply.github.com> --- src/core/project_worker.py | 61 ++++-- tests/test_project_worker.py | 393 +++++++++++++++++++++++++++++++++++ 2 files changed, 441 insertions(+), 13 deletions(-) create mode 100644 tests/test_project_worker.py diff --git a/src/core/project_worker.py b/src/core/project_worker.py index 9f7d277..bac86e0 100644 --- a/src/core/project_worker.py +++ b/src/core/project_worker.py @@ -13,6 +13,8 @@ update_workflow_files, ) +DEFAULT_TEMPLATE_URL = "https://github.com/SeamusMullan/PluginTemplate.git" + class ProjectWorker(QObject): """Worker class that handles project generation in a separate thread""" @@ -61,21 +63,41 @@ def run(self): self.finished.emit() except Exception as e: + self._cleanup_on_failure() self.error.emit(f"Project generation failed: {e!s}") self.finished.emit() + def _cleanup_on_failure(self) -> None: + """Remove a partially-created output directory after a generation failure.""" + output_dir = self.params.get("output_directory", "") + if output_dir and os.path.exists(output_dir): + try: + shutil.rmtree(output_dir, onerror=self.remove_readonly) + self.progress.emit("Cleaned up partial output directory") + except Exception: + pass + def clone_template_repo(self): """Clone the template repository""" + output_dir = self.params.get("output_directory", "") + if not output_dir: + raise RuntimeError("Output directory is not specified") + + if os.path.exists(output_dir): + raise RuntimeError(f"Output directory already exists: {output_dir}") + + fork_url = self.params.get("fork_url", "") or DEFAULT_TEMPLATE_URL + try: - self.progress.emit(f"Cloning template repository: {self.params['fork_url']}") + self.progress.emit(f"Cloning template repository: {fork_url}") self.progress_value.emit(5) subprocess.run( [ "git", "clone", - self.params["fork_url"], - self.params["output_directory"], + fork_url, + output_dir, ], check=True, capture_output=True, @@ -158,16 +180,18 @@ def fetch_optional_submodules(self): def prepare_project_variables(self): """Prepare variables for template substitution""" - # Read version from file if it exists - version_path = os.path.join(self.params["output_directory"], "VERSION") - if os.path.exists(version_path): - with open(version_path) as f: - version = f.read().strip() - else: - version = "0.0.1" - - # Generate unique plugin code - plugin_code = generate_plugin_id() + # Use version from params if provided; fall back to VERSION file or default. + version = self.params.get("version", "").strip() + if not version: + version_path = os.path.join(self.params["output_directory"], "VERSION") + if os.path.exists(version_path): + with open(version_path) as f: + version = f.read().strip() + else: + version = "0.0.1" + + # Use plugin code from params if provided, otherwise generate a unique one. + plugin_code = (self.params.get("plugin_code", "") or "").strip() or generate_plugin_id() # Format selection formats = [] @@ -179,6 +203,8 @@ def prepare_project_variables(self): formats.append("AU") if self.options.get("auv3", False): formats.append("AUv3") + if self.options.get("clap", False): + formats.append("CLAP") # Format string for CMake formats_string = f"FORMATS {' '.join(formats)}" @@ -297,6 +323,14 @@ def init_git_repo(self): text=True, ) + # Provide fallback git author info for CI or clean environments that + # have no global git user.name / user.email configured. + env = os.environ.copy() + env.setdefault("GIT_AUTHOR_NAME", "Plugin Configurator") + env.setdefault("GIT_AUTHOR_EMAIL", "noreply@direktdsp.com") + env.setdefault("GIT_COMMITTER_NAME", "Plugin Configurator") + env.setdefault("GIT_COMMITTER_EMAIL", "noreply@direktdsp.com") + # Commit subprocess.run( [ @@ -309,6 +343,7 @@ def init_git_repo(self): check=True, capture_output=True, text=True, + env=env, ) self.progress.emit("Git repository initialized successfully") diff --git a/tests/test_project_worker.py b/tests/test_project_worker.py new file mode 100644 index 0000000..6a1005c --- /dev/null +++ b/tests/test_project_worker.py @@ -0,0 +1,393 @@ +"""Tests for ProjectWorker - project generation logic.""" + +from __future__ import annotations + +import os +import sys +from unittest.mock import MagicMock, patch + +import pytest +from PySide6.QtWidgets import QApplication + +from core.project_worker import DEFAULT_TEMPLATE_URL, ProjectWorker + + +@pytest.fixture(scope="module") +def app(): + instance = QApplication.instance() + if not instance: + instance = QApplication(sys.argv) + yield instance + + +def _make_params( + *, + project_name: str = "TestPlugin", + product_name: str = "Test Plugin", + company_name: str = "TestCo", + bundle_id: str = "com.testco.testplugin", + manufacturer_code: str = "Ttco", + plugin_code: str = "TPlg", + version: str = "2.3.4", + output_directory: str = "/tmp/test_output", + fork_url: str = "https://example.com/template.git", + options: dict | None = None, +) -> dict: + return { + "project_name": project_name, + "product_name": product_name, + "company_name": company_name, + "bundle_id": bundle_id, + "manufacturer_code": manufacturer_code, + "plugin_code": plugin_code, + "version": version, + "output_directory": output_directory, + "fork_url": fork_url, + "options": options or {}, + } + + +# --------------------------------------------------------------------------- +# Instantiation +# --------------------------------------------------------------------------- + + +class TestProjectWorkerInit: + def test_params_stored(self, app): + params = _make_params() + worker = ProjectWorker(params) + assert worker.params is params + + def test_options_extracted(self, app): + params = _make_params(options={"vst3": True, "clap": True}) + worker = ProjectWorker(params) + assert worker.options == {"vst3": True, "clap": True} + + def test_options_default_to_empty_dict(self, app): + params = _make_params() + params.pop("options", None) + worker = ProjectWorker(params) + assert worker.options == {} + + +# --------------------------------------------------------------------------- +# prepare_project_variables - version +# --------------------------------------------------------------------------- + + +class TestPrepareProjectVariablesVersion: + def test_uses_version_from_params(self, app, tmp_path): + params = _make_params(version="3.1.4", output_directory=str(tmp_path)) + worker = ProjectWorker(params) + variables = worker.prepare_project_variables() + assert variables["VERSION"] == "3.1.4" + + def test_falls_back_to_version_file_when_params_version_empty(self, app, tmp_path): + (tmp_path / "VERSION").write_text("1.2.3") + params = _make_params(version="", output_directory=str(tmp_path)) + worker = ProjectWorker(params) + variables = worker.prepare_project_variables() + assert variables["VERSION"] == "1.2.3" + + def test_falls_back_to_default_when_no_params_version_and_no_file(self, app, tmp_path): + params = _make_params(version="", output_directory=str(tmp_path)) + worker = ProjectWorker(params) + variables = worker.prepare_project_variables() + assert variables["VERSION"] == "0.0.1" + + def test_params_version_takes_precedence_over_version_file(self, app, tmp_path): + (tmp_path / "VERSION").write_text("9.9.9") + params = _make_params(version="1.0.0", output_directory=str(tmp_path)) + worker = ProjectWorker(params) + variables = worker.prepare_project_variables() + assert variables["VERSION"] == "1.0.0" + + +# --------------------------------------------------------------------------- +# prepare_project_variables - plugin_code +# --------------------------------------------------------------------------- + + +class TestPrepareProjectVariablesPluginCode: + def test_uses_plugin_code_from_params(self, app, tmp_path): + params = _make_params(plugin_code="AbCd", output_directory=str(tmp_path)) + worker = ProjectWorker(params) + variables = worker.prepare_project_variables() + assert variables["PLUGIN_CODE"] == "AbCd" + + def test_generates_plugin_code_when_params_code_empty(self, app, tmp_path): + params = _make_params(plugin_code="", output_directory=str(tmp_path)) + worker = ProjectWorker(params) + variables = worker.prepare_project_variables() + code = variables["PLUGIN_CODE"] + assert len(code) == 4 + assert code[0].isupper() + + def test_generates_plugin_code_when_params_code_whitespace(self, app, tmp_path): + params = _make_params(plugin_code=" ", output_directory=str(tmp_path)) + worker = ProjectWorker(params) + variables = worker.prepare_project_variables() + code = variables["PLUGIN_CODE"] + assert len(code) == 4 + + +# --------------------------------------------------------------------------- +# prepare_project_variables - formats +# --------------------------------------------------------------------------- + + +class TestPrepareProjectVariablesFormats: + def _get_formats(self, options: dict, tmp_path) -> str: + params = _make_params(options=options, output_directory=str(tmp_path)) + worker = ProjectWorker(params) + return worker.prepare_project_variables()["FORMATS"] + + def test_vst3_included_by_default(self, app, tmp_path): + fmt = self._get_formats({"vst3": True}, tmp_path) + assert "VST3" in fmt + + def test_au_included_by_default(self, app, tmp_path): + fmt = self._get_formats({"au": True}, tmp_path) + assert "AU" in fmt + + def test_standalone_included_when_enabled(self, app, tmp_path): + fmt = self._get_formats({"standalone": True}, tmp_path) + assert "Standalone" in fmt + + def test_standalone_excluded_when_disabled(self, app, tmp_path): + fmt = self._get_formats({"standalone": False}, tmp_path) + assert "Standalone" not in fmt + + def test_clap_included_when_enabled(self, app, tmp_path): + fmt = self._get_formats({"clap": True}, tmp_path) + assert "CLAP" in fmt + + def test_clap_excluded_when_disabled(self, app, tmp_path): + fmt = self._get_formats({"clap": False}, tmp_path) + assert "CLAP" not in fmt + + def test_auv3_included_when_enabled(self, app, tmp_path): + fmt = self._get_formats({"auv3": True}, tmp_path) + assert "AUv3" in fmt + + def test_formats_string_starts_with_formats_keyword(self, app, tmp_path): + fmt = self._get_formats({"vst3": True}, tmp_path) + assert fmt.startswith("FORMATS") + + def test_multiple_formats_combined(self, app, tmp_path): + fmt = self._get_formats({"standalone": True, "vst3": True, "clap": True}, tmp_path) + assert "Standalone" in fmt + assert "VST3" in fmt + assert "CLAP" in fmt + + +# --------------------------------------------------------------------------- +# prepare_project_variables - other fields +# --------------------------------------------------------------------------- + + +class TestPrepareProjectVariablesFields: + def test_project_name_in_variables(self, app, tmp_path): + params = _make_params(project_name="MyPlugin", output_directory=str(tmp_path)) + variables = ProjectWorker(params).prepare_project_variables() + assert variables["PROJECT_NAME"] == "MyPlugin" + + def test_product_name_in_variables(self, app, tmp_path): + params = _make_params(product_name="My Plugin", output_directory=str(tmp_path)) + variables = ProjectWorker(params).prepare_project_variables() + assert variables["PRODUCT_NAME"] == "My Plugin" + + def test_company_name_in_variables(self, app, tmp_path): + params = _make_params(company_name="TestCorp", output_directory=str(tmp_path)) + variables = ProjectWorker(params).prepare_project_variables() + assert variables["COMPANY_NAME"] == "TestCorp" + + def test_bundle_id_in_variables(self, app, tmp_path): + params = _make_params(bundle_id="com.tc.plug", output_directory=str(tmp_path)) + variables = ProjectWorker(params).prepare_project_variables() + assert variables["BUNDLE_ID"] == "com.tc.plug" + + def test_manufacturer_code_in_variables(self, app, tmp_path): + params = _make_params(manufacturer_code="TcCo", output_directory=str(tmp_path)) + variables = ProjectWorker(params).prepare_project_variables() + assert variables["MANUFACTURER_CODE"] == "TcCo" + + def test_readme_plugin_name_matches_product_name(self, app, tmp_path): + params = _make_params(product_name="Awesome FX", output_directory=str(tmp_path)) + variables = ProjectWorker(params).prepare_project_variables() + assert variables["plugin_name"] == "Awesome FX" + + +# --------------------------------------------------------------------------- +# clone_template_repo - validation +# --------------------------------------------------------------------------- + + +class TestCloneTemplateRepoValidation: + def test_raises_when_output_directory_empty(self, app): + params = _make_params(output_directory="") + worker = ProjectWorker(params) + with pytest.raises(RuntimeError, match="Output directory is not specified"): + worker.clone_template_repo() + + def test_raises_when_output_directory_already_exists(self, app, tmp_path): + existing = tmp_path / "already_exists" + existing.mkdir() + params = _make_params(output_directory=str(existing)) + worker = ProjectWorker(params) + with pytest.raises(RuntimeError, match="already exists"): + worker.clone_template_repo() + + def test_uses_default_url_when_fork_url_empty(self, app, tmp_path): + target = str(tmp_path / "new_project") + params = _make_params(fork_url="", output_directory=target) + worker = ProjectWorker(params) + + with patch("subprocess.run") as mock_run: + mock_run.return_value = MagicMock(returncode=0) + worker.clone_template_repo() + + clone_call = mock_run.call_args_list[0] + cmd = clone_call[0][0] + assert DEFAULT_TEMPLATE_URL in cmd + + def test_uses_provided_url_when_set(self, app, tmp_path): + target = str(tmp_path / "new_project") + custom_url = "https://custom.example.com/template.git" + params = _make_params(fork_url=custom_url, output_directory=target) + worker = ProjectWorker(params) + + with patch("subprocess.run") as mock_run: + mock_run.return_value = MagicMock(returncode=0) + worker.clone_template_repo() + + clone_call = mock_run.call_args_list[0] + cmd = clone_call[0][0] + assert custom_url in cmd + + +# --------------------------------------------------------------------------- +# _cleanup_on_failure +# --------------------------------------------------------------------------- + + +class TestCleanupOnFailure: + def test_removes_existing_output_directory(self, app, tmp_path): + output = tmp_path / "partial_project" + output.mkdir() + (output / "some_file.txt").write_text("data") + + params = _make_params(output_directory=str(output)) + worker = ProjectWorker(params) + worker._cleanup_on_failure() + + assert not output.exists() + + def test_does_nothing_when_output_directory_absent(self, app, tmp_path): + non_existent = str(tmp_path / "does_not_exist") + params = _make_params(output_directory=non_existent) + worker = ProjectWorker(params) + # Should not raise + worker._cleanup_on_failure() + + def test_does_nothing_when_output_directory_is_empty_string(self, app): + params = _make_params(output_directory="") + worker = ProjectWorker(params) + # Should not raise + worker._cleanup_on_failure() + + +# --------------------------------------------------------------------------- +# run() - cleanup on failure +# --------------------------------------------------------------------------- + + +class TestRunCleansUpOnFailure: + def test_cleanup_called_on_exception(self, app, tmp_path): + output = str(tmp_path / "project") + params = _make_params(output_directory=output) + worker = ProjectWorker(params) + + with patch.object( + worker, "clone_template_repo", side_effect=RuntimeError("boom") + ), patch.object(worker, "_cleanup_on_failure") as mock_cleanup: + worker.run() + mock_cleanup.assert_called_once() + + def test_error_signal_emitted_on_exception(self, app, tmp_path): + output = str(tmp_path / "project") + params = _make_params(output_directory=output) + worker = ProjectWorker(params) + + errors: list[str] = [] + worker.error.connect(errors.append) + + with patch.object( + worker, "clone_template_repo", side_effect=RuntimeError("kaboom") + ), patch.object(worker, "_cleanup_on_failure"): + worker.run() + + assert len(errors) == 1 + assert "kaboom" in errors[0] + + def test_finished_signal_emitted_even_on_exception(self, app, tmp_path): + output = str(tmp_path / "project") + params = _make_params(output_directory=output) + worker = ProjectWorker(params) + + finished: list[bool] = [] + worker.finished.connect(lambda: finished.append(True)) + + with patch.object( + worker, "clone_template_repo", side_effect=RuntimeError("fail") + ), patch.object(worker, "_cleanup_on_failure"): + worker.run() + + assert finished + + +# --------------------------------------------------------------------------- +# init_git_repo - author env vars +# --------------------------------------------------------------------------- + + +class TestInitGitRepoEnv: + def test_git_commit_uses_env_with_author_info(self, app, tmp_path): + output = str(tmp_path / "project") + os.makedirs(output) + params = _make_params(output_directory=output, options={"create_git_repo": True}) + worker = ProjectWorker(params) + + with patch("subprocess.run") as mock_run: + mock_run.return_value = MagicMock(returncode=0) + worker.init_git_repo() + + # The commit call is the third subprocess.run call (init, add, commit) + commit_call = mock_run.call_args_list[2] + env_arg = commit_call[1].get("env") or commit_call.kwargs.get("env") + assert env_arg is not None + assert "GIT_AUTHOR_NAME" in env_arg + assert "GIT_COMMITTER_NAME" in env_arg + + def test_init_git_repo_skipped_when_option_false(self, app, tmp_path): + output = str(tmp_path / "project") + os.makedirs(output) + params = _make_params(output_directory=output, options={"create_git_repo": False}) + worker = ProjectWorker(params) + + with patch("subprocess.run") as mock_run: + worker.init_git_repo() + mock_run.assert_not_called() + + +# --------------------------------------------------------------------------- +# DEFAULT_TEMPLATE_URL constant +# --------------------------------------------------------------------------- + + +class TestDefaultTemplateUrl: + def test_constant_is_a_valid_url(self): + assert DEFAULT_TEMPLATE_URL.startswith("https://") + + def test_constant_ends_with_git(self): + assert DEFAULT_TEMPLATE_URL.endswith(".git") From 78cc51eaefa74d9ec85f5174d03b42bb0de21ae9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 3 Apr 2026 13:42:59 +0000 Subject: [PATCH 3/3] Address code review: rename test and simplify env_arg extraction Agent-Logs-Url: https://github.com/DirektDSP/PluginConfiguratorApp/sessions/f55a91fa-1e9b-460c-be8b-fccf0d53ca9a Co-authored-by: SeamusMullan <43112447+SeamusMullan@users.noreply.github.com> --- tests/test_project_worker.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_project_worker.py b/tests/test_project_worker.py index 6a1005c..b97eea2 100644 --- a/tests/test_project_worker.py +++ b/tests/test_project_worker.py @@ -364,7 +364,7 @@ def test_git_commit_uses_env_with_author_info(self, app, tmp_path): # The commit call is the third subprocess.run call (init, add, commit) commit_call = mock_run.call_args_list[2] - env_arg = commit_call[1].get("env") or commit_call.kwargs.get("env") + env_arg = commit_call.kwargs["env"] assert env_arg is not None assert "GIT_AUTHOR_NAME" in env_arg assert "GIT_COMMITTER_NAME" in env_arg @@ -386,7 +386,7 @@ def test_init_git_repo_skipped_when_option_false(self, app, tmp_path): class TestDefaultTemplateUrl: - def test_constant_is_a_valid_url(self): + def test_constant_starts_with_https(self): assert DEFAULT_TEMPLATE_URL.startswith("https://") def test_constant_ends_with_git(self):