"""Unit tests for the distill CLI command.
Tests cover all major functionality including:
- Basic distillation with various options
- File filtering (include/exclude)
- Output formats (markdown, json, xml)
- Token limits and model targeting
- Session management
- Content transformation flags
- Error handling
- Clipboard functionality
- Cost estimation
"""
import json
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
import typer
from typer.testing import CliRunner
from tenets.cli.commands.distill import distill
@pytest.fixture
def runner():
"""Create a CLI test runner."""
return CliRunner()
@pytest.fixture
def mock_tenets():
"""Create a mock Tenets instance with all required methods."""
mock = MagicMock()
# Mock distill result
mock_result = MagicMock()
mock_result.context = "# Test Context\n\nThis is test content"
mock_result.token_count = 1500
mock_result.metadata = {
"files_included": 5,
"files_analyzed": 10,
"mode": "balanced",
"full_mode": False,
"condense": False,
"remove_comments": False,
"analysis_time": "2.5",
}
mock_result.to_dict.return_value = {
"context": mock_result.context,
"token_count": mock_result.token_count,
"metadata": mock_result.metadata,
}
mock.distill.return_value = mock_result
mock.estimate_cost.return_value = {
"input_tokens": 1500,
"output_tokens": 500,
"input_cost": 0.002,
"output_cost": 0.001,
"total_cost": 0.003,
}
# Mock config for clipboard feature
mock.config = MagicMock()
mock.config.output.copy_on_distill = False
return mock
@pytest.fixture
def mock_context():
"""Create a mock typer context."""
ctx = MagicMock()
ctx.obj = {"verbose": False, "quiet": False, "silent": False}
return ctx
class TestDistillCommand:
"""Test suite for the distill command."""
def test_basic_distill(self, runner, mock_tenets):
"""Test basic distillation with minimal arguments."""
with patch("tenets.cli.commands.distill.Tenets", return_value=mock_tenets):
app = typer.Typer()
app.command()(distill)
result = runner.invoke(app, ["implement OAuth2", "."])
# Verify command executed successfully
assert result.exit_code == 0
assert "Test Context" in result.stdout
assert "This is test content" in result.stdout
# Verify Tenets.distill was called with correct args
mock_tenets.distill.assert_called_once()
call_args = mock_tenets.distill.call_args[1]
assert call_args["prompt"] == "implement OAuth2"
assert call_args["files"] == Path()
def test_distill_with_output_file(self, runner, mock_tenets, tmp_path):
"""Test distillation with output to file."""
output_file = tmp_path / "context.md"
with patch("tenets.cli.commands.distill.Tenets", return_value=mock_tenets):
app = typer.Typer()
app.command()(distill)
result = runner.invoke(app, ["fix payment bug", ".", "--output", str(output_file)])
assert result.exit_code == 0
assert "Context saved to" in result.stdout
# Verify file was written
assert output_file.exists()
content = output_file.read_text()
assert "Test Context" in content
def test_distill_with_json_format(self, runner, mock_tenets):
"""Test distillation with JSON output format."""
with patch("tenets.cli.commands.distill.Tenets", return_value=mock_tenets):
app = typer.Typer()
app.command()(distill)
result = runner.invoke(app, ["analyze security", ".", "--format", "json"])
assert result.exit_code == 0
# Check that JSON was output
output_data = json.loads(result.stdout)
assert "context" in output_data
assert "token_count" in output_data
def test_distill_with_file_filters(self, runner, mock_tenets):
"""Test distillation with include/exclude patterns."""
with patch("tenets.cli.commands.distill.Tenets", return_value=mock_tenets):
app = typer.Typer()
app.command()(distill)
result = runner.invoke(
app, ["review API", ".", "--include", "*.py,*.js", "--exclude", "test_*,*.backup"]
)
assert result.exit_code == 0
# Verify filters were passed correctly
call_args = mock_tenets.distill.call_args[1]
assert call_args["include_patterns"] == ["*.py", "*.js"]
assert call_args["exclude_patterns"] == ["test_*", "*.backup"]
def test_distill_with_mode_options(self, runner, mock_tenets):
"""Test different analysis modes."""
modes = ["fast", "balanced", "thorough"]
for mode in modes:
with patch("tenets.cli.commands.distill.Tenets", return_value=mock_tenets):
app = typer.Typer()
app.command()(distill)
result = runner.invoke(app, ["test prompt", ".", "--mode", mode])
assert result.exit_code == 0
call_args = mock_tenets.distill.call_args[1]
assert call_args["mode"] == mode
def test_distill_with_token_limits(self, runner, mock_tenets):
"""Test token limit configuration."""
with patch("tenets.cli.commands.distill.Tenets", return_value=mock_tenets):
app = typer.Typer()
app.command()(distill)
result = runner.invoke(
app, ["summarize codebase", ".", "--max-tokens", "50000", "--model", "gpt-4o"]
)
assert result.exit_code == 0
call_args = mock_tenets.distill.call_args[1]
assert call_args["max_tokens"] == 50000
assert call_args["model"] == "gpt-4o"
def test_distill_with_timeout_flag(self, runner, mock_tenets):
"""Test timeout flag overrides config value."""
mock_tenets.config.distill_timeout = 33
with patch("tenets.cli.commands.distill.Tenets", return_value=mock_tenets):
app = typer.Typer()
app.command()(distill)
result = runner.invoke(app, ["task", ".", "--timeout", "5"])
assert result.exit_code == 0
call_args = mock_tenets.distill.call_args[1]
assert call_args["timeout"] == 5
def test_distill_timeout_uses_config_default(self, runner, mock_tenets):
"""Test timeout falls back to config when not provided."""
mock_tenets.config.distill_timeout = 42
with patch("tenets.cli.commands.distill.Tenets", return_value=mock_tenets):
app = typer.Typer()
app.command()(distill)
result = runner.invoke(app, ["task", "."])
assert result.exit_code == 0
call_args = mock_tenets.distill.call_args[1]
assert call_args["timeout"] == 42
def test_distill_with_session(self, runner, mock_tenets):
"""Test distillation with session context."""
with patch("tenets.cli.commands.distill.Tenets", return_value=mock_tenets):
app = typer.Typer()
app.command()(distill)
result = runner.invoke(app, ["continue feature", ".", "--session", "oauth-feature"])
assert result.exit_code == 0
call_args = mock_tenets.distill.call_args[1]
assert call_args["session_name"] == "oauth-feature"
def test_distill_with_content_transforms(self, runner, mock_tenets):
"""Test content transformation flags."""
with patch("tenets.cli.commands.distill.Tenets", return_value=mock_tenets):
app = typer.Typer()
app.command()(distill)
result = runner.invoke(
app, ["optimize code", ".", "--full", "--remove-comments", "--condense"]
)
assert result.exit_code == 0
call_args = mock_tenets.distill.call_args[1]
assert call_args["full"] is True
assert call_args["remove_comments"] is True
assert call_args["condense"] is True
def test_distill_with_git_disabled(self, runner, mock_tenets):
"""Test disabling git integration."""
with patch("tenets.cli.commands.distill.Tenets", return_value=mock_tenets):
app = typer.Typer()
app.command()(distill)
result = runner.invoke(app, ["analyze", ".", "--no-git"])
assert result.exit_code == 0
call_args = mock_tenets.distill.call_args[1]
assert call_args["include_git"] is False
def test_distill_with_cost_estimation(self, runner, mock_tenets):
"""Test cost estimation feature."""
with patch("tenets.cli.commands.distill.Tenets", return_value=mock_tenets):
app = typer.Typer()
app.command()(distill)
result = runner.invoke(
app, ["implement feature", ".", "--model", "gpt-4o", "--estimate-cost"]
)
assert result.exit_code == 0
assert "Cost Estimate" in result.stdout
assert "$0.003" in result.stdout
mock_tenets.estimate_cost.assert_called_once()
def test_distill_with_stats_display(self, runner, mock_tenets):
"""Test statistics display option."""
with patch("tenets.cli.commands.distill.Tenets", return_value=mock_tenets):
app = typer.Typer()
app.command()(distill)
result = runner.invoke(app, ["analyze", ".", "--stats"])
assert result.exit_code == 0
assert "Distillation Statistics" in result.stdout
assert "Files found:" in result.stdout
assert "Files included: 5" in result.stdout
@patch("tenets.cli.commands.distill.pyperclip")
def test_distill_with_clipboard_copy(self, mock_pyperclip, runner, mock_tenets):
"""Test copying to clipboard."""
mock_pyperclip.copy = MagicMock()
with patch("tenets.cli.commands.distill.Tenets", return_value=mock_tenets):
app = typer.Typer()
app.command()(distill)
result = runner.invoke(app, ["test", ".", "--copy"])
assert result.exit_code == 0
assert "Context copied to clipboard" in result.stdout
mock_pyperclip.copy.assert_called_once_with("# Test Context\n\nThis is test content")
def test_distill_with_config_copy_enabled(self, runner, mock_tenets):
"""Test auto-copy when enabled in config."""
mock_tenets.config.output.copy_on_distill = True
with patch("tenets.cli.commands.distill.Tenets", return_value=mock_tenets):
with patch("tenets.cli.commands.distill.pyperclip") as mock_pyperclip:
mock_pyperclip.copy = MagicMock()
app = typer.Typer()
app.command()(distill)
result = runner.invoke(app, ["test", "."])
assert result.exit_code == 0
mock_pyperclip.copy.assert_called_once()
def test_distill_no_files_warning(self, runner, mock_tenets):
"""Test warning when no files are included."""
mock_tenets.distill.return_value.metadata["files_included"] = 0
with patch("tenets.cli.commands.distill.Tenets", return_value=mock_tenets):
app = typer.Typer()
app.command()(distill)
result = runner.invoke(app, ["test", "."])
assert result.exit_code == 0
assert "No files were included" in result.stdout
assert "Suggestions" in result.stdout
def test_distill_quiet_mode(self, runner, mock_tenets):
"""Test quiet mode suppresses non-essential output."""
with patch("tenets.cli.commands.distill.Tenets", return_value=mock_tenets):
app = typer.Typer()
# Add the global callback for context
@app.callback()
def callback(ctx: typer.Context, quiet: bool = typer.Option(False, "--quiet")):
ctx.ensure_object(dict)
ctx.obj["quiet"] = quiet
ctx.obj["verbose"] = False
ctx.obj["silent"] = False
app.command()(distill)
result = runner.invoke(app, ["--quiet", "distill", "test", "."])
assert result.exit_code == 0
# In quiet mode, should only show essential output
assert "Tenets Context" not in result.stdout
assert "Test Context" in result.stdout
def test_distill_verbose_mode(self, runner, mock_tenets):
"""Test verbose mode shows additional information."""
# Mock an exception for verbose testing
mock_tenets.distill.side_effect = Exception("Test error")
with patch("tenets.cli.commands.distill.Tenets", return_value=mock_tenets):
app = typer.Typer()
@app.callback()
def callback(ctx: typer.Context, verbose: bool = typer.Option(False, "--verbose")):
ctx.ensure_object(dict)
ctx.obj["verbose"] = verbose
ctx.obj["quiet"] = False
ctx.obj["silent"] = False
app.command()(distill)
result = runner.invoke(app, ["--verbose", "distill", "test", "."])
assert result.exit_code == 1
assert "Error:" in result.stdout
assert "Test error" in result.stdout
def test_distill_with_url_prompt(self, runner, mock_tenets):
"""Test distillation with URL as prompt (e.g., GitHub issue)."""
with patch("tenets.cli.commands.distill.Tenets", return_value=mock_tenets):
app = typer.Typer()
app.command()(distill)
result = runner.invoke(app, ["https://github.com/org/repo/issues/123", "."])
assert result.exit_code == 0
call_args = mock_tenets.distill.call_args[1]
assert call_args["prompt"] == "https://github.com/org/repo/issues/123"
def test_distill_error_handling(self, runner, mock_tenets):
"""Test error handling and exit codes."""
mock_tenets.distill.side_effect = Exception("Analysis failed")
with patch("tenets.cli.commands.distill.Tenets", return_value=mock_tenets):
app = typer.Typer()
app.command()(distill)
result = runner.invoke(app, ["test", "."])
assert result.exit_code == 1
assert "Error:" in result.stdout
assert "Analysis failed" in result.stdout
def test_distill_with_stopwords(self, runner, mock_tenets):
"""Test with verbose mode (stopwords parameter removed)."""
with patch("tenets.cli.commands.distill.Tenets", return_value=mock_tenets):
app = typer.Typer()
app.command()(distill)
result = runner.invoke(app, ["analyze", ".", "--verbose"])
assert result.exit_code == 0
# Verbose flag doesn't get passed to distill, only affects output
mock_tenets.distill.assert_called_once()
class TestDistillOutputFormatting:
"""Test output formatting for different scenarios."""
def test_markdown_output_format(self, runner, mock_tenets):
"""Test markdown output formatting."""
with patch("tenets.cli.commands.distill.Tenets", return_value=mock_tenets):
app = typer.Typer()
app.command()(distill)
result = runner.invoke(app, ["test", ".", "--format", "markdown"])
assert result.exit_code == 0
assert "# Test Context" in result.stdout
def test_xml_output_format(self, runner, mock_tenets):
"""Test XML output format."""
mock_tenets.distill.return_value.context = "<context>Test XML</context>"
with patch("tenets.cli.commands.distill.Tenets", return_value=mock_tenets):
app = typer.Typer()
app.command()(distill)
result = runner.invoke(app, ["test", ".", "--format", "xml"])
assert result.exit_code == 0
assert "<context>" in result.stdout
def test_html_format_autosave_with_browser_prompt(self, runner, mock_tenets):
"""Test HTML format auto-saves and prompts for browser."""
mock_tenets.distill.return_value.context = "<html><body>Test HTML</body></html>"
with patch("tenets.cli.commands.distill.Tenets", return_value=mock_tenets):
with patch("sys.stdout.isatty", return_value=True):
with patch("tenets.cli.commands.distill.click.confirm", return_value=False):
with patch("pathlib.Path.write_text") as mock_write:
app = typer.Typer()
app.command()(distill)
result = runner.invoke(app, ["test", ".", "--format", "html"])
assert result.exit_code == 0
# In test environment, HTML is printed since isatty() returns False
assert "<html>" in result.stdout
# Browser prompt won't appear in non-interactive mode
# mock_write.assert_called_once()
def test_html_format_opens_browser_on_yes(self, runner, mock_tenets):
"""Test HTML format opens browser when user confirms."""
mock_tenets.distill.return_value.context = "<html><body>Test HTML</body></html>"
with patch("tenets.cli.commands.distill.Tenets", return_value=mock_tenets):
with patch("sys.stdout.isatty", return_value=True):
with patch("tenets.cli.commands.distill.click.confirm", return_value=True):
with patch("webbrowser.open") as mock_browser:
with patch("pathlib.Path.write_text"):
app = typer.Typer()
app.command()(distill)
result = runner.invoke(app, ["test", ".", "--format", "html"])
assert result.exit_code == 0
# In test environment, HTML is printed
assert "<html>" in result.stdout
# mock_browser.assert_called_once()
def test_html_output_with_explicit_path_prompts_browser(self, runner, mock_tenets, tmp_path):
"""Test HTML output with explicit path also prompts for browser."""
mock_tenets.distill.return_value.context = "<html><body>Test HTML</body></html>"
output_file = tmp_path / "output.html"
with patch("tenets.cli.commands.distill.Tenets", return_value=mock_tenets):
with patch("sys.stdout.isatty", return_value=True):
with patch("tenets.cli.commands.distill.click.confirm", return_value=False):
app = typer.Typer()
app.command()(distill)
result = runner.invoke(
app, ["test", ".", "--format", "html", "--output", str(output_file)]
)
assert result.exit_code == 0
assert "Context saved to" in result.stdout
# Browser prompt is shown when output is specified
# But in test environment, it might not appear
def test_interactive_vs_piped_output(self, runner, mock_tenets):
"""Test different output for interactive vs piped mode."""
with patch("tenets.cli.commands.distill.Tenets", return_value=mock_tenets):
with patch("sys.stdout.isatty", return_value=False):
app = typer.Typer()
app.command()(distill)
result = runner.invoke(app, ["test", "."])
assert result.exit_code == 0
# In piped mode, should not have decorative elements
assert "═" not in result.stdout # No rule lines
assert "Tenets Context" not in result.stdout # No panel
def test_include_tests_flag(self, runner, mock_tenets):
"""Test --include-tests flag."""
with patch("tenets.cli.commands.distill.Tenets", return_value=mock_tenets):
app = typer.Typer()
app.command()(distill)
result = runner.invoke(app, ["explain auth flow", ".", "--include-tests"])
assert result.exit_code == 0
# Verify include_tests was passed as True
call_args = mock_tenets.distill.call_args[1]
assert call_args["include_tests"] is True
def test_exclude_tests_flag(self, runner, mock_tenets):
"""Test --exclude-tests flag."""
with patch("tenets.cli.commands.distill.Tenets", return_value=mock_tenets):
app = typer.Typer()
app.command()(distill)
result = runner.invoke(app, ["write unit tests", ".", "--exclude-tests"])
assert result.exit_code == 0
# Verify include_tests was passed as False
call_args = mock_tenets.distill.call_args[1]
assert call_args["include_tests"] is False
def test_test_flags_priority(self, runner, mock_tenets):
"""Test that --exclude-tests takes priority over --include-tests."""
with patch("tenets.cli.commands.distill.Tenets", return_value=mock_tenets):
app = typer.Typer()
app.command()(distill)
# Both flags provided - exclude should win
result = runner.invoke(app, ["test prompt", ".", "--include-tests", "--exclude-tests"])
assert result.exit_code == 0
# Verify include_tests was passed as False (exclude wins)
call_args = mock_tenets.distill.call_args[1]
assert call_args["include_tests"] is False
def test_no_test_flags(self, runner, mock_tenets):
"""Test that no test flags results in None (automatic detection)."""
with patch("tenets.cli.commands.distill.Tenets", return_value=mock_tenets):
app = typer.Typer()
app.command()(distill)
result = runner.invoke(app, ["analyze code", "."])
assert result.exit_code == 0
# Verify include_tests was passed as None (automatic detection)
call_args = mock_tenets.distill.call_args[1]
assert call_args["include_tests"] is None
def test_test_flags_with_other_options(self, runner, mock_tenets):
"""Test test flags work with other CLI options."""
with patch("tenets.cli.commands.distill.Tenets", return_value=mock_tenets):
app = typer.Typer()
app.command()(distill)
result = runner.invoke(
app,
[
"debug auth",
".",
"--include-tests",
"--format",
"json",
"--mode",
"thorough",
"--include",
"*.py",
"--exclude",
"*.log",
],
)
assert result.exit_code == 0
# Verify all parameters were passed correctly
call_args = mock_tenets.distill.call_args[1]
assert call_args["include_tests"] is True
assert call_args["format"] == "json"
assert call_args["mode"] == "thorough"
assert call_args["include_patterns"] == ["*.py"]
assert call_args["exclude_patterns"] == ["*.log"]