hokusai/tests/test_cli.py

230 lines
7.9 KiB
Python

"""Integration tests for hokusai.cli.
Patching ``Path.cwd()`` produces Any-typed return values from mock objects.
"""
# pyright: reportAny=false
from __future__ import annotations
from pathlib import Path
from unittest.mock import AsyncMock, patch
import pytest
import yaml
from typer.testing import CliRunner
from hokusai.builder import BuildResult
from hokusai.cli import app
runner = CliRunner()
@pytest.fixture
def cli_project(tmp_path: Path) -> Path:
"""Create a minimal project directory with a config file."""
config = {
"targets": {
"output.txt": {"prompt": "Generate text"},
"image.png": {"prompt": "Generate image"},
}
}
_ = (tmp_path / "project.hokusai.yaml").write_text(
yaml.dump(config, default_flow_style=False)
)
return tmp_path
class TestFindConfig:
"""Test config file discovery."""
def test_no_config_file(self, tmp_path: Path) -> None:
with patch("hokusai.cli.Path") as mock_path_cls:
mock_path_cls.cwd.return_value = tmp_path
result = runner.invoke(app, ["build"])
assert result.exit_code != 0
assert "No .hokusai.yaml/.yml file found" in result.output
def test_multiple_config_files(self, tmp_path: Path) -> None:
_ = (tmp_path / "a.hokusai.yaml").write_text(
yaml.dump({"targets": {"x.txt": {"prompt": "a"}}})
)
_ = (tmp_path / "b.hokusai.yaml").write_text(
yaml.dump({"targets": {"y.txt": {"prompt": "b"}}})
)
with patch("hokusai.cli.Path") as mock_path_cls:
mock_path_cls.cwd.return_value = tmp_path
result = runner.invoke(app, ["build"])
assert result.exit_code != 0
assert "Multiple .hokusai.yaml/.yml files found" in result.output
class TestBuildCommand:
"""Test the build CLI command."""
def test_build_success(self, cli_project: Path) -> None:
build_result = BuildResult(
built=["output.txt", "image.png"], skipped=[], failed={}
)
with (
patch("hokusai.cli.Path") as mock_path_cls,
patch(
"hokusai.cli.run_build",
new_callable=AsyncMock,
return_value=build_result,
),
):
mock_path_cls.cwd.return_value = cli_project
result = runner.invoke(app, ["build"])
assert result.exit_code == 0
assert "2 built" in result.output
def test_build_with_skipped(self, cli_project: Path) -> None:
build_result = BuildResult(
built=[], skipped=["output.txt", "image.png"], failed={}
)
with (
patch("hokusai.cli.Path") as mock_path_cls,
patch(
"hokusai.cli.run_build",
new_callable=AsyncMock,
return_value=build_result,
),
):
mock_path_cls.cwd.return_value = cli_project
result = runner.invoke(app, ["build"])
assert result.exit_code == 0
assert "2 skipped" in result.output
def test_build_with_failures(self, cli_project: Path) -> None:
build_result = BuildResult(
built=["output.txt"],
skipped=[],
failed={"image.png": "Missing BFL_API_KEY"},
)
with (
patch("hokusai.cli.Path") as mock_path_cls,
patch(
"hokusai.cli.run_build",
new_callable=AsyncMock,
return_value=build_result,
),
):
mock_path_cls.cwd.return_value = cli_project
result = runner.invoke(app, ["build"])
assert result.exit_code == 1
assert "1 failed" in result.output
def test_build_specific_target(self, cli_project: Path) -> None:
build_result = BuildResult(built=["output.txt"], skipped=[], failed={})
with (
patch("hokusai.cli.Path") as mock_path_cls,
patch(
"hokusai.cli.run_build",
new_callable=AsyncMock,
return_value=build_result,
) as mock_run,
):
mock_path_cls.cwd.return_value = cli_project
result = runner.invoke(app, ["build", "output.txt"])
assert result.exit_code == 0
call_args = mock_run.call_args
# positional args: (config, project_dir, project_name, target)
assert call_args[0][3] == "output.txt"
class TestCleanCommand:
"""Test the clean CLI command."""
def test_clean_removes_targets(self, cli_project: Path) -> None:
_ = (cli_project / "output.txt").write_text("generated")
_ = (cli_project / "image.png").write_bytes(b"\x89PNG")
state_file = ".project.hokusai-state.yaml"
_ = (cli_project / state_file).write_text("targets: {}")
with patch("hokusai.cli.Path") as mock_path_cls:
mock_path_cls.cwd.return_value = cli_project
result = runner.invoke(app, ["clean"])
assert result.exit_code == 0
assert not (cli_project / "output.txt").exists()
assert not (cli_project / "image.png").exists()
assert not (cli_project / state_file).exists()
assert "Cleaned 2 artifact(s)" in result.output
def test_clean_no_artifacts(self, cli_project: Path) -> None:
with patch("hokusai.cli.Path") as mock_path_cls:
mock_path_cls.cwd.return_value = cli_project
result = runner.invoke(app, ["clean"])
assert result.exit_code == 0
assert "Cleaned 0 artifact(s)" in result.output
def test_clean_partial_artifacts(self, cli_project: Path) -> None:
_ = (cli_project / "output.txt").write_text("generated")
with patch("hokusai.cli.Path") as mock_path_cls:
mock_path_cls.cwd.return_value = cli_project
result = runner.invoke(app, ["clean"])
assert result.exit_code == 0
assert "Cleaned 1 artifact(s)" in result.output
assert not (cli_project / "output.txt").exists()
class TestGraphCommand:
"""Test the graph CLI command."""
def test_graph_single_target(self, tmp_path: Path) -> None:
config = {"targets": {"out.txt": {"prompt": "hello"}}}
_ = (tmp_path / "test.hokusai.yaml").write_text(
yaml.dump(config, default_flow_style=False)
)
with patch("hokusai.cli.Path") as mock_path_cls:
mock_path_cls.cwd.return_value = tmp_path
result = runner.invoke(app, ["graph"])
assert result.exit_code == 0
assert "out.txt" in result.output
def test_graph_with_dependencies(self, tmp_path: Path) -> None:
_ = (tmp_path / "input.txt").write_text("data")
config = {
"targets": {
"step1.md": {"prompt": "x", "inputs": ["input.txt"]},
"step2.txt": {"prompt": "y", "inputs": ["step1.md"]},
}
}
_ = (tmp_path / "test.hokusai.yaml").write_text(
yaml.dump(config, default_flow_style=False)
)
with patch("hokusai.cli.Path") as mock_path_cls:
mock_path_cls.cwd.return_value = tmp_path
result = runner.invoke(app, ["graph"])
assert result.exit_code == 0
assert "input.txt" in result.output
assert "step1.md" in result.output
assert "step2.txt" in result.output
assert "<-" in result.output
def test_graph_shows_stages(self, tmp_path: Path) -> None:
_ = (tmp_path / "data.txt").write_text("data")
config = {
"targets": {
"a.txt": {"prompt": "x", "inputs": ["data.txt"]},
"b.txt": {"prompt": "y", "inputs": ["a.txt"]},
}
}
_ = (tmp_path / "test.hokusai.yaml").write_text(
yaml.dump(config, default_flow_style=False)
)
with patch("hokusai.cli.Path") as mock_path_cls:
mock_path_cls.cwd.return_value = tmp_path
result = runner.invoke(app, ["graph"])
assert result.exit_code == 0
assert "Stage" in result.output