"""Unit tests for State Store operations.""" import json import os from datetime import datetime, timezone from pathlib import Path import pytest from vlm.models import FileState, StateStore from vlm.state import VALID_STATUSES, StateManager, load_state, save_state class TestLoadSaveState: """Tests for load_state and save_state functions.""" def test_save_and_load_empty_state(self, tmp_path): """Test saving and loading an empty state store.""" state_path = tmp_path / "state.json" # Create empty state store store = StateStore( states={}, version='1.0', last_updated=datetime(2024, 1, 1, 12, 0, 0) ) # Save and load save_state(store, state_path) loaded = load_state(state_path) assert loaded.states == {} assert loaded.version == '1.0' # load_state normalizes naive ISO timestamps to UTC assert loaded.last_updated == datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc) def test_save_and_load_with_states(self, tmp_path): """Test saving and loading state store with file states.""" state_path = tmp_path / "state.json" # Create state store with some states file1 = Path("/videos/movie1.mp4") file2 = Path("/videos/series/episode.mkv") store = StateStore( states={ str(file1): FileState( file_path=file1, status="reviewed", reason="Checked manually", updated_at=datetime(2024, 1, 1, 12, 0, 0) ), str(file2): FileState( file_path=file2, status="ignored", reason=None, updated_at=datetime(2024, 1, 2, 12, 0, 0) ) }, version='1.0', last_updated=datetime(2024, 1, 2, 12, 0, 0) ) # Save and load save_state(store, state_path) loaded = load_state(state_path) assert len(loaded.states) == 2 assert str(file1) in loaded.states assert str(file2) in loaded.states state1 = loaded.states[str(file1)] assert state1.file_path == file1 assert state1.status == "reviewed" assert state1.reason == "Checked manually" # load_state normalizes naive ISO timestamps to UTC assert state1.updated_at == datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc) state2 = loaded.states[str(file2)] assert state2.file_path == file2 assert state2.status == "ignored" assert state2.reason is None assert state2.updated_at == datetime(2024, 1, 2, 12, 0, 0, tzinfo=timezone.utc) def test_save_creates_parent_directory(self, tmp_path): """Test that save_state creates parent directories if needed.""" state_path = tmp_path / "subdir" / "nested" / "state.json" store = StateStore( states={}, version='1.0', last_updated=datetime.now(timezone.utc) ) save_state(store, state_path) assert state_path.exists() assert state_path.parent.exists() def test_load_nonexistent_file_raises_error(self, tmp_path): """Test that loading a nonexistent file raises FileNotFoundError.""" state_path = tmp_path / "nonexistent.json" with pytest.raises(FileNotFoundError): load_state(state_path) def test_load_invalid_json_raises_error(self, tmp_path): """Test that loading invalid JSON raises JSONDecodeError.""" state_path = tmp_path / "invalid.json" state_path.write_text("not valid json {") with pytest.raises(json.JSONDecodeError): load_state(state_path) def test_saved_json_is_valid(self, tmp_path): """Test that saved JSON is valid and human-readable.""" state_path = tmp_path / "state.json" file1 = Path("/videos/movie.mp4") store = StateStore( states={ str(file1): FileState( file_path=file1, status="reviewed", reason="Test", updated_at=datetime(2024, 1, 1, 12, 0, 0) ) }, version='1.0', last_updated=datetime(2024, 1, 1, 12, 0, 0) ) save_state(store, state_path) # Verify JSON is valid by loading it directly with open(state_path, 'r') as f: data = json.load(f) assert 'states' in data assert 'version' in data assert 'last_updated' in data assert data['version'] == '1.0' class TestStateManager: """Tests for StateManager class.""" def test_init_creates_new_state_if_not_exists(self, tmp_path): """Test that StateManager creates a new state store if file doesn't exist.""" state_path = tmp_path / "state.json" manager = StateManager(state_path) assert manager.store.states == {} assert manager.store.version == '1.0' assert isinstance(manager.store.last_updated, datetime) def test_init_loads_existing_state(self, tmp_path): """Test that StateManager loads existing state store.""" state_path = tmp_path / "state.json" # Create existing state file1 = Path("/videos/movie.mp4") store = StateStore( states={ str(file1): FileState( file_path=file1, status="reviewed", reason="Test", updated_at=datetime(2024, 1, 1, 12, 0, 0) ) }, version='1.0', last_updated=datetime(2024, 1, 1, 12, 0, 0) ) save_state(store, state_path) # Load with manager manager = StateManager(state_path) assert len(manager.store.states) == 1 assert str(file1) in manager.store.states def test_get_file_state_returns_state(self, tmp_path): """Test getting state for a file.""" state_path = tmp_path / "state.json" manager = StateManager(state_path) file1 = Path("/videos/movie.mp4") manager.set_file_state(file1, "reviewed", "Test reason") state = manager.get_file_state(file1) assert state is not None assert state.file_path == file1 assert state.status == "reviewed" assert state.reason == "Test reason" def test_get_file_state_returns_none_if_not_found(self, tmp_path): """Test that get_file_state returns None for unknown files.""" state_path = tmp_path / "state.json" manager = StateManager(state_path) file1 = Path("/videos/movie.mp4") state = manager.get_file_state(file1) assert state is None def test_set_file_state_creates_new_state(self, tmp_path): """Test setting state for a new file.""" state_path = tmp_path / "state.json" manager = StateManager(state_path) file1 = Path("/videos/movie.mp4") manager.set_file_state(file1, "reviewed", "Checked") state = manager.get_file_state(file1) assert state.status == "reviewed" assert state.reason == "Checked" assert isinstance(state.updated_at, datetime) def test_set_file_state_updates_existing_state(self, tmp_path): """Test that set_file_state is idempotent and updates timestamp.""" state_path = tmp_path / "state.json" manager = StateManager(state_path) file1 = Path("/videos/movie.mp4") # Set initial state manager.set_file_state(file1, "reviewed", "First check") state1 = manager.get_file_state(file1) # Update state manager.set_file_state(file1, "reviewed", "Second check") state2 = manager.get_file_state(file1) assert state2.status == "reviewed" assert state2.reason == "Second check" assert state2.updated_at >= state1.updated_at def test_set_file_state_validates_status(self, tmp_path): """Test that set_file_state validates status values.""" state_path = tmp_path / "state.json" manager = StateManager(state_path) file1 = Path("/videos/movie.mp4") with pytest.raises(ValueError, match="Invalid status"): manager.set_file_state(file1, "invalid_status") def test_set_file_state_accepts_all_valid_statuses(self, tmp_path): """Test that all valid statuses are accepted.""" state_path = tmp_path / "state.json" manager = StateManager(state_path) file1 = Path("/videos/movie.mp4") for status in VALID_STATUSES: manager.set_file_state(file1, status) state = manager.get_file_state(file1) assert state.status == status def test_set_file_state_without_reason(self, tmp_path): """Test setting state without a reason.""" state_path = tmp_path / "state.json" manager = StateManager(state_path) file1 = Path("/videos/movie.mp4") manager.set_file_state(file1, "ignored") state = manager.get_file_state(file1) assert state.status == "ignored" assert state.reason is None def test_query_by_status_returns_matching_files(self, tmp_path): """Test querying files by status.""" state_path = tmp_path / "state.json" manager = StateManager(state_path) file1 = Path("/videos/movie1.mp4") file2 = Path("/videos/movie2.mp4") file3 = Path("/videos/movie3.mp4") manager.set_file_state(file1, "reviewed") manager.set_file_state(file2, "ignored") manager.set_file_state(file3, "reviewed") reviewed = manager.query_by_status("reviewed") assert len(reviewed) == 2 reviewed_paths = {state.file_path for state in reviewed} assert file1 in reviewed_paths assert file3 in reviewed_paths def test_query_by_status_returns_empty_list_if_none_match(self, tmp_path): """Test that query_by_status returns empty list if no matches.""" state_path = tmp_path / "state.json" manager = StateManager(state_path) file1 = Path("/videos/movie.mp4") manager.set_file_state(file1, "reviewed") quarantined = manager.query_by_status("quarantined") assert quarantined == [] def test_clear_state_removes_file_state(self, tmp_path): """Test clearing state for a file.""" state_path = tmp_path / "state.json" manager = StateManager(state_path) file1 = Path("/videos/movie.mp4") manager.set_file_state(file1, "reviewed") assert manager.get_file_state(file1) is not None manager.clear_state(file1) assert manager.get_file_state(file1) is None def test_clear_state_on_nonexistent_file_does_nothing(self, tmp_path): """Test that clearing state on nonexistent file doesn't raise error.""" state_path = tmp_path / "state.json" manager = StateManager(state_path) file1 = Path("/videos/movie.mp4") # Should not raise error manager.clear_state(file1) def test_save_persists_state_to_disk(self, tmp_path): """Test that save() persists state to disk.""" state_path = tmp_path / "state.json" manager = StateManager(state_path) file1 = Path("/videos/movie.mp4") manager.set_file_state(file1, "reviewed", "Test") # Save to disk manager.save() # Load in new manager manager2 = StateManager(state_path) state = manager2.get_file_state(file1) assert state is not None assert state.status == "reviewed" assert state.reason == "Test" def test_state_updates_last_updated_timestamp(self, tmp_path): """Test that state operations update last_updated timestamp.""" state_path = tmp_path / "state.json" manager = StateManager(state_path) initial_timestamp = manager.store.last_updated file1 = Path("/videos/movie.mp4") manager.set_file_state(file1, "reviewed") assert manager.store.last_updated >= initial_timestamp def test_multiple_files_with_different_statuses(self, tmp_path): """Test managing multiple files with different statuses.""" state_path = tmp_path / "state.json" manager = StateManager(state_path) files = [ (Path("/videos/movie1.mp4"), "reviewed"), (Path("/videos/movie2.mp4"), "ignored"), (Path("/videos/movie3.mp4"), "planned"), (Path("/videos/movie4.mp4"), "executed"), (Path("/videos/movie5.mp4"), "quarantined"), ] for file_path, status in files: manager.set_file_state(file_path, status) # Verify all statuses for file_path, expected_status in files: state = manager.get_file_state(file_path) assert state.status == expected_status # Verify queries for status in VALID_STATUSES: results = manager.query_by_status(status) expected_count = sum(1 for _, s in files if s == status) assert len(results) == expected_count def test_set_file_state_uses_canonical_key_for_symlink_paths(self, tmp_path): """Setting state through symlink and real path should deduplicate keys.""" real_dir = tmp_path / "real" real_dir.mkdir() real_file = real_dir / "movie.mkv" real_file.write_text("x") link_dir = tmp_path / "link" os.symlink(real_dir, link_dir) symlink_file = link_dir / "movie.mkv" manager = StateManager(tmp_path / "state.json") manager.set_file_state(symlink_file, "reviewed", "via symlink") manager.set_file_state(real_file, "ignored", "via real path") assert len(manager.store.states) == 1 state = manager.get_file_state(real_file) assert state is not None assert state.status == "ignored" def test_save_state_uses_atomic_replace(tmp_path, monkeypatch): """save_state should atomically replace the destination file.""" state_path = tmp_path / "state.json" replaced = {"called": False} original_replace = os.replace def _replace(src, dst): replaced["called"] = True return original_replace(src, dst) monkeypatch.setattr("vlm.state.os.replace", _replace) manager = StateManager(state_path) manager.set_file_state(Path("/videos/movie.mp4"), "reviewed", "atomic") manager.save() assert replaced["called"] is True loaded = load_state(state_path) assert len(loaded.states) == 1