test: add integration tests for all modules
- 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
This commit is contained in:
parent
452b3c4eb0
commit
eef9712924
10 changed files with 1662 additions and 0 deletions
310
tests/test_state.py
Normal file
310
tests/test_state.py
Normal file
|
|
@ -0,0 +1,310 @@
|
|||
"""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,
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue