"""Tests for queue manager.""" import json import tempfile from collections.abc import Generator from pathlib import Path import pytest from src.models import IssueMetadata from src.queue import QueueItem, QueueItemStatus, QueueManager class TestQueueItem: """Tests for QueueItem dataclass.""" def test_queue_item_creation(self) -> None: """Test creating a queue item with all fields.""" metadata = IssueMetadata( estimated_context=50000, difficulty="medium", assigned_agent="sonnet", blocks=[161, 162], blocked_by=[158], ) item = QueueItem( issue_number=159, metadata=metadata, status=QueueItemStatus.PENDING, ) assert item.issue_number == 159 assert item.metadata == metadata assert item.status == QueueItemStatus.PENDING assert item.ready is False # Should not be ready (blocked_by exists) def test_queue_item_defaults(self) -> None: """Test queue item with default values.""" metadata = IssueMetadata() item = QueueItem( issue_number=160, metadata=metadata, ) assert item.issue_number == 160 assert item.status == QueueItemStatus.PENDING assert item.ready is True # Should be ready (no blockers) def test_queue_item_serialization(self) -> None: """Test converting queue item to dict for JSON serialization.""" metadata = IssueMetadata( estimated_context=30000, difficulty="easy", assigned_agent="haiku", blocks=[165], blocked_by=[], ) item = QueueItem( issue_number=164, metadata=metadata, status=QueueItemStatus.IN_PROGRESS, ready=True, ) data = item.to_dict() assert data["issue_number"] == 164 assert data["status"] == "in_progress" assert data["ready"] is True assert data["metadata"]["estimated_context"] == 30000 assert data["metadata"]["difficulty"] == "easy" def test_queue_item_deserialization(self) -> None: """Test creating queue item from dict.""" data = { "issue_number": 161, "status": "completed", "ready": False, "metadata": { "estimated_context": 75000, "difficulty": "hard", "assigned_agent": "opus", "blocks": [166, 167], "blocked_by": [159], }, } item = QueueItem.from_dict(data) assert item.issue_number == 161 assert item.status == QueueItemStatus.COMPLETED assert item.ready is False assert item.metadata.estimated_context == 75000 assert item.metadata.difficulty == "hard" assert item.metadata.assigned_agent == "opus" assert item.metadata.blocks == [166, 167] assert item.metadata.blocked_by == [159] class TestQueueManager: """Tests for QueueManager.""" @pytest.fixture def temp_queue_file(self) -> Generator[Path, None, None]: """Create a temporary file for queue persistence.""" with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".json") as f: temp_path = Path(f.name) yield temp_path # Cleanup if temp_path.exists(): temp_path.unlink() @pytest.fixture def queue_manager(self, temp_queue_file: Path) -> QueueManager: """Create a queue manager with temporary storage.""" return QueueManager(queue_file=temp_queue_file) def test_enqueue_single_item(self, queue_manager: QueueManager) -> None: """Test enqueuing a single item.""" metadata = IssueMetadata( estimated_context=40000, difficulty="medium", assigned_agent="sonnet", blocks=[], blocked_by=[], ) queue_manager.enqueue(159, metadata) assert queue_manager.size() == 1 item = queue_manager.get_item(159) assert item is not None assert item.issue_number == 159 assert item.status == QueueItemStatus.PENDING assert item.ready is True def test_enqueue_multiple_items(self, queue_manager: QueueManager) -> None: """Test enqueuing multiple items.""" meta1 = IssueMetadata(assigned_agent="sonnet") meta2 = IssueMetadata(assigned_agent="haiku") meta3 = IssueMetadata(assigned_agent="glm") queue_manager.enqueue(159, meta1) queue_manager.enqueue(160, meta2) queue_manager.enqueue(161, meta3) assert queue_manager.size() == 3 def test_dequeue_item(self, queue_manager: QueueManager) -> None: """Test removing an item from the queue.""" metadata = IssueMetadata() queue_manager.enqueue(159, metadata) assert queue_manager.size() == 1 queue_manager.dequeue(159) assert queue_manager.size() == 0 assert queue_manager.get_item(159) is None def test_dequeue_nonexistent_item(self, queue_manager: QueueManager) -> None: """Test dequeuing an item that doesn't exist.""" # Should not raise error, just be a no-op queue_manager.dequeue(999) assert queue_manager.size() == 0 def test_get_next_ready_simple(self, queue_manager: QueueManager) -> None: """Test getting next ready item with no dependencies.""" meta1 = IssueMetadata(assigned_agent="sonnet") meta2 = IssueMetadata(assigned_agent="haiku") queue_manager.enqueue(159, meta1) queue_manager.enqueue(160, meta2) next_item = queue_manager.get_next_ready() assert next_item is not None # Should return first item (159) since both are ready assert next_item.issue_number == 159 def test_get_next_ready_with_dependencies(self, queue_manager: QueueManager) -> None: """Test getting next ready item with dependency chain.""" # Issue 160 blocks 161, 158 blocks 159 meta_158 = IssueMetadata(blocks=[159], blocked_by=[]) meta_159 = IssueMetadata(blocks=[161], blocked_by=[158]) meta_160 = IssueMetadata(blocks=[161], blocked_by=[]) meta_161 = IssueMetadata(blocks=[], blocked_by=[159, 160]) queue_manager.enqueue(158, meta_158) queue_manager.enqueue(159, meta_159) queue_manager.enqueue(160, meta_160) queue_manager.enqueue(161, meta_161) # Should get 158 or 160 (both ready, no blockers) next_item = queue_manager.get_next_ready() assert next_item is not None assert next_item.issue_number in [158, 160] assert next_item.ready is True def test_get_next_ready_empty_queue(self, queue_manager: QueueManager) -> None: """Test getting next ready item from empty queue.""" next_item = queue_manager.get_next_ready() assert next_item is None def test_get_next_ready_all_blocked(self, queue_manager: QueueManager) -> None: """Test getting next ready when all items are blocked.""" # Circular dependency: 159 blocks 160, 160 blocks 159 meta_159 = IssueMetadata(blocks=[160], blocked_by=[160]) meta_160 = IssueMetadata(blocks=[159], blocked_by=[159]) queue_manager.enqueue(159, meta_159) queue_manager.enqueue(160, meta_160) next_item = queue_manager.get_next_ready() # Should still return one (circular dependencies handled) assert next_item is not None def test_mark_complete(self, queue_manager: QueueManager) -> None: """Test marking an item as complete.""" metadata = IssueMetadata() queue_manager.enqueue(159, metadata) queue_manager.mark_complete(159) item = queue_manager.get_item(159) assert item is not None assert item.status == QueueItemStatus.COMPLETED def test_mark_complete_unblocks_dependents(self, queue_manager: QueueManager) -> None: """Test that completing an item unblocks dependent items.""" # 158 blocks 159 meta_158 = IssueMetadata(blocks=[159], blocked_by=[]) meta_159 = IssueMetadata(blocks=[], blocked_by=[158]) queue_manager.enqueue(158, meta_158) queue_manager.enqueue(159, meta_159) # Initially, 159 should not be ready item_159 = queue_manager.get_item(159) assert item_159 is not None assert item_159.ready is False # Complete 158 queue_manager.mark_complete(158) # Now 159 should be ready item_159_updated = queue_manager.get_item(159) assert item_159_updated is not None assert item_159_updated.ready is True def test_mark_complete_nonexistent_item(self, queue_manager: QueueManager) -> None: """Test marking nonexistent item as complete.""" # Should not raise error, just be a no-op queue_manager.mark_complete(999) def test_update_ready_status(self, queue_manager: QueueManager) -> None: """Test updating ready status for all items.""" # Complex dependency chain meta_158 = IssueMetadata(blocks=[159], blocked_by=[]) meta_159 = IssueMetadata(blocks=[160, 161], blocked_by=[158]) meta_160 = IssueMetadata(blocks=[], blocked_by=[159]) meta_161 = IssueMetadata(blocks=[], blocked_by=[159]) queue_manager.enqueue(158, meta_158) queue_manager.enqueue(159, meta_159) queue_manager.enqueue(160, meta_160) queue_manager.enqueue(161, meta_161) # Initially: 158 ready, others blocked item_158 = queue_manager.get_item(158) item_159 = queue_manager.get_item(159) item_160 = queue_manager.get_item(160) item_161 = queue_manager.get_item(161) assert item_158 is not None assert item_159 is not None assert item_160 is not None assert item_161 is not None assert item_158.ready is True assert item_159.ready is False assert item_160.ready is False assert item_161.ready is False # Complete 158 queue_manager.mark_complete(158) # Now: 159 ready, 160 and 161 still blocked item_159_updated = queue_manager.get_item(159) item_160_updated = queue_manager.get_item(160) item_161_updated = queue_manager.get_item(161) assert item_159_updated is not None assert item_160_updated is not None assert item_161_updated is not None assert item_159_updated.ready is True assert item_160_updated.ready is False assert item_161_updated.ready is False def test_persistence_save(self, queue_manager: QueueManager, temp_queue_file: Path) -> None: """Test saving queue to disk.""" metadata = IssueMetadata( estimated_context=50000, difficulty="medium", assigned_agent="sonnet", blocks=[161], blocked_by=[158], ) queue_manager.enqueue(159, metadata) queue_manager.save() assert temp_queue_file.exists() # Verify JSON structure with open(temp_queue_file) as f: data = json.load(f) assert "items" in data assert len(data["items"]) == 1 assert data["items"][0]["issue_number"] == 159 def test_persistence_load(self, temp_queue_file: Path) -> None: """Test loading queue from disk.""" # Create test data queue_data = { "items": [ { "issue_number": 159, "status": "pending", "ready": False, "metadata": { "estimated_context": 50000, "difficulty": "medium", "assigned_agent": "sonnet", "blocks": [161], "blocked_by": [158], }, }, { "issue_number": 160, "status": "in_progress", "ready": True, "metadata": { "estimated_context": 30000, "difficulty": "easy", "assigned_agent": "haiku", "blocks": [], "blocked_by": [], }, }, ] } with open(temp_queue_file, "w") as f: json.dump(queue_data, f) # Load queue queue_manager = QueueManager(queue_file=temp_queue_file) assert queue_manager.size() == 2 item_159 = queue_manager.get_item(159) assert item_159 is not None assert item_159.status == QueueItemStatus.PENDING assert item_159.ready is False item_160 = queue_manager.get_item(160) assert item_160 is not None assert item_160.status == QueueItemStatus.IN_PROGRESS assert item_160.ready is True def test_persistence_load_nonexistent_file(self, temp_queue_file: Path) -> None: """Test loading from nonexistent file creates empty queue.""" # Don't create the file temp_queue_file.unlink(missing_ok=True) queue_manager = QueueManager(queue_file=temp_queue_file) assert queue_manager.size() == 0 def test_persistence_autosave_on_enqueue( self, queue_manager: QueueManager, temp_queue_file: Path ) -> None: """Test that enqueue automatically saves to disk.""" metadata = IssueMetadata() queue_manager.enqueue(159, metadata) # Should auto-save assert temp_queue_file.exists() # Load in new manager to verify new_manager = QueueManager(queue_file=temp_queue_file) assert new_manager.size() == 1 def test_persistence_autosave_on_mark_complete( self, queue_manager: QueueManager, temp_queue_file: Path ) -> None: """Test that mark_complete automatically saves to disk.""" metadata = IssueMetadata() queue_manager.enqueue(159, metadata) queue_manager.mark_complete(159) # Load in new manager to verify new_manager = QueueManager(queue_file=temp_queue_file) item = new_manager.get_item(159) assert item is not None assert item.status == QueueItemStatus.COMPLETED def test_circular_dependency_detection(self, queue_manager: QueueManager) -> None: """Test handling of circular dependencies.""" # Create circular dependency: 159 -> 160 -> 161 -> 159 meta_159 = IssueMetadata(blocks=[160], blocked_by=[161]) meta_160 = IssueMetadata(blocks=[161], blocked_by=[159]) meta_161 = IssueMetadata(blocks=[159], blocked_by=[160]) queue_manager.enqueue(159, meta_159) queue_manager.enqueue(160, meta_160) queue_manager.enqueue(161, meta_161) # Should still be able to get next ready (break the cycle gracefully) next_item = queue_manager.get_next_ready() assert next_item is not None def test_list_all_items(self, queue_manager: QueueManager) -> None: """Test listing all items in queue.""" meta1 = IssueMetadata(assigned_agent="sonnet") meta2 = IssueMetadata(assigned_agent="haiku") meta3 = IssueMetadata(assigned_agent="glm") queue_manager.enqueue(159, meta1) queue_manager.enqueue(160, meta2) queue_manager.enqueue(161, meta3) all_items = queue_manager.list_all() assert len(all_items) == 3 issue_numbers = [item.issue_number for item in all_items] assert 159 in issue_numbers assert 160 in issue_numbers assert 161 in issue_numbers def test_list_ready_items(self, queue_manager: QueueManager) -> None: """Test listing only ready items.""" meta_ready = IssueMetadata(blocked_by=[]) meta_blocked = IssueMetadata(blocked_by=[158]) queue_manager.enqueue(159, meta_ready) queue_manager.enqueue(160, meta_ready) queue_manager.enqueue(161, meta_blocked) ready_items = queue_manager.list_ready() assert len(ready_items) == 2 issue_numbers = [item.issue_number for item in ready_items] assert 159 in issue_numbers assert 160 in issue_numbers assert 161 not in issue_numbers def test_get_item_nonexistent(self, queue_manager: QueueManager) -> None: """Test getting an item that doesn't exist.""" item = queue_manager.get_item(999) assert item is None def test_status_transitions(self, queue_manager: QueueManager) -> None: """Test valid status transitions.""" metadata = IssueMetadata() queue_manager.enqueue(159, metadata) # PENDING -> IN_PROGRESS item = queue_manager.get_item(159) assert item is not None assert item.status == QueueItemStatus.PENDING queue_manager.mark_in_progress(159) item = queue_manager.get_item(159) assert item is not None assert item.status == QueueItemStatus.IN_PROGRESS # IN_PROGRESS -> COMPLETED queue_manager.mark_complete(159) item = queue_manager.get_item(159) assert item is not None assert item.status == QueueItemStatus.COMPLETED class TestQueueCorruptionHandling: """Tests for queue file corruption handling.""" @pytest.fixture def temp_queue_file(self) -> Generator[Path, None, None]: """Create a temporary file for queue persistence.""" with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".json") as f: temp_path = Path(f.name) yield temp_path # Cleanup - remove main file and any backup files if temp_path.exists(): temp_path.unlink() # Clean up backup files for backup in temp_path.parent.glob(f"{temp_path.stem}.corrupted.*.json"): backup.unlink() def test_corrupted_json_logs_error_and_creates_backup( self, temp_queue_file: Path, caplog: pytest.LogCaptureFixture ) -> None: """Test that corrupted JSON file triggers logging and backup creation.""" # Write invalid JSON to the file with open(temp_queue_file, "w") as f: f.write("{ invalid json content }") import logging with caplog.at_level(logging.ERROR): queue_manager = QueueManager(queue_file=temp_queue_file) # Verify queue is empty after corruption assert queue_manager.size() == 0 # Verify error was logged assert "Queue file corruption detected" in caplog.text assert "JSONDecodeError" in caplog.text # Verify backup file was created backup_files = list(temp_queue_file.parent.glob(f"{temp_queue_file.stem}.corrupted.*.json")) assert len(backup_files) == 1 assert "Corrupted queue file backed up" in caplog.text # Verify backup contains original corrupted content with open(backup_files[0]) as f: backup_content = f.read() assert "invalid json content" in backup_content def test_corrupted_structure_logs_error_and_creates_backup( self, temp_queue_file: Path, caplog: pytest.LogCaptureFixture ) -> None: """Test that valid JSON with invalid structure triggers logging and backup.""" # Write valid JSON but with missing required fields with open(temp_queue_file, "w") as f: json.dump( { "items": [ { "issue_number": 159, # Missing "status", "ready", "metadata" fields } ] }, f, ) import logging with caplog.at_level(logging.ERROR): queue_manager = QueueManager(queue_file=temp_queue_file) # Verify queue is empty after corruption assert queue_manager.size() == 0 # Verify error was logged (KeyError for missing fields) assert "Queue file corruption detected" in caplog.text assert "KeyError" in caplog.text # Verify backup file was created backup_files = list(temp_queue_file.parent.glob(f"{temp_queue_file.stem}.corrupted.*.json")) assert len(backup_files) == 1 def test_invalid_status_value_logs_error_and_creates_backup( self, temp_queue_file: Path, caplog: pytest.LogCaptureFixture ) -> None: """Test that invalid enum value triggers logging and backup.""" # Write valid JSON but with invalid status enum value with open(temp_queue_file, "w") as f: json.dump( { "items": [ { "issue_number": 159, "status": "invalid_status", "ready": True, "metadata": { "estimated_context": 50000, "difficulty": "medium", "assigned_agent": "sonnet", "blocks": [], "blocked_by": [], }, } ] }, f, ) import logging with caplog.at_level(logging.ERROR): queue_manager = QueueManager(queue_file=temp_queue_file) # Verify queue is empty after corruption assert queue_manager.size() == 0 # Verify error was logged (ValueError for invalid enum) assert "Queue file corruption detected" in caplog.text assert "ValueError" in caplog.text # Verify backup file was created backup_files = list(temp_queue_file.parent.glob(f"{temp_queue_file.stem}.corrupted.*.json")) assert len(backup_files) == 1 def test_queue_reset_logged_after_corruption( self, temp_queue_file: Path, caplog: pytest.LogCaptureFixture ) -> None: """Test that queue reset is logged after handling corruption.""" # Write invalid JSON with open(temp_queue_file, "w") as f: f.write("not valid json") import logging with caplog.at_level(logging.ERROR): QueueManager(queue_file=temp_queue_file) # Verify the reset was logged assert "Queue reset to empty state after corruption" in caplog.text