"""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, )