Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
227 changes: 227 additions & 0 deletions tests/unit/test_compile_display_helpers.py
Original file line number Diff line number Diff line change
@@ -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
157 changes: 157 additions & 0 deletions tests/unit/test_list_command_extended.py
Original file line number Diff line number Diff line change
@@ -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
Loading