Simplified database layer and consolidated Markov writes into a single transaction.
CI / Formatting (push) Successful in 5s
CI / Linting (push) Successful in 5s
CI / Tests (push) Successful in 16s
CI / Type Checking (push) Successful in 10s
CI / Spelling (push) Successful in 5s

This commit is contained in:
2026-03-17 17:07:28 -04:00
parent 56d3c36c7d
commit 6aa8527adc
5 changed files with 109 additions and 219 deletions
+42 -90
View File
@@ -15,16 +15,14 @@
"""Unit tests for the Database class.
Tests cover Database.connect (pragmas, schema), markov start word and
transition CRUD, image storage, flag CRUD, channel ingestion tracking,
and the auto-commit write-batching mechanism.
transition CRUD, image storage, flag CRUD, and channel ingestion tracking.
"""
import asyncio
from typing import TYPE_CHECKING
import pytest
from crabstero.database import AUTO_COMMIT_WRITE_THRESHOLD, Database
from crabstero.database import Database
if TYPE_CHECKING:
from pathlib import Path
@@ -33,13 +31,6 @@ if TYPE_CHECKING:
class TestConnect:
"""Database.connect creates a configured SQLite database."""
async def test_wal_mode(self, db: Database) -> None:
"""Journal mode is set to WAL."""
async with db._connection.execute("PRAGMA journal_mode") as cursor:
row = await cursor.fetchone()
assert row is not None
assert row[0] == "wal"
async def test_synchronous_normal(self, db: Database) -> None:
"""Synchronous mode is set to NORMAL."""
async with db._connection.execute("PRAGMA synchronous") as cursor:
@@ -62,42 +53,34 @@ class TestConnect:
]
class TestMarkovStartWords:
"""Start word storage and random retrieval."""
class TestAddMarkovData:
"""Markov start word and transition storage via add_markov_data."""
async def test_add_and_retrieve(self, db: Database) -> None:
async def test_stores_start_word(self, db: Database) -> None:
"""Inserted start word can be retrieved by channel."""
await db.add_start_words_batch([(1, 100, "Hello")])
await db.commit()
await db.add_markov_data([(1, 100, "Hello")], [])
result = await db.get_random_start_word(1)
assert result == "Hello"
async def test_returns_none_when_empty(self, db: Database) -> None:
"""Returns None for a channel with no start words."""
result = await db.get_random_start_word(999)
assert result is None
async def test_empty_batch_stores_nothing(self, db: Database) -> None:
"""An empty batch does not store any start words."""
await db.add_start_words_batch([])
await db.commit()
assert await db.get_random_start_word(1) is None
class TestMarkovTransitions:
"""Word transition storage and random retrieval."""
async def test_add_and_retrieve(self, db: Database) -> None:
async def test_stores_transition(self, db: Database) -> None:
"""Inserted transition can be retrieved by channel and word."""
await db.add_transitions_batch([(1, 100, "Hello", "world.")])
await db.commit()
await db.add_markov_data([], [(1, 100, "Hello", "world.")])
result = await db.get_random_next_word(1, "Hello")
assert result == "world."
async def test_returns_none_when_empty(self, db: Database) -> None:
"""Returns None when no transitions exist for the word."""
result = await db.get_random_next_word(1, "nonexistent")
assert result is None
async def test_stores_both_in_single_call(self, db: Database) -> None:
"""Start words and transitions are stored in a single call."""
await db.add_markov_data(
[(1, 100, "Hello")],
[(1, 100, "Hello", "world.")],
)
assert await db.get_random_start_word(1) == "Hello"
assert await db.get_random_next_word(1, "Hello") == "world."
async def test_empty_lists_store_nothing(self, db: Database) -> None:
"""Empty lists do not store any data."""
await db.add_markov_data([], [])
assert await db.get_random_start_word(1) is None
@pytest.mark.parametrize(
"completing_word",
@@ -105,36 +88,38 @@ class TestMarkovTransitions:
pytest.param("world.", id="period"),
pytest.param("world!", id="exclamation"),
pytest.param("world?", id="question"),
pytest.param("world§", id="section-sign"),
pytest.param("world\u00a7", id="section-sign"),
],
)
async def test_completing_word_filters_punctuation(
self, db: Database, completing_word: str
) -> None:
"""get_random_completing_next_word only returns sentence-ending words."""
await db.add_transitions_batch(
await db.add_markov_data(
[],
[
(1, 100, "Hello", "beautiful"),
(1, 100, "Hello", completing_word),
]
],
)
await db.commit()
for _ in range(100):
result = await db.get_random_completing_next_word(1, "Hello")
assert result == completing_word
async def test_completing_returns_none_without_match(self, db: Database) -> None:
"""Returns None when no transitions end with sentence punctuation."""
await db.add_transitions_batch([(1, 100, "Hello", "beautiful")])
await db.commit()
await db.add_markov_data([], [(1, 100, "Hello", "beautiful")])
result = await db.get_random_completing_next_word(1, "Hello")
assert result is None
async def test_empty_batch_stores_nothing(self, db: Database) -> None:
"""An empty batch does not store any transitions."""
await db.add_transitions_batch([])
await db.commit()
assert await db.get_random_next_word(1, "Hello") is None
class TestMarkovReadMethods:
"""Read behavior when no data has been stored."""
async def test_returns_none_when_empty(self, db: Database) -> None:
"""Returns None for channels with no data."""
assert await db.get_random_start_word(999) is None
assert await db.get_random_next_word(1, "nonexistent") is None
class TestImages:
@@ -143,7 +128,6 @@ class TestImages:
async def test_add_and_retrieve(self, db: Database) -> None:
"""Inserted image URL can be retrieved by channel."""
await db.add_image(1, 100, "https://example.com/cat.png")
await db.commit()
result = await db.get_random_image(1)
assert result == "https://example.com/cat.png"
@@ -159,7 +143,6 @@ class TestFlags:
async def test_set_and_check(self, db: Database) -> None:
"""A set flag is reported as set."""
await db.set_flag("channel", "123", "noReply")
await db.commit()
assert await db.is_flag_set("channel", "123", "noReply") is True
async def test_unset_flag_is_false(self, db: Database) -> None:
@@ -169,16 +152,13 @@ class TestFlags:
async def test_clear_flag(self, db: Database) -> None:
"""A cleared flag is no longer reported as set."""
await db.set_flag("channel", "123", "noReply")
await db.commit()
await db.clear_flag("channel", "123", "noReply")
await db.commit()
assert await db.is_flag_set("channel", "123", "noReply") is False
async def test_set_idempotent(self, db: Database) -> None:
"""Setting the same flag twice does not raise."""
await db.set_flag("channel", "123", "noReply")
await db.set_flag("channel", "123", "noReply")
await db.commit()
assert await db.is_flag_set("channel", "123", "noReply") is True
@@ -188,7 +168,6 @@ class TestChannelIngestion:
async def test_mark_and_check(self, db: Database) -> None:
"""A marked channel is reported as ingested."""
await db.mark_channel_ingested(42)
await db.commit()
assert await db.is_channel_ingested(42) is True
async def test_not_ingested_by_default(self, db: Database) -> None:
@@ -199,47 +178,20 @@ class TestChannelIngestion:
"""Marking the same channel twice does not raise."""
await db.mark_channel_ingested(42)
await db.mark_channel_ingested(42)
await db.commit()
assert await db.is_channel_ingested(42) is True
class TestAutoCommit:
"""Write batching and automatic commit behavior."""
class TestWriteDurability:
"""Writes persist across close and reopen."""
async def test_commits_after_threshold(self, db: Database) -> None:
"""Pending writes reset to zero after reaching the write threshold."""
for i in range(AUTO_COMMIT_WRITE_THRESHOLD):
await db.add_start_words_batch([(1, 100, f"word{i}")])
assert db._pending_writes == 0
assert await db.get_random_start_word(1) is not None
async def test_close_commits_pending(self, tmp_path: Path) -> None:
"""close() commits any pending writes before closing."""
db_path = str(tmp_path / "close_test.db")
async def test_markov_data_survives_reopen(self, tmp_path: Path) -> None:
"""Data written via add_markov_data is durable after close/reopen."""
db_path = str(tmp_path / "durability.db")
db = await Database.connect(db_path)
await db.add_start_words_batch([(1, 100, "hello")])
assert db._pending_writes > 0
await db.add_markov_data([(1, 100, "Hello")], [(1, 100, "Hello", "world.")])
await db.close()
# Reopen and verify data persisted.
db2 = await Database.connect(db_path)
result = await db2.get_random_start_word(1)
assert await db2.get_random_start_word(1) == "Hello"
assert await db2.get_random_next_word(1, "Hello") == "world."
await db2.close()
assert result == "hello"
async def test_close_without_pending_writes(self, tmp_path: Path) -> None:
"""close() succeeds when there are no pending writes."""
db = await Database.connect(str(tmp_path / "clean_close.db"))
await db.close()
async def test_flush_timer_commits(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Background flush timer commits pending writes after timeout."""
monkeypatch.setattr("crabstero.database.AUTO_COMMIT_TIMEOUT_SECONDS", 0.05)
db = await Database.connect(str(tmp_path / "timer_test.db"))
await db.add_start_words_batch([(1, 100, "hello")])
assert db._pending_writes > 0
await asyncio.sleep(0.1)
assert db._pending_writes == 0
await db.close()