- State filename now derives from config: cards.bulkgen.yaml produces .cards.bulkgen-state.yaml instead of .bulkgen.state.yaml - Store resolved prompt text and extra params directly in state file instead of hashing them, making state files human-readable - Only file input contents remain hashed (SHA-256) - Thread project_name through builder and CLI - Remove hash_string() and _extra_hash() helpers - Update .gitignore pattern to .*.bulkgen-state.yaml
230 lines
7.9 KiB
Python
230 lines
7.9 KiB
Python
"""Integration tests for bulkgen.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 bulkgen.builder import BuildResult
|
|
from bulkgen.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.bulkgen.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("bulkgen.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 .bulkgen.yaml file found" in result.output
|
|
|
|
def test_multiple_config_files(self, tmp_path: Path) -> None:
|
|
_ = (tmp_path / "a.bulkgen.yaml").write_text(
|
|
yaml.dump({"targets": {"x.txt": {"prompt": "a"}}})
|
|
)
|
|
_ = (tmp_path / "b.bulkgen.yaml").write_text(
|
|
yaml.dump({"targets": {"y.txt": {"prompt": "b"}}})
|
|
)
|
|
with patch("bulkgen.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 .bulkgen.yaml 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("bulkgen.cli.Path") as mock_path_cls,
|
|
patch(
|
|
"bulkgen.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("bulkgen.cli.Path") as mock_path_cls,
|
|
patch(
|
|
"bulkgen.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("bulkgen.cli.Path") as mock_path_cls,
|
|
patch(
|
|
"bulkgen.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("bulkgen.cli.Path") as mock_path_cls,
|
|
patch(
|
|
"bulkgen.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.bulkgen-state.yaml"
|
|
_ = (cli_project / state_file).write_text("targets: {}")
|
|
|
|
with patch("bulkgen.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("bulkgen.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("bulkgen.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.bulkgen.yaml").write_text(
|
|
yaml.dump(config, default_flow_style=False)
|
|
)
|
|
with patch("bulkgen.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.bulkgen.yaml").write_text(
|
|
yaml.dump(config, default_flow_style=False)
|
|
)
|
|
with patch("bulkgen.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.bulkgen.yaml").write_text(
|
|
yaml.dump(config, default_flow_style=False)
|
|
)
|
|
with patch("bulkgen.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
|