From f7bb08a9ee5f823dacb0e5338078d30583655dac Mon Sep 17 00:00:00 2001 From: Test Improver Date: Mon, 30 Mar 2026 01:12:58 +0000 Subject: [PATCH] test: extend list command and compile display helper coverage (0->33 tests) - Add 13 tests for apm list command covering all branches: empty scripts, scripts with/without start key, Rich path, Rich exception fallback, and error handling - Add 20 tests for compile CLI display helpers: _get_validation_suggestion, _display_validation_errors, _display_next_steps, and _display_single_file_summary (both Rich and fallback paths) Total: +33 tests, 3108->3141 passing Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tests/unit/test_compile_display_helpers.py | 227 +++++++++++++++++++++ tests/unit/test_list_command_extended.py | 157 ++++++++++++++ 2 files changed, 384 insertions(+) create mode 100644 tests/unit/test_compile_display_helpers.py create mode 100644 tests/unit/test_list_command_extended.py diff --git a/tests/unit/test_compile_display_helpers.py b/tests/unit/test_compile_display_helpers.py new file mode 100644 index 00000000..c0694faf --- /dev/null +++ b/tests/unit/test_compile_display_helpers.py @@ -0,0 +1,227 @@ +"""Tests for compile CLI display helper functions.""" + +import io +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +from apm_cli.commands.compile.cli import ( + _display_next_steps, + _display_single_file_summary, + _display_validation_errors, + _get_validation_suggestion, +) + + +# --------------------------------------------------------------------------- +# _get_validation_suggestion +# --------------------------------------------------------------------------- + + +class TestGetValidationSuggestion: + """Tests for _get_validation_suggestion().""" + + def test_missing_description(self): + result = _get_validation_suggestion("Missing 'description' field") + assert "description:" in result + + def test_missing_apply_to(self): + result = _get_validation_suggestion("Missing 'applyTo' in frontmatter") + assert "applyTo" in result + + def test_empty_content(self): + result = _get_validation_suggestion("Empty content in file") + assert "markdown content" in result.lower() or "content" in result.lower() + + def test_unknown_error_returns_fallback(self): + result = _get_validation_suggestion("Some unknown error type") + assert result # must be non-empty + assert "Check primitive" in result or "frontmatter" in result + + def test_all_returns_are_strings(self): + for msg in [ + "Missing 'description'", + "Missing 'applyTo'", + "Empty content", + "Unknown error", + ]: + assert isinstance(_get_validation_suggestion(msg), str) + + +# --------------------------------------------------------------------------- +# _display_validation_errors +# --------------------------------------------------------------------------- + + +class TestDisplayValidationErrors: + """Tests for _display_validation_errors().""" + + def test_fallback_shows_each_error(self, capsys): + errors = ["file.md: Missing 'description'", "other.md: Empty content"] + with patch( + "apm_cli.commands.compile.cli._get_console", return_value=None + ): + _display_validation_errors(errors) + captured = capsys.readouterr() + out = captured.out + captured.err + # Each error should be rendered (click.echo writes to stdout) + assert "file.md" in out or "Missing" in out + + def test_fallback_empty_errors_no_crash(self, capsys): + with patch( + "apm_cli.commands.compile.cli._get_console", return_value=None + ): + _display_validation_errors([]) # should not raise + + def test_rich_path_calls_console_print(self): + mock_console = MagicMock() + errors = ["file.md: Missing 'description'"] + with patch( + "apm_cli.commands.compile.cli._get_console", return_value=mock_console + ): + _display_validation_errors(errors) + assert mock_console.print.called + + def test_error_without_colon_handled(self, capsys): + """Errors without ':' separator should not crash.""" + with patch( + "apm_cli.commands.compile.cli._get_console", return_value=None + ): + _display_validation_errors(["justaplainerrormessage"]) + + def test_error_with_colon_splits_file_and_message(self): + """Errors with ':' should split into file/message for the Rich table.""" + mock_console = MagicMock() + errors = ["src/foo.md: Missing 'description' field"] + with patch( + "apm_cli.commands.compile.cli._get_console", return_value=mock_console + ): + _display_validation_errors(errors) + # Verify table was added with a row (the first add_row call) + # We can't inspect the Rich Table directly, but we verify print was called + assert mock_console.print.called + + +# --------------------------------------------------------------------------- +# _display_next_steps +# --------------------------------------------------------------------------- + + +class TestDisplayNextSteps: + """Tests for _display_next_steps().""" + + def test_fallback_shows_review_generated_file(self, capsys): + with patch( + "apm_cli.commands.compile.cli._get_console", return_value=None + ): + _display_next_steps("AGENTS.md") + captured = capsys.readouterr() + out = captured.out + captured.err + assert "AGENTS.md" in out + + def test_fallback_shows_install_step(self, capsys): + with patch( + "apm_cli.commands.compile.cli._get_console", return_value=None + ): + _display_next_steps("AGENTS.md") + captured = capsys.readouterr() + out = captured.out + captured.err + assert "apm install" in out + + def test_rich_path_calls_console_print(self): + mock_console = MagicMock() + with patch( + "apm_cli.commands.compile.cli._get_console", return_value=mock_console + ): + _display_next_steps("AGENTS.md") + assert mock_console.print.called + + def test_output_filename_included_in_next_step(self, capsys): + with patch( + "apm_cli.commands.compile.cli._get_console", return_value=None + ): + _display_next_steps("CLAUDE.md") + captured = capsys.readouterr() + out = captured.out + captured.err + assert "CLAUDE.md" in out + + +# --------------------------------------------------------------------------- +# _display_single_file_summary +# --------------------------------------------------------------------------- + + +class TestDisplaySingleFileSummary: + """Tests for _display_single_file_summary().""" + + def _make_output_path(self, tmp_path, name="AGENTS.md"): + p = tmp_path / name + p.write_text("content") + return p + + def test_fallback_shows_primitives_count(self, capsys, tmp_path): + stats = {"primitives_found": 5, "instructions": 3, "contexts": 2, "chatmodes": 0} + output_path = self._make_output_path(tmp_path) + with patch( + "apm_cli.commands.compile.cli._get_console", return_value=None + ): + _display_single_file_summary(stats, "unchanged", "abc123", output_path, False) + captured = capsys.readouterr() + out = captured.out + captured.err + assert "5" in out + assert "instructions" in out + + def test_fallback_shows_constitution_hash(self, capsys, tmp_path): + stats = {"primitives_found": 1, "instructions": 1, "contexts": 0, "chatmodes": 0} + output_path = self._make_output_path(tmp_path) + with patch( + "apm_cli.commands.compile.cli._get_console", return_value=None + ): + _display_single_file_summary(stats, "unchanged", "myhash", output_path, False) + captured = capsys.readouterr() + out = captured.out + captured.err + assert "myhash" in out + + def test_fallback_no_hash_shows_dash(self, capsys, tmp_path): + stats = {"primitives_found": 0, "instructions": 0, "contexts": 0, "chatmodes": 0} + output_path = self._make_output_path(tmp_path) + with patch( + "apm_cli.commands.compile.cli._get_console", return_value=None + ): + _display_single_file_summary(stats, "unchanged", None, output_path, False) + captured = capsys.readouterr() + out = captured.out + captured.err + assert "hash=-" in out or "hash= -" in out or "-" in out + + def test_rich_path_calls_console_print(self, tmp_path): + mock_console = MagicMock() + stats = {"primitives_found": 2, "instructions": 2, "contexts": 0, "chatmodes": 0} + output_path = self._make_output_path(tmp_path) + with patch( + "apm_cli.commands.compile.cli._get_console", return_value=mock_console + ): + _display_single_file_summary(stats, "unchanged", "h1", output_path, False) + assert mock_console.print.called + + def test_dry_run_shows_preview_size(self, tmp_path): + mock_console = MagicMock() + stats = {"primitives_found": 1, "instructions": 1, "contexts": 0, "chatmodes": 0} + output_path = self._make_output_path(tmp_path) + # dry_run=True => file_size=0 => "Preview" + with patch( + "apm_cli.commands.compile.cli._get_console", return_value=mock_console + ): + _display_single_file_summary(stats, "new", "h2", output_path, dry_run=True) + assert mock_console.print.called + + def test_empty_stats_uses_zero_defaults(self, capsys, tmp_path): + """Empty stats dict should not crash - defaults to 0.""" + output_path = self._make_output_path(tmp_path) + with patch( + "apm_cli.commands.compile.cli._get_console", return_value=None + ): + _display_single_file_summary({}, "new", None, output_path, False) + captured = capsys.readouterr() + out = captured.out + captured.err + assert "0 primitives" in out diff --git a/tests/unit/test_list_command_extended.py b/tests/unit/test_list_command_extended.py new file mode 100644 index 00000000..3782db07 --- /dev/null +++ b/tests/unit/test_list_command_extended.py @@ -0,0 +1,157 @@ +"""Extended tests for the apm list command covering all branches.""" + +import sys +from unittest.mock import MagicMock, patch + +import pytest +from click.testing import CliRunner + +from apm_cli.commands.list_cmd import list as list_command + + +class TestListCommandNoScripts: + """Tests for the list command when no scripts are found.""" + + def setup_method(self): + self.runner = CliRunner() + + def test_no_scripts_exits_zero(self): + """Empty scripts dict should produce exit code 0.""" + with patch("apm_cli.commands.list_cmd._list_available_scripts", return_value={}): + result = self.runner.invoke(list_command, obj={}) + assert result.exit_code == 0 + + def test_no_scripts_shows_warning(self): + """Empty scripts dict should show 'No scripts found' warning.""" + with patch("apm_cli.commands.list_cmd._list_available_scripts", return_value={}): + result = self.runner.invoke(list_command, obj={}) + assert "No scripts found" in result.output + + def test_no_scripts_fallback_shows_example(self): + """Fallback (no Rich) path for empty scripts shows apm.yml example.""" + with patch( + "apm_cli.commands.list_cmd._list_available_scripts", return_value={} + ), patch("apm_cli.commands.list_cmd._rich_panel", side_effect=ImportError): + result = self.runner.invoke(list_command, obj={}) + assert result.exit_code == 0 + assert "scripts:" in result.output + + +class TestListCommandWithScripts: + """Tests for the list command when scripts are available.""" + + def setup_method(self): + self.runner = CliRunner() + + def test_scripts_without_start_exits_zero(self): + """Scripts without 'start' key should list successfully.""" + with patch( + "apm_cli.commands.list_cmd._list_available_scripts", + return_value={"lint": "ruff check ."}, + ), patch("apm_cli.commands.list_cmd._get_console", return_value=None): + result = self.runner.invoke(list_command, obj={}) + assert result.exit_code == 0 + + def test_scripts_without_start_no_default_label(self): + """Without 'start' key, the default script label should not appear.""" + with patch( + "apm_cli.commands.list_cmd._list_available_scripts", + return_value={"lint": "ruff check ."}, + ), patch("apm_cli.commands.list_cmd._get_console", return_value=None): + result = self.runner.invoke(list_command, obj={}) + assert "default script" not in result.output + + def test_scripts_with_start_shows_default_label(self): + """With 'start' key, the default script label should appear.""" + with patch( + "apm_cli.commands.list_cmd._list_available_scripts", + return_value={"start": "python main.py", "lint": "ruff check ."}, + ), patch("apm_cli.commands.list_cmd._get_console", return_value=None): + result = self.runner.invoke(list_command, obj={}) + assert result.exit_code == 0 + assert "default script" in result.output + + def test_scripts_fallback_renders_all_scripts(self): + """Fallback path should render all script names.""" + scripts = {"start": "python main.py", "test": "pytest", "lint": "ruff ."} + with patch( + "apm_cli.commands.list_cmd._list_available_scripts", + return_value=scripts, + ), patch("apm_cli.commands.list_cmd._get_console", return_value=None): + result = self.runner.invoke(list_command, obj={}) + assert result.exit_code == 0 + for name in scripts: + assert name in result.output + + def test_scripts_fallback_renders_commands(self): + """Fallback path should render script commands.""" + with patch( + "apm_cli.commands.list_cmd._list_available_scripts", + return_value={"start": "python main.py"}, + ), patch("apm_cli.commands.list_cmd._get_console", return_value=None): + result = self.runner.invoke(list_command, obj={}) + assert "python main.py" in result.output + + +class TestListCommandRichPath: + """Tests for the list command with a mocked Rich console.""" + + def setup_method(self): + self.runner = CliRunner() + + def test_rich_path_exits_zero(self): + """With a Rich console, command should succeed.""" + mock_console = MagicMock() + with patch( + "apm_cli.commands.list_cmd._list_available_scripts", + return_value={"start": "python main.py"}, + ), patch("apm_cli.commands.list_cmd._get_console", return_value=mock_console): + result = self.runner.invoke(list_command, obj={}) + assert result.exit_code == 0 + + def test_rich_console_print_called(self): + """With a Rich console, console.print should be called.""" + mock_console = MagicMock() + with patch( + "apm_cli.commands.list_cmd._list_available_scripts", + return_value={"start": "python main.py"}, + ), patch("apm_cli.commands.list_cmd._get_console", return_value=mock_console): + self.runner.invoke(list_command, obj={}) + assert mock_console.print.called + + def test_rich_exception_falls_back_to_text(self): + """If Rich table raises, command falls back to plain text output.""" + mock_console = MagicMock() + mock_console.print.side_effect = Exception("Rich failure") + with patch( + "apm_cli.commands.list_cmd._list_available_scripts", + return_value={"start": "python main.py"}, + ), patch("apm_cli.commands.list_cmd._get_console", return_value=mock_console): + result = self.runner.invoke(list_command, obj={}) + assert result.exit_code == 0 + assert "Available scripts:" in result.output + + +class TestListCommandErrorHandling: + """Tests for error handling in the list command.""" + + def setup_method(self): + self.runner = CliRunner() + + def test_exception_in_list_scripts_exits_one(self): + """If _list_available_scripts raises, command should exit with code 1.""" + with patch( + "apm_cli.commands.list_cmd._list_available_scripts", + side_effect=RuntimeError("disk read error"), + ): + result = self.runner.invoke(list_command, obj={}) + assert result.exit_code == 1 + + def test_exception_shows_error_message(self): + """If _list_available_scripts raises, error message should be shown.""" + with patch( + "apm_cli.commands.list_cmd._list_available_scripts", + side_effect=RuntimeError("disk read error"), + ): + result = self.runner.invoke(list_command, obj={}) + assert "Error listing scripts" in result.output