Stop tracking personal workflow artifacts at repo root, add CI and MIT license, align README and agent skills with artifacts/ defaults, and enable Ruff in dev/CI so releases are verifiable without local-only runs. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> Co-authored-by: Cursor <cursoragent@cursor.com>
430 lines
15 KiB
Python
430 lines
15 KiB
Python
"""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
|