"""Integration tests for hokusai.state.""" from __future__ import annotations from pathlib import Path import yaml from hokusai.state import ( BuildState, TargetState, hash_file, is_target_dirty, load_state, record_target_state, save_state, state_filename, ) _PROJECT = "test" 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 class TestStateFilename: """Test state filename derivation.""" def test_state_filename(self) -> None: assert state_filename("cards") == ".cards.hokusai-state.yaml" def test_state_filename_simple(self) -> None: assert state_filename("project") == ".project.hokusai-state.yaml" 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, _PROJECT) 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="Generate something", model="mistral-large-latest", extra_params={}, ) } ) save_state(state, project_dir, _PROJECT) loaded = load_state(project_dir, _PROJECT) 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 == "Generate something" def test_load_empty_yaml(self, project_dir: Path) -> None: _ = (project_dir / state_filename(_PROJECT)).write_text("") state = load_state(project_dir, _PROJECT) assert state.targets == {} def test_save_overwrites_existing(self, project_dir: Path) -> None: state1 = BuildState( targets={"a.txt": TargetState(input_hashes={}, prompt="p1", model="m1")} ) save_state(state1, project_dir, _PROJECT) state2 = BuildState( targets={"b.txt": TargetState(input_hashes={}, prompt="p2", model="m2")} ) save_state(state2, project_dir, _PROJECT) loaded = load_state(project_dir, _PROJECT) 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="do something", model="m", extra_params={"width": 512}, ) } ) save_state(state, project_dir, _PROJECT) raw: object = yaml.safe_load( # pyright: ignore[reportAny] (project_dir / state_filename(_PROJECT)).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, _PROJECT) loaded_state = load_state(project_dir, _PROJECT) 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, )