feat: SQLite event storage with pagination and filtering
Implement SQLite-based event persistence as specified in sqlite-event-manager.md.
## Changes
### Backend
- **EventStorage** (`src/classes/event_storage.py`): New SQLite storage layer
- Cursor-based pagination with compound cursor `{month_stamp}_{rowid}`
- Avatar filtering (single and pair queries)
- Major/minor event separation
- Cleanup API with `keep_major` and `before_month_stamp` filters
- **EventManager** (`src/classes/event_manager.py`): Refactored to use SQLite
- Delegates to EventStorage for persistence
- Memory fallback mode for testing
- New `get_events_paginated()` method
- **API** (`src/server/main.py`):
- `GET /api/events` - Paginated event retrieval with filtering
- `DELETE /api/events/cleanup` - User-triggered cleanup
### Frontend
- **EventPanel.vue**: Scroll-to-load pagination, dual-person filter UI
- **world.ts**: Event state management with pagination
- **game.ts**: New API client methods
### Testing
- 81 new tests for EventStorage, EventManager, and API
- Added `pytest-asyncio` and `httpx` to requirements.txt
## Known Issues: Save/Load is Currently Broken
After loading a saved game, the following issues occur:
1. **Wrong database used**: API returns events from the startup database instead
of the loaded save's `_events.db` file
2. **Events from wrong time period**: Shows events from year 115 when loaded
save is at year 114
3. **Pagination broken after load**: `has_more` returns `False` despite hundreds
of events in the saved database
4. **Filter functionality broken**: Character selection filter stops working
after loading a game
Root cause: `load_game.py` does not properly switch the EventManager's database
connection to the loaded save's events database.
This commit is contained in:
389
tests/test_api_events.py
Normal file
389
tests/test_api_events.py
Normal file
@@ -0,0 +1,389 @@
|
||||
"""
|
||||
Tests for the Events API endpoints.
|
||||
|
||||
Covers:
|
||||
- GET /api/events - pagination and filtering
|
||||
- DELETE /api/events/cleanup - event cleanup
|
||||
|
||||
Uses FastAPI TestClient to test the API directly.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from src.classes.world import World
|
||||
from src.classes.map import Map
|
||||
from src.classes.tile import TileType
|
||||
from src.classes.calendar import Month, Year, create_month_stamp
|
||||
from src.classes.event import Event
|
||||
from src.classes.event_storage import EventStorage
|
||||
from src.classes.event_manager import EventManager
|
||||
|
||||
|
||||
def create_test_map():
|
||||
"""Create a simple 10x10 plain map for testing."""
|
||||
m = Map(width=10, height=10)
|
||||
for x in range(10):
|
||||
for y in range(10):
|
||||
m.create_tile(x, y, TileType.PLAIN)
|
||||
return m
|
||||
|
||||
|
||||
def make_event(
|
||||
year: int,
|
||||
month: int,
|
||||
content: str,
|
||||
avatar_ids: list[str] | None = None,
|
||||
is_major: bool = False,
|
||||
is_story: bool = False,
|
||||
) -> Event:
|
||||
"""Helper to create an Event."""
|
||||
month_stamp = create_month_stamp(Year(year), Month(month))
|
||||
return Event(
|
||||
month_stamp=month_stamp,
|
||||
content=content,
|
||||
related_avatars=avatar_ids,
|
||||
is_major=is_major,
|
||||
is_story=is_story,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_db_path():
|
||||
"""Create a temporary database file path."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
yield Path(tmpdir) / "test_events.db"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_world_with_events(temp_db_path):
|
||||
"""Create a mock world with event manager."""
|
||||
game_map = create_test_map()
|
||||
month_stamp = create_month_stamp(Year(100), Month.JANUARY)
|
||||
|
||||
world = World.create_with_db(
|
||||
map=game_map,
|
||||
month_stamp=month_stamp,
|
||||
events_db_path=temp_db_path,
|
||||
)
|
||||
|
||||
# Add some test events
|
||||
world.event_manager.add_event(make_event(100, 1, "Event 1", ["a1"]))
|
||||
world.event_manager.add_event(make_event(100, 2, "Event 2", ["a2"]))
|
||||
world.event_manager.add_event(make_event(100, 3, "Event between", ["a1", "a2"]))
|
||||
world.event_manager.add_event(make_event(100, 4, "Major event", ["a1"], is_major=True))
|
||||
world.event_manager.add_event(make_event(100, 5, "Story event", ["a1"], is_story=True))
|
||||
|
||||
yield world
|
||||
|
||||
world.event_manager.close()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client_with_world(mock_world_with_events):
|
||||
"""Create a TestClient with mocked game_instance."""
|
||||
# We need to patch the game_instance in main.py
|
||||
from src.server import main
|
||||
|
||||
# Backup original
|
||||
original_instance = main.game_instance.copy()
|
||||
|
||||
# Set up mock
|
||||
main.game_instance["world"] = mock_world_with_events
|
||||
main.game_instance["sim"] = MagicMock()
|
||||
main.game_instance["is_paused"] = True
|
||||
|
||||
client = TestClient(main.app)
|
||||
yield client
|
||||
|
||||
# Restore
|
||||
main.game_instance.update(original_instance)
|
||||
|
||||
|
||||
class TestGetEventsAPI:
|
||||
"""Tests for GET /api/events endpoint."""
|
||||
|
||||
def test_get_events_returns_all(self, client_with_world):
|
||||
"""Test getting all events without filters."""
|
||||
response = client_with_world.get("/api/events")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
||||
assert "events" in data
|
||||
assert "next_cursor" in data
|
||||
assert "has_more" in data
|
||||
|
||||
assert len(data["events"]) == 5
|
||||
assert data["has_more"] is False
|
||||
|
||||
def test_get_events_with_limit(self, client_with_world):
|
||||
"""Test pagination with limit parameter."""
|
||||
response = client_with_world.get("/api/events?limit=2")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
||||
assert len(data["events"]) == 2
|
||||
assert data["has_more"] is True
|
||||
assert data["next_cursor"] is not None
|
||||
|
||||
def test_get_events_pagination_cursor(self, client_with_world):
|
||||
"""Test pagination with cursor."""
|
||||
# First page
|
||||
response1 = client_with_world.get("/api/events?limit=3")
|
||||
data1 = response1.json()
|
||||
|
||||
cursor = data1["next_cursor"]
|
||||
assert cursor is not None
|
||||
|
||||
# Second page
|
||||
response2 = client_with_world.get(f"/api/events?limit=3&cursor={cursor}")
|
||||
data2 = response2.json()
|
||||
|
||||
assert len(data2["events"]) == 2 # 5 total, 3 in first page
|
||||
|
||||
# No overlap in event IDs
|
||||
ids1 = {e["id"] for e in data1["events"]}
|
||||
ids2 = {e["id"] for e in data2["events"]}
|
||||
assert ids1.isdisjoint(ids2)
|
||||
|
||||
def test_get_events_by_avatar(self, client_with_world):
|
||||
"""Test filtering by single avatar."""
|
||||
response = client_with_world.get("/api/events?avatar_id=a1")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
||||
# a1 has: Event 1, Event between, Major event, Story event
|
||||
assert len(data["events"]) == 4
|
||||
|
||||
for event in data["events"]:
|
||||
assert "a1" in event["related_avatar_ids"]
|
||||
|
||||
def test_get_events_by_avatar_pair(self, client_with_world):
|
||||
"""Test filtering by avatar pair."""
|
||||
response = client_with_world.get("/api/events?avatar_id_1=a1&avatar_id_2=a2")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
||||
# Only "Event between" involves both
|
||||
assert len(data["events"]) == 1
|
||||
assert data["events"][0]["content"] == "Event between"
|
||||
|
||||
def test_get_events_returns_correct_structure(self, client_with_world):
|
||||
"""Test that events have correct structure."""
|
||||
response = client_with_world.get("/api/events?limit=1")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
||||
assert len(data["events"]) == 1
|
||||
event = data["events"][0]
|
||||
|
||||
# Check required fields
|
||||
assert "id" in event
|
||||
assert "text" in event
|
||||
assert "content" in event
|
||||
assert "year" in event
|
||||
assert "month" in event
|
||||
assert "month_stamp" in event
|
||||
assert "related_avatar_ids" in event
|
||||
assert "is_major" in event
|
||||
assert "is_story" in event
|
||||
|
||||
def test_get_events_no_world(self):
|
||||
"""Test API response when no world is loaded."""
|
||||
from src.server import main
|
||||
|
||||
original = main.game_instance.copy()
|
||||
main.game_instance["world"] = None
|
||||
|
||||
try:
|
||||
client = TestClient(main.app)
|
||||
response = client.get("/api/events")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
||||
assert data["events"] == []
|
||||
assert data["next_cursor"] is None
|
||||
assert data["has_more"] is False
|
||||
finally:
|
||||
main.game_instance.update(original)
|
||||
|
||||
|
||||
class TestCleanupEventsAPI:
|
||||
"""Tests for DELETE /api/events/cleanup endpoint."""
|
||||
|
||||
def test_cleanup_deletes_minor_events(self, client_with_world, mock_world_with_events):
|
||||
"""Test that cleanup deletes minor events."""
|
||||
initial_count = mock_world_with_events.event_manager.count()
|
||||
|
||||
response = client_with_world.delete("/api/events/cleanup")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
||||
# Should delete non-major events (4 of them)
|
||||
assert data["deleted"] == 4
|
||||
assert mock_world_with_events.event_manager.count() == 1
|
||||
|
||||
def test_cleanup_with_keep_major_false(self, client_with_world, mock_world_with_events):
|
||||
"""Test cleanup with keep_major=false deletes all."""
|
||||
response = client_with_world.delete("/api/events/cleanup?keep_major=false")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
||||
assert data["deleted"] == 5
|
||||
assert mock_world_with_events.event_manager.count() == 0
|
||||
|
||||
def test_cleanup_with_before_month_stamp(self, client_with_world, mock_world_with_events):
|
||||
"""Test cleanup with before_month_stamp filter."""
|
||||
# Add an older event
|
||||
old_event = make_event(50, 1, "Old event", is_major=False)
|
||||
mock_world_with_events.event_manager.add_event(old_event)
|
||||
|
||||
before_stamp = int(create_month_stamp(Year(99), Month.JANUARY))
|
||||
response = client_with_world.delete(
|
||||
f"/api/events/cleanup?keep_major=false&before_month_stamp={before_stamp}"
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
||||
# Only the old event should be deleted
|
||||
assert data["deleted"] == 1
|
||||
assert mock_world_with_events.event_manager.count() == 5
|
||||
|
||||
def test_cleanup_no_world(self):
|
||||
"""Test cleanup response when no world is loaded."""
|
||||
from src.server import main
|
||||
|
||||
original = main.game_instance.copy()
|
||||
main.game_instance["world"] = None
|
||||
|
||||
try:
|
||||
client = TestClient(main.app)
|
||||
response = client.delete("/api/events/cleanup")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
||||
assert data["deleted"] == 0
|
||||
assert "error" in data
|
||||
finally:
|
||||
main.game_instance.update(original)
|
||||
|
||||
|
||||
class TestEventsPaginationIntegration:
|
||||
"""Integration tests for events pagination."""
|
||||
|
||||
def test_full_pagination_cycle(self, temp_db_path):
|
||||
"""Test complete pagination through many events."""
|
||||
from src.server import main
|
||||
|
||||
# Create world with many events
|
||||
game_map = create_test_map()
|
||||
month_stamp = create_month_stamp(Year(100), Month.JANUARY)
|
||||
world = World.create_with_db(
|
||||
map=game_map,
|
||||
month_stamp=month_stamp,
|
||||
events_db_path=temp_db_path,
|
||||
)
|
||||
|
||||
# Add 50 events
|
||||
for i in range(50):
|
||||
world.event_manager.add_event(
|
||||
make_event(100 + (i // 12), (i % 12) + 1, f"Event {i}", ["a1"])
|
||||
)
|
||||
|
||||
original = main.game_instance.copy()
|
||||
main.game_instance["world"] = world
|
||||
main.game_instance["sim"] = MagicMock()
|
||||
|
||||
try:
|
||||
client = TestClient(main.app)
|
||||
|
||||
all_event_ids = set()
|
||||
cursor = None
|
||||
page_count = 0
|
||||
|
||||
while True:
|
||||
url = "/api/events?limit=15"
|
||||
if cursor:
|
||||
url += f"&cursor={cursor}"
|
||||
|
||||
response = client.get(url)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
||||
for event in data["events"]:
|
||||
assert event["id"] not in all_event_ids, "Duplicate event in pagination"
|
||||
all_event_ids.add(event["id"])
|
||||
|
||||
page_count += 1
|
||||
|
||||
if not data["has_more"]:
|
||||
break
|
||||
|
||||
cursor = data["next_cursor"]
|
||||
|
||||
# Should have gotten all 50 events
|
||||
assert len(all_event_ids) == 50
|
||||
# Should have taken 4 pages (15+15+15+5)
|
||||
assert page_count == 4
|
||||
|
||||
finally:
|
||||
world.event_manager.close()
|
||||
main.game_instance.update(original)
|
||||
|
||||
def test_events_order_consistency(self, temp_db_path):
|
||||
"""Test that events maintain consistent ordering across pages."""
|
||||
from src.server import main
|
||||
|
||||
game_map = create_test_map()
|
||||
month_stamp = create_month_stamp(Year(100), Month.JANUARY)
|
||||
world = World.create_with_db(
|
||||
map=game_map,
|
||||
month_stamp=month_stamp,
|
||||
events_db_path=temp_db_path,
|
||||
)
|
||||
|
||||
# Add events with known order
|
||||
for i in range(10):
|
||||
world.event_manager.add_event(
|
||||
make_event(100, i + 1, f"Event {i}")
|
||||
)
|
||||
|
||||
original = main.game_instance.copy()
|
||||
main.game_instance["world"] = world
|
||||
main.game_instance["sim"] = MagicMock()
|
||||
|
||||
try:
|
||||
client = TestClient(main.app)
|
||||
|
||||
# Get events in two pages
|
||||
response1 = client.get("/api/events?limit=5")
|
||||
response2 = client.get(f"/api/events?limit=5&cursor={response1.json()['next_cursor']}")
|
||||
|
||||
page1 = response1.json()["events"]
|
||||
page2 = response2.json()["events"]
|
||||
|
||||
# Events should be in descending order (newest first)
|
||||
all_events = page1 + page2
|
||||
month_stamps = [e["month_stamp"] for e in all_events]
|
||||
|
||||
# Each month_stamp should be >= the next (descending order)
|
||||
for i in range(len(month_stamps) - 1):
|
||||
assert month_stamps[i] >= month_stamps[i + 1]
|
||||
|
||||
finally:
|
||||
world.event_manager.close()
|
||||
main.game_instance.update(original)
|
||||
Reference in New Issue
Block a user