- Add pytest-asyncio dev dependency and configure asyncio_mode=auto - Add filterwarnings to suppress third-party PydanticDeprecatedSince20 - Add conftest.py with shared fixtures (project_dir, write_config, etc.) - Add test_config.py: YAML loading, target type inference, model resolution - Add test_graph.py: DAG construction, cycle detection, build ordering - Add test_state.py: hash functions, state persistence, dirty checking - Add test_builder.py: full build pipeline with FakeProvider, incremental builds, selective builds, error isolation, dependency cascading - Add test_providers.py: ImageProvider and TextProvider with mocked clients - Add test_cli.py: build/clean/graph commands via typer CliRunner - All 94 tests pass with 0 basedpyright warnings
310 lines
9.3 KiB
Python
310 lines
9.3 KiB
Python
"""Integration tests for bulkgen.state."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
|
|
import yaml
|
|
|
|
from bulkgen.state import (
|
|
BuildState,
|
|
TargetState,
|
|
hash_file,
|
|
hash_string,
|
|
is_target_dirty,
|
|
load_state,
|
|
record_target_state,
|
|
save_state,
|
|
)
|
|
|
|
|
|
class TestHashFunctions:
|
|
"""Test hashing helpers."""
|
|
|
|
def test_hash_file_deterministic(self, project_dir: Path) -> None:
|
|
f = project_dir / "data.txt"
|
|
_ = f.write_text("hello world")
|
|
assert hash_file(f) == hash_file(f)
|
|
|
|
def test_hash_file_changes_with_content(self, project_dir: Path) -> None:
|
|
f = project_dir / "data.txt"
|
|
_ = f.write_text("version 1")
|
|
h1 = hash_file(f)
|
|
_ = f.write_text("version 2")
|
|
h2 = hash_file(f)
|
|
assert h1 != h2
|
|
|
|
def test_hash_string_deterministic(self) -> None:
|
|
assert hash_string("abc") == hash_string("abc")
|
|
|
|
def test_hash_string_differs(self) -> None:
|
|
assert hash_string("abc") != hash_string("xyz")
|
|
|
|
|
|
class TestStatePersistence:
|
|
"""Test save/load round-trip of build state."""
|
|
|
|
def test_load_missing_file_returns_empty(self, project_dir: Path) -> None:
|
|
state = load_state(project_dir)
|
|
assert state.targets == {}
|
|
|
|
def test_save_and_load_round_trip(self, project_dir: Path) -> None:
|
|
state = BuildState(
|
|
targets={
|
|
"out.txt": TargetState(
|
|
input_hashes={"dep.txt": "abc123"},
|
|
prompt_hash="prompt_hash_val",
|
|
model="mistral-large-latest",
|
|
extra_hash="",
|
|
)
|
|
}
|
|
)
|
|
save_state(state, project_dir)
|
|
loaded = load_state(project_dir)
|
|
|
|
assert loaded.targets["out.txt"].model == "mistral-large-latest"
|
|
assert loaded.targets["out.txt"].input_hashes == {"dep.txt": "abc123"}
|
|
assert loaded.targets["out.txt"].prompt_hash == "prompt_hash_val"
|
|
|
|
def test_load_empty_yaml(self, project_dir: Path) -> None:
|
|
_ = (project_dir / ".bulkgen.state.yaml").write_text("")
|
|
state = load_state(project_dir)
|
|
assert state.targets == {}
|
|
|
|
def test_save_overwrites_existing(self, project_dir: Path) -> None:
|
|
state1 = BuildState(
|
|
targets={
|
|
"a.txt": TargetState(input_hashes={}, prompt_hash="h1", model="m1")
|
|
}
|
|
)
|
|
save_state(state1, project_dir)
|
|
|
|
state2 = BuildState(
|
|
targets={
|
|
"b.txt": TargetState(input_hashes={}, prompt_hash="h2", model="m2")
|
|
}
|
|
)
|
|
save_state(state2, project_dir)
|
|
|
|
loaded = load_state(project_dir)
|
|
assert "b.txt" in loaded.targets
|
|
assert "a.txt" not in loaded.targets
|
|
|
|
def test_state_file_is_valid_yaml(self, project_dir: Path) -> None:
|
|
state = BuildState(
|
|
targets={
|
|
"out.txt": TargetState(
|
|
input_hashes={"f.txt": "hash"},
|
|
prompt_hash="ph",
|
|
model="m",
|
|
extra_hash="eh",
|
|
)
|
|
}
|
|
)
|
|
save_state(state, project_dir)
|
|
|
|
raw: object = yaml.safe_load( # pyright: ignore[reportAny]
|
|
(project_dir / ".bulkgen.state.yaml").read_text()
|
|
)
|
|
assert isinstance(raw, dict)
|
|
assert "targets" in raw
|
|
|
|
|
|
class TestIsDirty:
|
|
"""Test dirty-checking logic with real files."""
|
|
|
|
def _setup_target(
|
|
self, project_dir: Path, *, dep_content: str = "dep data"
|
|
) -> tuple[BuildState, list[Path]]:
|
|
"""Create a built target with one dependency and return (state, dep_files)."""
|
|
dep = project_dir / "dep.txt"
|
|
_ = dep.write_text(dep_content)
|
|
output = project_dir / "out.txt"
|
|
_ = output.write_text("generated output")
|
|
|
|
state = BuildState()
|
|
dep_files = [dep]
|
|
record_target_state(
|
|
"out.txt",
|
|
resolved_prompt="prompt",
|
|
model="model-v1",
|
|
dep_files=dep_files,
|
|
extra_params={},
|
|
state=state,
|
|
project_dir=project_dir,
|
|
)
|
|
return state, dep_files
|
|
|
|
def test_clean_target_not_dirty(self, project_dir: Path) -> None:
|
|
state, dep_files = self._setup_target(project_dir)
|
|
|
|
assert not is_target_dirty(
|
|
"out.txt",
|
|
resolved_prompt="prompt",
|
|
model="model-v1",
|
|
dep_files=dep_files,
|
|
extra_params={},
|
|
state=state,
|
|
project_dir=project_dir,
|
|
)
|
|
|
|
def test_missing_output_is_dirty(self, project_dir: Path) -> None:
|
|
state, dep_files = self._setup_target(project_dir)
|
|
(project_dir / "out.txt").unlink()
|
|
|
|
assert is_target_dirty(
|
|
"out.txt",
|
|
resolved_prompt="prompt",
|
|
model="model-v1",
|
|
dep_files=dep_files,
|
|
extra_params={},
|
|
state=state,
|
|
project_dir=project_dir,
|
|
)
|
|
|
|
def test_changed_dep_is_dirty(self, project_dir: Path) -> None:
|
|
state, dep_files = self._setup_target(project_dir)
|
|
_ = (project_dir / "dep.txt").write_text("MODIFIED content")
|
|
|
|
assert is_target_dirty(
|
|
"out.txt",
|
|
resolved_prompt="prompt",
|
|
model="model-v1",
|
|
dep_files=dep_files,
|
|
extra_params={},
|
|
state=state,
|
|
project_dir=project_dir,
|
|
)
|
|
|
|
def test_changed_prompt_is_dirty(self, project_dir: Path) -> None:
|
|
state, dep_files = self._setup_target(project_dir)
|
|
|
|
assert is_target_dirty(
|
|
"out.txt",
|
|
resolved_prompt="DIFFERENT prompt",
|
|
model="model-v1",
|
|
dep_files=dep_files,
|
|
extra_params={},
|
|
state=state,
|
|
project_dir=project_dir,
|
|
)
|
|
|
|
def test_changed_model_is_dirty(self, project_dir: Path) -> None:
|
|
state, dep_files = self._setup_target(project_dir)
|
|
|
|
assert is_target_dirty(
|
|
"out.txt",
|
|
resolved_prompt="prompt",
|
|
model="model-v2",
|
|
dep_files=dep_files,
|
|
extra_params={},
|
|
state=state,
|
|
project_dir=project_dir,
|
|
)
|
|
|
|
def test_changed_extra_params_is_dirty(self, project_dir: Path) -> None:
|
|
state, dep_files = self._setup_target(project_dir)
|
|
|
|
assert is_target_dirty(
|
|
"out.txt",
|
|
resolved_prompt="prompt",
|
|
model="model-v1",
|
|
dep_files=dep_files,
|
|
extra_params={"width": 512},
|
|
state=state,
|
|
project_dir=project_dir,
|
|
)
|
|
|
|
def test_never_built_target_is_dirty(self, project_dir: Path) -> None:
|
|
_ = (project_dir / "out.txt").write_text("exists but never recorded")
|
|
|
|
assert is_target_dirty(
|
|
"out.txt",
|
|
resolved_prompt="prompt",
|
|
model="model-v1",
|
|
dep_files=[],
|
|
extra_params={},
|
|
state=BuildState(),
|
|
project_dir=project_dir,
|
|
)
|
|
|
|
def test_new_dep_added_is_dirty(self, project_dir: Path) -> None:
|
|
state, dep_files = self._setup_target(project_dir)
|
|
|
|
new_dep = project_dir / "extra.txt"
|
|
_ = new_dep.write_text("extra dep")
|
|
dep_files.append(new_dep)
|
|
|
|
assert is_target_dirty(
|
|
"out.txt",
|
|
resolved_prompt="prompt",
|
|
model="model-v1",
|
|
dep_files=dep_files,
|
|
extra_params={},
|
|
state=state,
|
|
project_dir=project_dir,
|
|
)
|
|
|
|
|
|
class TestRecordAndDirtyRoundTrip:
|
|
"""Test that recording state then checking produces consistent results."""
|
|
|
|
def test_record_then_check_not_dirty(self, project_dir: Path) -> None:
|
|
dep = project_dir / "input.txt"
|
|
_ = dep.write_text("data")
|
|
output = project_dir / "result.md"
|
|
_ = output.write_text("result")
|
|
|
|
state = BuildState()
|
|
dep_files = [dep]
|
|
|
|
record_target_state(
|
|
"result.md",
|
|
resolved_prompt="do the thing",
|
|
model="mistral-large-latest",
|
|
dep_files=dep_files,
|
|
extra_params={"width": 100},
|
|
state=state,
|
|
project_dir=project_dir,
|
|
)
|
|
|
|
assert not is_target_dirty(
|
|
"result.md",
|
|
resolved_prompt="do the thing",
|
|
model="mistral-large-latest",
|
|
dep_files=dep_files,
|
|
extra_params={"width": 100},
|
|
state=state,
|
|
project_dir=project_dir,
|
|
)
|
|
|
|
def test_state_survives_save_load_cycle(self, project_dir: Path) -> None:
|
|
dep = project_dir / "input.txt"
|
|
_ = dep.write_text("data")
|
|
output = project_dir / "result.md"
|
|
_ = output.write_text("result")
|
|
|
|
state = BuildState()
|
|
dep_files = [dep]
|
|
|
|
record_target_state(
|
|
"result.md",
|
|
resolved_prompt="do the thing",
|
|
model="mistral-large-latest",
|
|
dep_files=dep_files,
|
|
extra_params={},
|
|
state=state,
|
|
project_dir=project_dir,
|
|
)
|
|
save_state(state, project_dir)
|
|
|
|
loaded_state = load_state(project_dir)
|
|
assert not is_target_dirty(
|
|
"result.md",
|
|
resolved_prompt="do the thing",
|
|
model="mistral-large-latest",
|
|
dep_files=dep_files,
|
|
extra_params={},
|
|
state=loaded_state,
|
|
project_dir=project_dir,
|
|
)
|