Expanded integration coverage and enforced test categories.
Audit / Dependencies (push) Successful in 9s
CI / Formatting (push) Successful in 5s
CI / Linting (push) Successful in 4s
CI / Tests (push) Successful in 1m7s
CI / Type Checking (push) Successful in 12s
CI / Spelling (push) Successful in 8s

This commit is contained in:
2026-06-18 16:10:40 -04:00
parent 49062159f9
commit 5bbb0bbde5
22 changed files with 2433 additions and 550 deletions
+77 -66
View File
@@ -14,39 +14,59 @@
"""Unit tests for the IngestCache TTL cache."""
import asyncio
import time
import pytest
from crabstero.cache import CachedMessage, IngestCache
class _FakeClock:
"""Mutable monotonic clock for deterministic cache expiry tests."""
def __init__(self) -> None:
self.now = 1000.0
def __call__(self) -> float:
"""Return the current fake monotonic timestamp."""
return self.now
def advance(self, seconds: float) -> None:
"""Move the fake clock forward by the given number of seconds."""
self.now += seconds
@pytest.fixture
def cached_message() -> CachedMessage:
"""Return a representative cache entry for put/pop behavior tests."""
return CachedMessage(
channel_id=1,
user_id=100,
content="Hello world",
embed_texts=["embed title"],
image_urls=["https://example.com/cat.png"],
)
@pytest.fixture
def fake_clock(monkeypatch: pytest.MonkeyPatch) -> _FakeClock:
"""Patch the cache's clock and return a controllable timestamp source."""
clock = _FakeClock()
monkeypatch.setattr("crabstero.cache.time.monotonic", clock)
return clock
class TestIngestCache:
"""IngestCache put, pop, and expiry behavior."""
def test_put_and_pop(self) -> None:
def test_put_and_pop(self, cached_message: CachedMessage) -> None:
"""A cached message can be retrieved by message ID."""
cache = IngestCache()
entry = CachedMessage(
channel_id=1,
user_id=100,
content="Hello world",
embed_texts=["embed title"],
image_urls=["https://example.com/cat.png"],
)
cache.put(12345, entry)
assert cache.pop(12345) is entry
cache.put(12345, cached_message)
assert cache.pop(12345) is cached_message
def test_pop_removes_entry(self) -> None:
def test_pop_removes_entry(self, cached_message: CachedMessage) -> None:
"""Popping an entry removes it from the cache."""
cache = IngestCache()
entry = CachedMessage(
channel_id=1,
user_id=100,
content="Hello",
embed_texts=[],
image_urls=[],
)
cache.put(12345, entry)
cache.put(12345, cached_message)
cache.pop(12345)
assert cache.pop(12345) is None
@@ -55,64 +75,55 @@ class TestIngestCache:
cache = IngestCache()
assert cache.pop(99999) is None
def test_expired_entry_not_returned(self) -> None:
def test_expired_entry_not_returned(
self,
cached_message: CachedMessage,
fake_clock: _FakeClock,
) -> None:
"""An expired entry is not returned by pop."""
cache = IngestCache(ttl_seconds=0.01)
entry = CachedMessage(
channel_id=1,
user_id=100,
content="Hello",
embed_texts=[],
image_urls=[],
)
cache.put(12345, entry)
time.sleep(0.02)
cache = IngestCache(ttl_seconds=10)
cache.put(12345, cached_message)
fake_clock.advance(11)
assert cache.pop(12345) is None
def test_cleanup_removes_expired(self) -> None:
def test_cleanup_removes_expired(
self,
cached_message: CachedMessage,
fake_clock: _FakeClock,
) -> None:
"""Cleanup evicts all expired entries."""
cache = IngestCache(ttl_seconds=0.01)
entry = CachedMessage(
channel_id=1,
user_id=100,
content="Hello",
embed_texts=[],
image_urls=[],
)
cache.put(1, entry)
cache.put(2, entry)
time.sleep(0.02)
cache = IngestCache(ttl_seconds=10)
cache.put(1, cached_message)
cache.put(2, cached_message)
fake_clock.advance(11)
cache._cleanup()
assert cache.pop(1) is None
assert cache.pop(2) is None
def test_cleanup_keeps_unexpired(self) -> None:
def test_cleanup_keeps_unexpired(self, cached_message: CachedMessage) -> None:
"""Cleanup does not evict entries that are still valid."""
cache = IngestCache(ttl_seconds=300)
entry = CachedMessage(
channel_id=1,
user_id=100,
content="Hello",
embed_texts=[],
image_urls=[],
)
cache.put(1, entry)
cache.put(1, cached_message)
cache._cleanup()
assert cache.pop(1) is entry
assert cache.pop(1) is cached_message
async def test_start_and_stop(self) -> None:
"""The background cleanup task can be started and stopped."""
cache = IngestCache(ttl_seconds=0, cleanup_interval_seconds=0.01)
entry = CachedMessage(
channel_id=1,
user_id=100,
content="Hello",
embed_texts=[],
image_urls=[],
)
cache.put(1, entry)
cache = IngestCache()
cache.start()
await asyncio.sleep(0.05)
assert cache._task is not None
await cache.stop()
# Entry should have been cleaned up by the background task.
assert cache.pop(1) is None
assert cache._task is None
async def test_start_is_idempotent(self) -> None:
"""Starting an already-started cache keeps the existing cleanup task."""
cache = IngestCache()
cache.start()
try:
task = cache._task
cache.start()
assert cache._task is task
finally:
await cache.stop()
+103 -112
View File
@@ -20,7 +20,6 @@ Tests cover the CLI helpers and runtime orchestration from crabstero.cli.
import asyncio
import os
import signal
import socket
import sys
from typing import TYPE_CHECKING, Any, ClassVar, Self
@@ -43,12 +42,37 @@ if TYPE_CHECKING:
class _FakeCrabstero:
"""Fake bot that records CLI lifecycle calls without touching Discord.
Tests can inject callbacks during login/connect and exceptions during
login/close to exercise CLI shutdown ordering and propagation paths.
"""
events: ClassVar[list[object]] = []
login_error: ClassVar[BaseException | None] = None
login_callback: ClassVar[Callable[[], None] | None] = None
close_error: ClassVar[BaseException | None] = None
connect_callback: ClassVar[Callable[[], None] | None] = None
@classmethod
def reset(
cls,
*,
login_error: BaseException | None = None,
close_error: BaseException | None = None,
) -> list[object]:
"""Clear scenario state and return the shared lifecycle event log.
The event log is shared across fake instances so tests can assert the
ordering of construction, login, notifications, connect, and close.
"""
cls.events = []
cls.login_error = login_error
cls.login_callback = None
cls.close_error = close_error
cls.connect_callback = None
return cls.events
def __init__(self, **kwargs: object) -> None:
self._closed = False
self.events.append(("init", kwargs))
@@ -94,34 +118,68 @@ class _FakeCrabstero:
return self._closed
def _run_coroutine(
coro: Coroutine[Any, Any, signal.Signals | None],
) -> signal.Signals | None:
return asyncio.run(coro)
def _prepare_main_test(
@pytest.fixture
def prepared_main(
monkeypatch: pytest.MonkeyPatch,
*,
login_error: BaseException | None = None,
close_error: BaseException | None = None,
) -> list[object]:
_FakeCrabstero.events = []
_FakeCrabstero.login_error = login_error
_FakeCrabstero.login_callback = None
_FakeCrabstero.close_error = close_error
_FakeCrabstero.connect_callback = None
) -> Callable[..., list[object]]:
"""Return a factory that prepares cli.main for fake-bot lifecycle tests.
def sd_notify(state: str) -> None:
_FakeCrabstero.events.append(("notify", state))
Each factory call resets the fake bot, routes systemd notifications into
the event log, disables watchdog setup, and makes uvloop.run execute the
coroutine synchronously through asyncio.run.
"""
monkeypatch.setattr(sys, "argv", ["crabstero", "--token", "cli-token"])
monkeypatch.setattr(cli, "Crabstero", _FakeCrabstero)
monkeypatch.setattr("crabstero.cli.uvloop.run", _run_coroutine)
monkeypatch.setattr(cli, "_sd_notify", sd_notify)
monkeypatch.setattr(cli, "_watchdog_interval", lambda: None)
def prepare(
*,
login_error: BaseException | None = None,
close_error: BaseException | None = None,
) -> list[object]:
events = _FakeCrabstero.reset(
login_error=login_error,
close_error=close_error,
)
return _FakeCrabstero.events
def sd_notify(state: str) -> None:
events.append(("notify", state))
monkeypatch.setattr(sys, "argv", ["crabstero", "--token", "cli-token"])
monkeypatch.setattr(cli, "Crabstero", _FakeCrabstero)
monkeypatch.setattr("crabstero.cli.uvloop.run", asyncio.run)
monkeypatch.setattr(cli, "_sd_notify", sd_notify)
monkeypatch.setattr(cli, "_watchdog_interval", lambda: None)
return events
return prepare
@pytest.fixture
def signal_handlers(
monkeypatch: pytest.MonkeyPatch,
) -> dict[int, Callable[[], None]]:
"""Patch signal registration and return captured signal callbacks.
The returned mapping lets tests invoke registered SIGINT/SIGTERM handlers
directly and assert that cleanup removes them.
"""
handlers: dict[int, Callable[[], None]] = {}
class SignalLoop:
"""Minimal event-loop facade that stores installed signal handlers."""
def add_signal_handler(
self,
sig: int,
callback: Callable[..., None],
*args: object,
) -> None:
handlers[sig] = lambda: callback(*args)
def remove_signal_handler(self, sig: int) -> None:
handlers.pop(sig, None)
monkeypatch.setattr(asyncio, "get_running_loop", SignalLoop)
return handlers
class TestParseArgs:
@@ -322,23 +380,6 @@ class TestSystemdNotify:
monkeypatch.delenv("NOTIFY_SOCKET", raising=False)
_sd_notify("READY=1")
def test_sd_notify_sends_datagram(
self,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""_sd_notify sends the payload to NOTIFY_SOCKET."""
socket_path = tmp_path / "notify.sock"
with socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM) as server:
server.bind(str(socket_path))
server.settimeout(1)
monkeypatch.setenv("NOTIFY_SOCKET", str(socket_path))
_sd_notify("READY=1")
assert server.recv(1024) == b"READY=1"
class TestWatchdog:
"""Systemd watchdog helper behavior."""
@@ -426,10 +467,10 @@ class TestMainLifecycle:
def test_constructs_bot_without_token(
self,
monkeypatch: pytest.MonkeyPatch,
prepared_main: Callable[..., list[object]],
) -> None:
"""The CLI keeps the Discord token out of Crabstero construction."""
events = _prepare_main_test(monkeypatch)
events = prepared_main()
assert cli.main() == 0
@@ -449,14 +490,12 @@ class TestMainLifecycle:
def test_main_accepts_explicit_argv(
self,
monkeypatch: pytest.MonkeyPatch,
prepared_main: Callable[..., list[object]],
) -> None:
"""main(argv) runs from explicit arguments instead of sys.argv."""
events = _prepare_main_test(monkeypatch)
events = prepared_main()
assert (
cli.main(["--token", "argv-token", "--database-path", "/custom.db"]) == 0
)
assert cli.main(["--token", "argv-token", "--database-path", "/custom.db"]) == 0
assert events[0] == (
"init",
@@ -470,10 +509,10 @@ class TestMainLifecycle:
def test_ready_sent_after_login_before_connect(
self,
monkeypatch: pytest.MonkeyPatch,
prepared_main: Callable[..., list[object]],
) -> None:
"""READY=1 is sent after login succeeds and before connect starts."""
events = _prepare_main_test(monkeypatch)
events = prepared_main()
assert cli.main() == 0
@@ -486,10 +525,10 @@ class TestMainLifecycle:
def test_stopping_sent_after_clean_connect_return(
self,
monkeypatch: pytest.MonkeyPatch,
prepared_main: Callable[..., list[object]],
) -> None:
"""STOPPING=1 is sent when the CLI run exits cleanly after readiness."""
events = _prepare_main_test(monkeypatch)
events = prepared_main()
assert cli.main() == 0
@@ -499,13 +538,10 @@ class TestMainLifecycle:
def test_ready_not_sent_when_login_fails(
self,
monkeypatch: pytest.MonkeyPatch,
prepared_main: Callable[..., list[object]],
) -> None:
"""A login failure exits without reporting readiness."""
events = _prepare_main_test(
monkeypatch,
login_error=RuntimeError("login failed"),
)
events = prepared_main(login_error=RuntimeError("login failed"))
with pytest.raises(RuntimeError, match="login failed"):
cli.main()
@@ -515,32 +551,15 @@ class TestMainLifecycle:
def test_shutdown_task_exception_is_propagated(
self,
monkeypatch: pytest.MonkeyPatch,
prepared_main: Callable[..., list[object]],
signal_handlers: dict[int, Callable[[], None]],
) -> None:
"""A completed shutdown task exception is not silently dropped."""
signal_handlers: dict[int, Callable[[], None]] = {}
class SignalLoop:
def add_signal_handler(
self,
sig: int,
callback: Callable[..., None],
*args: object,
) -> None:
signal_handlers[sig] = lambda: callback(*args)
def remove_signal_handler(self, sig: int) -> None:
signal_handlers.pop(sig, None)
events = _prepare_main_test(
monkeypatch,
close_error=RuntimeError("close failed"),
)
events = prepared_main(close_error=RuntimeError("close failed"))
def request_shutdown() -> None:
signal_handlers[signal.SIGTERM]()
monkeypatch.setattr(asyncio, "get_running_loop", SignalLoop)
_FakeCrabstero.connect_callback = request_shutdown
with pytest.raises(RuntimeError, match="close failed"):
@@ -559,29 +578,15 @@ class TestMainLifecycle:
self,
sig: signal.Signals,
exit_code: int,
monkeypatch: pytest.MonkeyPatch,
prepared_main: Callable[..., list[object]],
signal_handlers: dict[int, Callable[[], None]],
) -> None:
"""Signal callbacks preserve SIGINT and SIGTERM CLI exit semantics."""
signal_handlers: dict[int, Callable[[], None]] = {}
class SignalLoop:
def add_signal_handler(
self,
sig: int,
callback: Callable[..., None],
*args: object,
) -> None:
signal_handlers[sig] = lambda: callback(*args)
def remove_signal_handler(self, sig: int) -> None:
signal_handlers.pop(sig, None)
events = _prepare_main_test(monkeypatch)
events = prepared_main()
def request_shutdown() -> None:
signal_handlers[sig]()
monkeypatch.setattr(asyncio, "get_running_loop", SignalLoop)
_FakeCrabstero.connect_callback = request_shutdown
assert cli.main() == exit_code
@@ -591,29 +596,15 @@ class TestMainLifecycle:
def test_registered_signal_during_login_exits_before_ready(
self,
monkeypatch: pytest.MonkeyPatch,
prepared_main: Callable[..., list[object]],
signal_handlers: dict[int, Callable[[], None]],
) -> None:
"""A signal during login cancels startup without reporting readiness."""
signal_handlers: dict[int, Callable[[], None]] = {}
class SignalLoop:
def add_signal_handler(
self,
sig: int,
callback: Callable[..., None],
*args: object,
) -> None:
signal_handlers[sig] = lambda: callback(*args)
def remove_signal_handler(self, sig: int) -> None:
signal_handlers.pop(sig, None)
events = _prepare_main_test(monkeypatch)
events = prepared_main()
def request_shutdown() -> None:
signal_handlers[signal.SIGINT]()
monkeypatch.setattr(asyncio, "get_running_loop", SignalLoop)
_FakeCrabstero.login_callback = request_shutdown
assert cli.main() == 130
-641
View File
@@ -1,641 +0,0 @@
# Copyright 2026 Logan Fick
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Unit tests for the Database class.
Tests cover Database.connect (pragmas, schema), markov start word and
transition CRUD, image storage, flag CRUD, and channel ingestion tracking.
"""
from typing import TYPE_CHECKING
import pytest
from crabstero.database import ChannelImage, Database, StartWord, Transition
from crabstero.flags import EntityType, Flag
if TYPE_CHECKING:
from pathlib import Path
class TestConnect:
"""Database.connect creates a configured SQLite database."""
async def test_synchronous_normal(self, db: Database) -> None:
"""Synchronous mode is set to NORMAL."""
async with db._connection.execute("PRAGMA synchronous") as cursor:
row = await cursor.fetchone()
assert row is not None
assert row[0] == 1
async def test_schema_creates_tables(self, db: Database) -> None:
"""All expected tables exist after connect."""
async with db._connection.execute(
"SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name",
) as cursor:
tables = [row[0] for row in await cursor.fetchall()]
assert tables == [
"channel_images",
"flags",
"ingested_channels",
"markov_start_words",
"markov_transitions",
]
class TestAddMarkovData:
"""Markov start word and transition storage via add_markov_data."""
async def test_stores_start_word(self, db: Database) -> None:
"""Inserted start word can be retrieved by channel."""
await db.add_markov_data([StartWord(1, 100, "Hello")], [])
result = await db.get_random_start_word(1)
assert result == "Hello"
async def test_stores_transition(self, db: Database) -> None:
"""Inserted transition can be retrieved by channel and word."""
await db.add_markov_data([], [Transition(1, 100, "Hello", "world.")])
result = await db.get_random_next_word(1, "Hello")
assert result == "world."
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(
[StartWord(1, 100, "Hello")],
[Transition(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_is_noop(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
class TestMarkovReadMethods:
"""Markov read methods return None for out-of-scope or missing data."""
async def test_returns_none_when_empty(self, db: Database) -> None:
"""Returns None when no data has been stored."""
assert await db.get_random_start_word(1) is None
assert await db.get_random_next_word(1, "nonexistent") is None
assert await db.get_random_completing_next_word(1, "nonexistent") is None
async def test_start_word_scoped_to_channel(self, db: Database) -> None:
"""A start word in one channel is not returned for another channel."""
await db.add_markov_data([StartWord(1, 100, "Hello")], [])
assert await db.get_random_start_word(2) is None
async def test_next_word_scoped_to_channel(self, db: Database) -> None:
"""A transition in one channel is not returned for another channel."""
await db.add_markov_data([], [Transition(1, 100, "Hello", "world.")])
assert await db.get_random_next_word(2, "Hello") is None
async def test_completing_next_word_scoped_to_channel(self, db: Database) -> None:
"""Completing transition is not returned for another channel."""
await db.add_markov_data([], [Transition(1, 100, "Hello", "world.")])
assert await db.get_random_completing_next_word(2, "Hello") is None
async def test_next_word_scoped_to_word(self, db: Database) -> None:
"""A transition for one word is not returned when querying a different word."""
await db.add_markov_data([], [Transition(1, 100, "Hello", "world.")])
assert await db.get_random_next_word(1, "Goodbye") is None
async def test_completing_next_word_scoped_to_word(self, db: Database) -> None:
"""Completing transition is not returned for a different word."""
await db.add_markov_data([], [Transition(1, 100, "Hello", "world.")])
assert await db.get_random_completing_next_word(1, "Goodbye") is None
@pytest.mark.parametrize(
"completing_word",
[
pytest.param("world.", id="period"),
pytest.param("world!", id="exclamation"),
pytest.param("world?", id="question"),
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_markov_data(
[],
[
Transition(1, 100, "Hello", "beautiful"),
Transition(1, 100, "Hello", completing_word),
],
)
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_markov_data([], [Transition(1, 100, "Hello", "beautiful")])
result = await db.get_random_completing_next_word(1, "Hello")
assert result is None
async def test_start_word_pooled_across_users(self, db: Database) -> None:
"""Start words from different users are visible in the same channel query."""
await db.add_markov_data(
[StartWord(1, 100, "Hello"), StartWord(1, 200, "Goodbye")],
[],
)
assert await db.get_random_start_word(1) in {"Hello", "Goodbye"}
async def test_next_word_pooled_across_users(self, db: Database) -> None:
"""Transitions from different users are visible in the same channel query."""
await db.add_markov_data(
[],
[
Transition(1, 100, "Hello", "world."),
Transition(1, 200, "Hello", "friend."),
],
)
assert await db.get_random_next_word(1, "Hello") in {"world.", "friend."}
async def test_completing_next_word_pooled_across_users(self, db: Database) -> None:
"""Completing transitions from different users are visible."""
await db.add_markov_data(
[],
[
Transition(1, 100, "Hello", "world."),
Transition(1, 200, "Hello", "friend."),
],
)
assert await db.get_random_completing_next_word(1, "Hello") in {
"world.",
"friend.",
}
class TestRemoveMarkovData:
"""Markov data removal via remove_markov_data."""
async def test_removes_one_start_word(self, db: Database) -> None:
"""Removes exactly one matching start word row."""
await db.add_markov_data(
[StartWord(1, 100, "Hello"), StartWord(1, 100, "Hello")],
[],
)
await db.remove_markov_data([StartWord(1, 100, "Hello")], [])
# One copy should remain.
assert await db.get_random_start_word(1) == "Hello"
async def test_removes_one_transition(self, db: Database) -> None:
"""Removes exactly one matching transition row."""
await db.add_markov_data(
[],
[
Transition(1, 100, "Hello", "world."),
Transition(1, 100, "Hello", "world."),
],
)
await db.remove_markov_data([], [Transition(1, 100, "Hello", "world.")])
assert await db.get_random_next_word(1, "Hello") == "world."
async def test_removes_last_start_word(self, db: Database) -> None:
"""Removing the only start word leaves the table empty for that channel."""
await db.add_markov_data([StartWord(1, 100, "Hello")], [])
await db.remove_markov_data([StartWord(1, 100, "Hello")], [])
assert await db.get_random_start_word(1) is None
async def test_removes_last_transition(self, db: Database) -> None:
"""Removing the only transition leaves no next word."""
await db.add_markov_data([], [Transition(1, 100, "Hello", "world.")])
await db.remove_markov_data([], [Transition(1, 100, "Hello", "world.")])
assert await db.get_random_next_word(1, "Hello") is None
async def test_no_match_is_noop(self, db: Database) -> None:
"""Removing a non-existent row does not raise."""
await db.remove_markov_data(
[StartWord(1, 100, "nope")],
[Transition(1, 100, "nope", "nah")],
)
async def test_start_word_removal_scoped_to_channel(self, db: Database) -> None:
"""Removing a start word in one channel leaves another channel intact."""
await db.add_markov_data(
[StartWord(1, 100, "Hello"), StartWord(2, 200, "Hello")],
[],
)
await db.remove_markov_data([StartWord(1, 100, "Hello")], [])
assert await db.get_random_start_word(1) is None
assert await db.get_random_start_word(2) == "Hello"
async def test_transition_removal_scoped_to_channel(self, db: Database) -> None:
"""Removing a transition in one channel leaves another channel intact."""
await db.add_markov_data(
[],
[
Transition(1, 100, "Hello", "world."),
Transition(2, 200, "Hello", "world."),
],
)
await db.remove_markov_data([], [Transition(1, 100, "Hello", "world.")])
assert await db.get_random_next_word(1, "Hello") is None
assert await db.get_random_next_word(2, "Hello") == "world."
async def test_start_word_removal_scoped_to_user(self, db: Database) -> None:
"""Removing a start word for one user leaves another user."""
await db.add_markov_data(
[StartWord(1, 100, "Hello"), StartWord(1, 200, "Hello")],
[],
)
await db.remove_markov_data([StartWord(1, 100, "Hello")], [])
assert await db.get_random_start_word(1) == "Hello"
async def test_transition_removal_scoped_to_user(self, db: Database) -> None:
"""Removing a transition for one user leaves another user."""
await db.add_markov_data(
[],
[
Transition(1, 100, "Hello", "world."),
Transition(1, 200, "Hello", "world."),
],
)
await db.remove_markov_data([], [Transition(1, 100, "Hello", "world.")])
assert await db.get_random_next_word(1, "Hello") == "world."
async def test_start_word_removal_scoped_to_word(self, db: Database) -> None:
"""Removing one start word leaves a different start word."""
await db.add_markov_data(
[StartWord(1, 100, "Hello"), StartWord(1, 100, "World")],
[],
)
await db.remove_markov_data([StartWord(1, 100, "Hello")], [])
assert await db.get_random_start_word(1) == "World"
async def test_transition_removal_scoped_to_word(self, db: Database) -> None:
"""Removing one word's transition leaves another word's."""
await db.add_markov_data(
[],
[
Transition(1, 100, "Hello", "world."),
Transition(1, 100, "Goodbye", "world."),
],
)
await db.remove_markov_data([], [Transition(1, 100, "Hello", "world.")])
assert await db.get_random_next_word(1, "Hello") is None
assert await db.get_random_next_word(1, "Goodbye") == "world."
async def test_transition_removal_scoped_to_next_word(self, db: Database) -> None:
"""Removing one next_word leaves a different next_word."""
await db.add_markov_data(
[],
[
Transition(1, 100, "Hello", "world."),
Transition(1, 100, "Hello", "friend."),
],
)
await db.remove_markov_data([], [Transition(1, 100, "Hello", "world.")])
assert await db.get_random_next_word(1, "Hello") == "friend."
async def test_empty_lists_is_noop(self, db: Database) -> None:
"""Empty lists do not error."""
await db.remove_markov_data([], [])
class TestImages:
"""Image URL storage and random retrieval."""
async def test_add_and_retrieve(self, db: Database) -> None:
"""Inserted image URL can be retrieved by channel."""
await db.add_images([ChannelImage(1, 100, "https://example.com/cat.png")])
result = await db.get_random_image(1)
assert result == "https://example.com/cat.png"
async def test_returns_none_when_empty(self, db: Database) -> None:
"""Returns None for a channel with no images."""
result = await db.get_random_image(999)
assert result is None
async def test_image_scoped_to_channel(self, db: Database) -> None:
"""An image in one channel is not returned for another channel."""
await db.add_images([ChannelImage(1, 100, "https://example.com/cat.png")])
assert await db.get_random_image(2) is None
async def test_empty_list_is_noop(self, db: Database) -> None:
"""Empty list does not store any data."""
await db.add_images([])
assert await db.get_random_image(1) is None
async def test_image_pooled_across_users(self, db: Database) -> None:
"""Images from different users are visible in the same channel query."""
await db.add_images(
[
ChannelImage(1, 100, "https://example.com/a.png"),
ChannelImage(1, 200, "https://example.com/b.png"),
],
)
assert await db.get_random_image(1) in {
"https://example.com/a.png",
"https://example.com/b.png",
}
class TestRemoveImages:
"""Image removal via remove_images."""
async def test_removes_one_image(self, db: Database) -> None:
"""Removes exactly one matching image row."""
await db.add_images(
[
ChannelImage(1, 100, "https://example.com/a.png"),
ChannelImage(1, 100, "https://example.com/a.png"),
],
)
await db.remove_images([ChannelImage(1, 100, "https://example.com/a.png")])
# One copy should remain.
assert await db.get_random_image(1) == "https://example.com/a.png"
async def test_removes_last_image(self, db: Database) -> None:
"""Removing the only image leaves none for that channel."""
await db.add_images([ChannelImage(1, 100, "https://example.com/a.png")])
await db.remove_images([ChannelImage(1, 100, "https://example.com/a.png")])
assert await db.get_random_image(1) is None
async def test_image_removal_scoped_to_channel(self, db: Database) -> None:
"""Removing an image in one channel leaves another channel intact."""
await db.add_images(
[
ChannelImage(1, 100, "https://example.com/a.png"),
ChannelImage(2, 200, "https://example.com/a.png"),
],
)
await db.remove_images([ChannelImage(1, 100, "https://example.com/a.png")])
assert await db.get_random_image(1) is None
assert await db.get_random_image(2) == "https://example.com/a.png"
async def test_image_removal_scoped_to_user(self, db: Database) -> None:
"""Removing an image for one user leaves another user."""
await db.add_images(
[
ChannelImage(1, 100, "https://example.com/a.png"),
ChannelImage(1, 200, "https://example.com/a.png"),
],
)
await db.remove_images([ChannelImage(1, 100, "https://example.com/a.png")])
assert await db.get_random_image(1) == "https://example.com/a.png"
async def test_image_removal_scoped_to_url(self, db: Database) -> None:
"""Removing one URL leaves a different URL for the same user."""
await db.add_images(
[
ChannelImage(1, 100, "https://example.com/a.png"),
ChannelImage(1, 100, "https://example.com/b.png"),
],
)
await db.remove_images([ChannelImage(1, 100, "https://example.com/a.png")])
assert await db.get_random_image(1) == "https://example.com/b.png"
async def test_no_match_is_noop(self, db: Database) -> None:
"""Removing a non-existent image does not raise."""
await db.remove_images([ChannelImage(1, 100, "https://example.com/nope.png")])
async def test_empty_list_is_noop(self, db: Database) -> None:
"""Empty list does not error."""
await db.remove_images([])
class TestFlags:
"""Flag CRUD operations on entities."""
async def test_set_and_check(self, db: Database) -> None:
"""A set flag is reported as set."""
await db.set_flag(EntityType.CHANNEL, "123", Flag.NO_REPLY)
assert await db.is_flag_set(EntityType.CHANNEL, "123", Flag.NO_REPLY) is True
async def test_unset_flag_is_false(self, db: Database) -> None:
"""An unset flag is reported as not set."""
assert await db.is_flag_set(EntityType.CHANNEL, "123", Flag.NO_REPLY) is False
async def test_clear_flag(self, db: Database) -> None:
"""A cleared flag is no longer reported as set."""
await db.set_flag(EntityType.CHANNEL, "123", Flag.NO_REPLY)
await db.clear_flag(EntityType.CHANNEL, "123", Flag.NO_REPLY)
assert await db.is_flag_set(EntityType.CHANNEL, "123", Flag.NO_REPLY) is False
async def test_set_idempotent(self, db: Database) -> None:
"""Setting the same flag twice does not raise."""
await db.set_flag(EntityType.CHANNEL, "123", Flag.NO_REPLY)
await db.set_flag(EntityType.CHANNEL, "123", Flag.NO_REPLY)
assert await db.is_flag_set(EntityType.CHANNEL, "123", Flag.NO_REPLY) is True
async def test_scoped_to_entity_id(self, db: Database) -> None:
"""A flag set on one entity is not visible on another entity."""
await db.set_flag(EntityType.CHANNEL, "123", Flag.NO_REPLY)
assert await db.is_flag_set(EntityType.CHANNEL, "456", Flag.NO_REPLY) is False
async def test_scoped_to_entity_type(self, db: Database) -> None:
"""A flag set on one entity type is not visible on another."""
await db.set_flag(EntityType.CHANNEL, "123", Flag.NO_REPLY)
assert await db.is_flag_set(EntityType.USER, "123", Flag.NO_REPLY) is False
async def test_scoped_to_flag_name(self, db: Database) -> None:
"""A flag set under one name is not visible under a different name."""
await db.set_flag(EntityType.CHANNEL, "123", Flag.NO_REPLY)
assert await db.is_flag_set(EntityType.CHANNEL, "123", Flag.NO_INGEST) is False
async def test_clear_scoped_to_flag_name(self, db: Database) -> None:
"""Clearing one flag leaves other flags on the same entity intact."""
await db.set_flag(EntityType.CHANNEL, "123", Flag.NO_REPLY)
await db.set_flag(EntityType.CHANNEL, "123", Flag.NO_INGEST)
await db.clear_flag(EntityType.CHANNEL, "123", Flag.NO_REPLY)
assert await db.is_flag_set(EntityType.CHANNEL, "123", Flag.NO_INGEST) is True
async def test_clear_scoped_to_entity_id(self, db: Database) -> None:
"""Clearing a flag on one entity leaves another entity."""
await db.set_flag(EntityType.CHANNEL, "123", Flag.NO_REPLY)
await db.set_flag(EntityType.CHANNEL, "456", Flag.NO_REPLY)
await db.clear_flag(EntityType.CHANNEL, "123", Flag.NO_REPLY)
assert await db.is_flag_set(EntityType.CHANNEL, "456", Flag.NO_REPLY) is True
async def test_clear_scoped_to_entity_type(self, db: Database) -> None:
"""Clearing a flag on one entity type leaves another type."""
await db.set_flag(EntityType.CHANNEL, "123", Flag.NO_REPLY)
await db.set_flag(EntityType.USER, "123", Flag.NO_REPLY)
await db.clear_flag(EntityType.CHANNEL, "123", Flag.NO_REPLY)
assert await db.is_flag_set(EntityType.USER, "123", Flag.NO_REPLY) is True
async def test_clear_unset_flag_is_noop(self, db: Database) -> None:
"""Clearing a flag that was never set does not raise or affect other flags."""
await db.set_flag(EntityType.CHANNEL, "123", Flag.NO_REPLY)
await db.clear_flag(EntityType.CHANNEL, "123", Flag.NO_INGEST)
assert await db.is_flag_set(EntityType.CHANNEL, "123", Flag.NO_REPLY) is True
class TestChannelIngestion:
"""Channel ingestion tracking."""
async def test_mark_and_check(self, db: Database) -> None:
"""A marked channel is reported as ingested."""
await db.mark_channel_ingested(42)
assert await db.is_channel_ingested(42) is True
async def test_not_ingested_by_default(self, db: Database) -> None:
"""Unmarked channels are not reported as ingested."""
assert await db.is_channel_ingested(42) is False
async def test_mark_idempotent(self, db: Database) -> None:
"""Marking the same channel twice does not raise."""
await db.mark_channel_ingested(42)
await db.mark_channel_ingested(42)
assert await db.is_channel_ingested(42) is True
async def test_scoped_to_channel(self, db: Database) -> None:
"""Marking one channel as ingested does not affect another channel."""
await db.mark_channel_ingested(42)
assert await db.is_channel_ingested(99) is False
class TestTransactionRollback:
"""Transaction rolls back all changes on error."""
async def test_error_rolls_back_insert(self, db: Database) -> None:
"""An error during a transaction prevents partial data from persisting."""
with pytest.raises(RuntimeError, match="simulated"):
await self._insert_and_fail(db)
assert await db.get_random_start_word(1) is None
@staticmethod
async def _insert_and_fail(db: Database) -> None:
async with db._transaction():
await db._connection.execute(
"INSERT INTO markov_start_words"
" (channel_id, user_id, word)"
" VALUES (?, ?, ?)",
(1, 100, "should_not_persist"),
)
msg = "simulated failure"
raise RuntimeError(msg)
class TestForgetUser:
"""Atomic forget-user transaction across all tables."""
async def test_deletes_data_and_sets_no_ingest(self, db: Database) -> None:
"""All user data is removed and noIngest flag is set."""
await db.add_markov_data(
[StartWord(1, 100, "Hello")],
[Transition(1, 100, "Hello", "world.")],
)
await db.add_images([ChannelImage(1, 100, "https://example.com/a.png")])
await db.set_flag(EntityType.USER, "100", Flag.ALLOW_PINGS)
await db.forget_user(100, Flag.NO_INGEST)
assert await db.get_random_start_word(1) is None
assert await db.get_random_next_word(1, "Hello") is None
assert await db.get_random_image(1) is None
assert await db.is_flag_set(EntityType.USER, "100", Flag.ALLOW_PINGS) is False
assert await db.is_flag_set(EntityType.USER, "100", Flag.NO_INGEST) is True
async def test_preserves_other_users(self, db: Database) -> None:
"""Data belonging to other users is not affected."""
await db.add_markov_data(
[StartWord(1, 100, "Gone"), StartWord(1, 200, "Keep")],
[
Transition(1, 100, "Gone", "away."),
Transition(1, 200, "Keep", "this."),
],
)
await db.add_images(
[
ChannelImage(1, 100, "https://example.com/gone.png"),
ChannelImage(1, 200, "https://example.com/stay.png"),
],
)
await db.set_flag(EntityType.USER, "200", Flag.ALLOW_PINGS)
await db.forget_user(100, Flag.NO_INGEST)
assert await db.get_random_start_word(1) == "Keep"
assert await db.get_random_next_word(1, "Keep") == "this."
assert await db.get_random_image(1) == "https://example.com/stay.png"
assert await db.is_flag_set(EntityType.USER, "200", Flag.ALLOW_PINGS) is True
async def test_preserves_other_entity_type_flags(self, db: Database) -> None:
"""Flags on channels with the same entity ID are not affected."""
await db.set_flag(EntityType.CHANNEL, "100", Flag.NO_REPLY)
await db.set_flag(EntityType.USER, "100", Flag.NO_REPLY)
await db.forget_user(100, Flag.NO_INGEST)
assert await db.is_flag_set(EntityType.CHANNEL, "100", Flag.NO_REPLY) is True
assert await db.is_flag_set(EntityType.USER, "100", Flag.NO_REPLY) is False
async def test_clears_existing_flags_except_no_ingest(self, db: Database) -> None:
"""Existing user flags are cleared but noIngest remains."""
await db.set_flag(EntityType.USER, "100", Flag.NO_REPLY)
await db.set_flag(EntityType.USER, "100", Flag.ALLOW_PINGS)
await db.forget_user(100, Flag.NO_INGEST)
assert await db.is_flag_set(EntityType.USER, "100", Flag.NO_REPLY) is False
assert await db.is_flag_set(EntityType.USER, "100", Flag.ALLOW_PINGS) is False
assert await db.is_flag_set(EntityType.USER, "100", Flag.NO_INGEST) is True
async def test_deletes_across_channels(self, db: Database) -> None:
"""All user data is removed from every channel."""
await db.add_markov_data(
[StartWord(1, 100, "One"), StartWord(2, 100, "Two")],
[
Transition(1, 100, "One", "fish."),
Transition(2, 100, "Two", "fish."),
],
)
await db.add_images(
[
ChannelImage(1, 100, "https://example.com/a.png"),
ChannelImage(2, 100, "https://example.com/b.png"),
],
)
await db.forget_user(100, Flag.NO_INGEST)
assert await db.get_random_start_word(1) is None
assert await db.get_random_start_word(2) is None
assert await db.get_random_next_word(1, "One") is None
assert await db.get_random_next_word(2, "Two") is None
assert await db.get_random_image(1) is None
assert await db.get_random_image(2) is None
async def test_noop_for_nonexistent_user(self, db: Database) -> None:
"""Forgetting a user with no data does not raise."""
await db.forget_user(999, Flag.NO_INGEST)
assert await db.is_flag_set(EntityType.USER, "999", Flag.NO_INGEST) is True
class TestWriteDurability:
"""Writes persist across close and reopen."""
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_markov_data(
[StartWord(1, 100, "Hello")],
[Transition(1, 100, "Hello", "world.")],
)
await db.close()
db2 = await Database.connect(db_path)
assert await db2.get_random_start_word(1) == "Hello"
assert await db2.get_random_next_word(1, "Hello") == "world."
await db2.close()
+64 -16
View File
@@ -15,13 +15,13 @@
"""Unit tests for the flag convenience wrappers and enums.
Tests cover _entity_id, Flag and EntityType enums, and the high-level
set_flag/clear_flag/is_flag_set wrappers from crabstero.flags.
set_flag/clear_flag/is_flag_set wrappers from crabstero.flags without crossing
the SQLite database boundary.
"""
from typing import TYPE_CHECKING
import pytest
from crabstero.database import Database
from crabstero.flags import (
EntityType,
Flag,
@@ -31,17 +31,49 @@ from crabstero.flags import (
set_flag,
)
if TYPE_CHECKING:
from crabstero.database import Database
class _StubSnowflake:
"""Minimal stand-in for discord.abc.Snowflake."""
"""Minimal stand-in for objects that expose a Discord snowflake ID."""
def __init__(self, *, entity_id: int = 123456789) -> None:
self.id = entity_id
class _FakeFlagDatabase(Database):
"""In-memory flag store that records wrapper calls below SQLite."""
def __init__(self) -> None:
self.flags: set[tuple[str, str, str]] = set()
async def set_flag(self, entity_type: str, entity_id: str, flag_name: str) -> None:
"""Record a flag set operation."""
self.flags.add((entity_type, entity_id, flag_name))
async def clear_flag(
self,
entity_type: str,
entity_id: str,
flag_name: str,
) -> None:
"""Record a flag clear operation."""
self.flags.discard((entity_type, entity_id, flag_name))
async def is_flag_set(
self,
entity_type: str,
entity_id: str,
flag_name: str,
) -> bool:
"""Return whether a flag has been set."""
return (entity_type, entity_id, flag_name) in self.flags
@pytest.fixture
def flag_db() -> _FakeFlagDatabase:
"""Return an isolated flag store for wrapper tests."""
return _FakeFlagDatabase()
class TestEntityId:
"""Entity ID extraction from Discord objects and integers."""
@@ -100,17 +132,33 @@ class TestSetClearCheck:
pytest.param(Flag.ALLOW_PINGS, id="allow-pings"),
],
)
async def test_set_then_check(self, db: Database, flag: Flag) -> None:
async def test_set_then_check(self, flag_db: _FakeFlagDatabase, flag: Flag) -> None:
"""A set flag is reported as set."""
await set_flag(db, 1, EntityType.CHANNEL, flag)
assert await is_flag_set(db, 1, EntityType.CHANNEL, flag) is True
await set_flag(flag_db, 1, EntityType.CHANNEL, flag)
assert await is_flag_set(flag_db, 1, EntityType.CHANNEL, flag) is True
async def test_unset_returns_false(self, db: Database) -> None:
async def test_unset_returns_false(self, flag_db: _FakeFlagDatabase) -> None:
"""An unset flag is reported as not set."""
assert await is_flag_set(db, 1, EntityType.CHANNEL, Flag.NO_REPLY) is False
assert (
await is_flag_set(
flag_db,
1,
EntityType.CHANNEL,
Flag.NO_REPLY,
)
is False
)
async def test_clear_removes_flag(self, db: Database) -> None:
async def test_clear_removes_flag(self, flag_db: _FakeFlagDatabase) -> None:
"""A cleared flag is no longer reported as set."""
await set_flag(db, 1, EntityType.CHANNEL, Flag.NO_REPLY)
await clear_flag(db, 1, EntityType.CHANNEL, Flag.NO_REPLY)
assert await is_flag_set(db, 1, EntityType.CHANNEL, Flag.NO_REPLY) is False
await set_flag(flag_db, 1, EntityType.CHANNEL, Flag.NO_REPLY)
await clear_flag(flag_db, 1, EntityType.CHANNEL, Flag.NO_REPLY)
assert (
await is_flag_set(
flag_db,
1,
EntityType.CHANNEL,
Flag.NO_REPLY,
)
is False
)
+250 -128
View File
@@ -15,14 +15,14 @@
"""Unit tests for Markov chain ingestion and generation.
Tests cover is_complete_sentence, _ingest_sentence, ingest, and generate
from crabstero.markov.
from crabstero.markov without crossing the SQLite database boundary.
"""
from typing import TYPE_CHECKING
import contextlib
import pytest
from crabstero.database import StartWord, Transition
from crabstero.database import Database, StartWord, Transition
from crabstero.markov import (
DEFAULT_SENTENCE_END,
_ingest_sentence,
@@ -33,8 +33,80 @@ from crabstero.markov import (
uningest,
)
if TYPE_CHECKING:
from crabstero.database import Database
class _FakeMarkovDatabase(Database):
"""In-memory Markov store with deterministic first-row selection.
The fake preserves duplicate rows and one-row-at-a-time removal semantics
so ingestion and uningestion tests can stay below the SQLite boundary.
"""
def __init__(self) -> None:
self.start_words: list[StartWord] = []
self.transitions: list[Transition] = []
async def add_markov_data(
self,
start_words: list[StartWord],
transitions: list[Transition],
) -> None:
"""Record Markov rows exactly as the application would write them."""
self.start_words.extend(start_words)
self.transitions.extend(transitions)
async def remove_markov_data(
self,
start_words: list[StartWord],
transitions: list[Transition],
) -> None:
"""Remove at most one matching row per requested Markov row."""
for start_word in start_words:
with contextlib.suppress(ValueError):
self.start_words.remove(start_word)
for transition in transitions:
with contextlib.suppress(ValueError):
self.transitions.remove(transition)
async def get_random_start_word(self, channel_id: int) -> str | None:
"""Return the first start word for deterministic generation tests."""
return next(
(row.word for row in self.start_words if row.channel_id == channel_id),
None,
)
async def get_random_next_word(self, channel_id: int, word: str) -> str | None:
"""Return the first matching transition for deterministic tests."""
return next(
(
row.next_word
for row in self.transitions
if row.channel_id == channel_id and row.word == word
),
None,
)
async def get_random_completing_next_word(
self,
channel_id: int,
word: str,
) -> str | None:
"""Return the first sentence-ending transition for deterministic tests."""
return next(
(
row.next_word
for row in self.transitions
if row.channel_id == channel_id
and row.word == word
and is_complete_sentence(row.next_word)
),
None,
)
@pytest.fixture
def markov_db() -> _FakeMarkovDatabase:
"""Return an isolated deterministic Markov store for one test."""
return _FakeMarkovDatabase()
class TestIsCompleteSentence:
@@ -61,83 +133,93 @@ class TestIsCompleteSentence:
class TestIngestSentence:
"""Single sentence ingestion into the Markov chain."""
async def test_stores_start_word(self, db: Database) -> None:
async def test_stores_start_word(self, markov_db: _FakeMarkovDatabase) -> None:
"""First word of the sentence is stored as a start word."""
await _ingest_sentence(db, 1, 100, "Hello world.")
await _ingest_sentence(markov_db, 1, 100, "Hello world.")
result = await db.get_random_start_word(1)
assert result == "Hello"
assert markov_db.start_words == [StartWord(1, 100, "Hello")]
async def test_stores_transitions(self, db: Database) -> None:
async def test_stores_transitions(self, markov_db: _FakeMarkovDatabase) -> None:
"""Adjacent words create transitions."""
await _ingest_sentence(db, 1, 100, "Hello world.")
await _ingest_sentence(markov_db, 1, 100, "Hello world.")
result = await db.get_random_next_word(1, "Hello")
assert result == "world."
assert markov_db.transitions == [Transition(1, 100, "Hello", "world.")]
async def test_appends_sentence_end_if_missing(self, db: Database) -> None:
async def test_appends_sentence_end_if_missing(
self,
markov_db: _FakeMarkovDatabase,
) -> None:
"""Unpunctuated sentence gets the default sentence-end marker."""
await _ingest_sentence(db, 1, 100, "Hello world")
await _ingest_sentence(markov_db, 1, 100, "Hello world")
result = await db.get_random_next_word(1, "Hello")
assert result == f"world{DEFAULT_SENTENCE_END}"
assert markov_db.transitions == [
Transition(1, 100, "Hello", f"world{DEFAULT_SENTENCE_END}"),
]
async def test_preserves_existing_punctuation(self, db: Database) -> None:
async def test_preserves_existing_punctuation(
self,
markov_db: _FakeMarkovDatabase,
) -> None:
"""Already-punctuated sentence keeps its terminator."""
await _ingest_sentence(db, 1, 100, "Hello world!")
await _ingest_sentence(markov_db, 1, 100, "Hello world!")
result = await db.get_random_next_word(1, "Hello")
assert result == "world!"
assert markov_db.transitions == [Transition(1, 100, "Hello", "world!")]
async def test_single_word_stores_nothing(self, db: Database) -> None:
async def test_single_word_stores_nothing(
self,
markov_db: _FakeMarkovDatabase,
) -> None:
"""A single-word sentence produces no start words or transitions."""
await _ingest_sentence(db, 1, 100, "Hello.")
await _ingest_sentence(markov_db, 1, 100, "Hello.")
assert await db.get_random_start_word(1) is None
assert await db.get_random_next_word(1, "Hello.") is None
assert markov_db.start_words == []
assert markov_db.transitions == []
async def test_stores_all_transitions(self, db: Database) -> None:
async def test_stores_all_transitions(
self,
markov_db: _FakeMarkovDatabase,
) -> None:
"""All adjacent word pairs create transitions."""
await _ingest_sentence(db, 1, 100, "A B C.")
await _ingest_sentence(markov_db, 1, 100, "A B C.")
assert await db.get_random_next_word(1, "A") == "B"
assert await db.get_random_next_word(1, "B") == "C."
assert markov_db.transitions == [
Transition(1, 100, "A", "B"),
Transition(1, 100, "B", "C."),
]
class TestIngestParagraph:
"""Paragraph ingestion splits into sentences."""
async def test_single_sentence(self, db: Database) -> None:
async def test_single_sentence(self, markov_db: _FakeMarkovDatabase) -> None:
"""A single sentence paragraph is ingested."""
await ingest(db, 1, 100, "Hello world.")
await ingest(markov_db, 1, 100, "Hello world.")
assert await db.get_random_start_word(1) == "Hello"
assert markov_db.start_words == [StartWord(1, 100, "Hello")]
async def test_multiple_sentences(self, db: Database) -> None:
async def test_multiple_sentences(self, markov_db: _FakeMarkovDatabase) -> None:
"""Multiple sentences are split and ingested individually."""
await ingest(db, 1, 100, "Hello world. Goodbye world!")
await ingest(markov_db, 1, 100, "Hello world. Goodbye world!")
# Both "Hello" and "Goodbye" should appear as start words.
async with db._connection.execute(
"SELECT DISTINCT word FROM markov_start_words WHERE channel_id = ?",
(1,),
) as cursor:
start_words = {row[0] for row in await cursor.fetchall()}
assert start_words == {"Hello", "Goodbye"}
assert {row.word for row in markov_db.start_words} == {"Hello", "Goodbye"}
async def test_normalizes_whitespace(self, db: Database) -> None:
async def test_normalizes_whitespace(
self,
markov_db: _FakeMarkovDatabase,
) -> None:
"""Extra spaces and newlines are collapsed."""
await ingest(db, 1, 100, "Hello world.\nGoodbye world!")
await ingest(markov_db, 1, 100, "Hello world.\nGoodbye world!")
result = await db.get_random_next_word(1, "Hello")
assert result == "world."
assert Transition(1, 100, "Hello", "world.") in markov_db.transitions
assert Transition(1, 100, "Goodbye", "world!") in markov_db.transitions
async def test_appends_default_end(self, db: Database) -> None:
async def test_appends_default_end(self, markov_db: _FakeMarkovDatabase) -> None:
"""Unpunctuated paragraph gets the default sentence-end marker."""
await ingest(db, 1, 100, "Hello world")
await ingest(markov_db, 1, 100, "Hello world")
result = await db.get_random_next_word(1, "Hello")
assert result == f"world{DEFAULT_SENTENCE_END}"
assert markov_db.transitions == [
Transition(1, 100, "Hello", f"world{DEFAULT_SENTENCE_END}"),
]
@pytest.mark.parametrize(
"paragraph",
@@ -148,72 +230,91 @@ class TestIngestParagraph:
)
async def test_empty_input_stores_nothing(
self,
db: Database,
markov_db: _FakeMarkovDatabase,
paragraph: str,
) -> None:
"""Empty or whitespace-only input does not store any data."""
await ingest(db, 1, 100, paragraph)
await ingest(markov_db, 1, 100, paragraph)
assert await db.get_random_start_word(1) is None
assert markov_db.start_words == []
assert markov_db.transitions == []
async def test_splits_on_punctuation_followed_by_space(self, db: Database) -> None:
async def test_splits_on_punctuation_followed_by_space(
self,
markov_db: _FakeMarkovDatabase,
) -> None:
"""Punctuation followed by a space splits into separate sentences."""
await ingest(db, 1, 100, "Dr. Smith likes cats")
await ingest(markov_db, 1, 100, "Dr. Smith likes cats")
# "Dr." splits off as a single-word sentence (stores nothing).
# "Smith likes cats" becomes a sentence with "Smith" as start word.
assert await db.get_random_start_word(1) == "Smith"
assert markov_db.start_words == [StartWord(1, 100, "Smith")]
async def test_no_split_without_space_after_punctuation(self, db: Database) -> None:
async def test_no_split_without_space_after_punctuation(
self,
markov_db: _FakeMarkovDatabase,
) -> None:
"""Punctuation not followed by a space keeps words together."""
await ingest(db, 1, 100, "Hello.World is here")
await ingest(markov_db, 1, 100, "Hello.World is here")
assert await db.get_random_start_word(1) == "Hello.World"
assert markov_db.start_words == [StartWord(1, 100, "Hello.World")]
class TestGenerate:
"""Markov chain text generation."""
async def test_fallback_on_empty_channel(self, db: Database) -> None:
async def test_fallback_on_empty_channel(
self,
markov_db: _FakeMarkovDatabase,
) -> None:
"""Returns an informational message when the channel has no data."""
result = await generate(db, 1)
result = await generate(markov_db, 1)
expected = (
"I do not have enough data to generate a message yet."
" Chat a bit more so I can learn how this channel talks."
)
assert result == expected
async def test_generates_from_ingested_data(self, db: Database) -> None:
async def test_generates_from_ingested_data(
self,
markov_db: _FakeMarkovDatabase,
) -> None:
"""Generated text uses words from ingested data."""
await ingest(db, 1, 100, "The quick brown fox.")
await ingest(markov_db, 1, 100, "The quick brown fox.")
result = await generate(db, 1)
result = await generate(markov_db, 1)
assert result == "The quick brown fox."
async def test_strips_section_sign(self, db: Database) -> None:
async def test_strips_section_sign(self, markov_db: _FakeMarkovDatabase) -> None:
"""The internal section sign marker never appears in output."""
await ingest(db, 1, 100, "Hello world")
await ingest(markov_db, 1, 100, "Hello world")
result = await generate(db, 1)
result = await generate(markov_db, 1)
assert result == "Hello world"
async def test_respects_hard_limit(self, db: Database) -> None:
async def test_respects_hard_limit(
self,
markov_db: _FakeMarkovDatabase,
) -> None:
"""Output is truncated at hard_limit."""
words = [f"w{i}" for i in range(100)]
text = " ".join(words)
await ingest(db, 1, 100, text)
await ingest(markov_db, 1, 100, text)
result = await generate(db, 1, soft_limit=10, hard_limit=49)
result = await generate(markov_db, 1, soft_limit=10, hard_limit=49)
assert result == "w0 w1 w2 w3 w4 w5 w6 w7 w8 w9 w10 w11 w12 w13 w14"
async def test_prefers_completing_word_after_soft_limit(self, db: Database) -> None:
async def test_prefers_completing_word_after_soft_limit(
self,
markov_db: _FakeMarkovDatabase,
) -> None:
"""After soft_limit, generation prefers completing words."""
# Chain: A B C D {E, "end."}
# Chain: A -> B -> C -> D -> {E, "end."}
# A, B, C have only non-completing transitions, so past the soft limit
# the loop falls back to get_random_next_word for each. D has both a
# continuing ("E") and completing ("end.") transition, so
# get_random_completing_next_word deterministically picks "end.".
await db.add_markov_data(
await markov_db.add_markov_data(
[StartWord(1, 100, "A")],
[
Transition(1, 100, "A", "B"),
@@ -227,29 +328,32 @@ class TestGenerate:
],
)
for _ in range(100):
result = await generate(db, 1, soft_limit=1, hard_limit=1000)
assert result == "A B C D end."
result = await generate(markov_db, 1, soft_limit=1, hard_limit=1000)
assert result == "A B C D end."
async def test_start_word_already_ends_sentence(self, db: Database) -> None:
async def test_start_word_already_ends_sentence(
self,
markov_db: _FakeMarkovDatabase,
) -> None:
"""Generation stops immediately when the start word is sentence-ending."""
await db.add_markov_data([StartWord(1, 100, "Yes.")], [])
await markov_db.add_markov_data([StartWord(1, 100, "Yes.")], [])
result = await generate(db, 1)
result = await generate(markov_db, 1)
assert result == "Yes."
async def test_chain_dead_end(self, db: Database) -> None:
async def test_chain_dead_end(self, markov_db: _FakeMarkovDatabase) -> None:
"""Generation stops when no next word exists (dead-end chain)."""
await db.add_markov_data([StartWord(1, 100, "Hello")], [])
await markov_db.add_markov_data([StartWord(1, 100, "Hello")], [])
# "Hello" has no transitions, so the loop breaks immediately.
result = await generate(db, 1)
result = await generate(markov_db, 1)
assert result == "Hello"
async def test_hard_limit_strips_section_sign(self, db: Database) -> None:
async def test_hard_limit_strips_section_sign(
self,
markov_db: _FakeMarkovDatabase,
) -> None:
"""Section sign at the truncation boundary is stripped."""
# Build a chain: "A" -> "B" -> "C§".
await db.add_markov_data(
await markov_db.add_markov_data(
[StartWord(1, 100, "A")],
[
Transition(1, 100, "A", "B"),
@@ -257,43 +361,41 @@ class TestGenerate:
],
)
# hard_limit=5 truncates "A B C§" (length 6) to "A B C".
result = await generate(db, 1, soft_limit=100, hard_limit=5)
result = await generate(markov_db, 1, soft_limit=100, hard_limit=6)
assert result == "A B C"
async def test_soft_limit_falls_back_to_regular_next_word(
self,
db: Database,
markov_db: _FakeMarkovDatabase,
) -> None:
"""After soft_limit, falls back when no completing word exists."""
# "A" -> "B" (no completing transition). Past soft_limit,
# get_random_completing_next_word returns None, falls back to "B".
await db.add_markov_data(
await markov_db.add_markov_data(
[StartWord(1, 100, "A")],
[Transition(1, 100, "A", "B")],
)
result = await generate(db, 1, soft_limit=1, hard_limit=1000)
result = await generate(markov_db, 1, soft_limit=1, hard_limit=1000)
assert result == "A B"
async def test_hard_limit_truncates_mid_word(self, db: Database) -> None:
async def test_hard_limit_truncates_mid_word(
self,
markov_db: _FakeMarkovDatabase,
) -> None:
"""Hard limit slices output even when it falls inside a word."""
await db.add_markov_data(
await markov_db.add_markov_data(
[StartWord(1, 100, "AB")],
[Transition(1, 100, "AB", "CDEF")],
)
# "AB CDEF" is 7 chars; hard_limit=5 truncates to "AB CD".
result = await generate(db, 1, soft_limit=100, hard_limit=5)
result = await generate(markov_db, 1, soft_limit=100, hard_limit=5)
assert result == "AB CD"
async def test_channel_isolation(self, db: Database) -> None:
async def test_channel_isolation(self, markov_db: _FakeMarkovDatabase) -> None:
"""Data ingested into one channel does not leak into another."""
await ingest(db, 1, 100, "Channel one data.")
await ingest(db, 2, 100, "Channel two data.")
await ingest(markov_db, 1, 100, "Channel one data.")
await ingest(markov_db, 2, 100, "Channel two data.")
# Channel 3 has no data; should get the fallback.
result = await generate(db, 3)
result = await generate(markov_db, 3)
expected = (
"I do not have enough data to generate a message yet."
" Chat a bit more so I can learn how this channel talks."
@@ -304,45 +406,65 @@ class TestGenerate:
class TestUningestSentence:
"""Single sentence uningest from the Markov chain."""
async def test_removes_start_word_and_transition(self, db: Database) -> None:
async def test_removes_start_word_and_transition(
self,
markov_db: _FakeMarkovDatabase,
) -> None:
"""Uningest removes the start word and transition added by ingest."""
await _ingest_sentence(db, 1, 100, "Hello world.")
await _uningest_sentence(db, 1, 100, "Hello world.")
assert await db.get_random_start_word(1) is None
assert await db.get_random_next_word(1, "Hello") is None
await _ingest_sentence(markov_db, 1, 100, "Hello world.")
await _uningest_sentence(markov_db, 1, 100, "Hello world.")
assert markov_db.start_words == []
assert markov_db.transitions == []
async def test_preserves_other_data(self, db: Database) -> None:
async def test_preserves_other_data(
self,
markov_db: _FakeMarkovDatabase,
) -> None:
"""Uningest only removes data for the specified sentence."""
await _ingest_sentence(db, 1, 100, "Hello world.")
await _ingest_sentence(db, 1, 100, "Goodbye world.")
await _uningest_sentence(db, 1, 100, "Hello world.")
assert await db.get_random_start_word(1) == "Goodbye"
await _ingest_sentence(markov_db, 1, 100, "Hello world.")
await _ingest_sentence(markov_db, 1, 100, "Goodbye world.")
await _uningest_sentence(markov_db, 1, 100, "Hello world.")
assert markov_db.start_words == [StartWord(1, 100, "Goodbye")]
assert markov_db.transitions == [Transition(1, 100, "Goodbye", "world.")]
async def test_handles_missing_punctuation(self, db: Database) -> None:
async def test_handles_missing_punctuation(
self,
markov_db: _FakeMarkovDatabase,
) -> None:
"""Uningest appends the default sentence end, matching ingest behavior."""
await _ingest_sentence(db, 1, 100, "Hello world")
await _uningest_sentence(db, 1, 100, "Hello world")
assert await db.get_random_start_word(1) is None
await _ingest_sentence(markov_db, 1, 100, "Hello world")
await _uningest_sentence(markov_db, 1, 100, "Hello world")
assert markov_db.start_words == []
assert markov_db.transitions == []
async def test_single_word_is_noop(self, db: Database) -> None:
async def test_single_word_is_noop(self, markov_db: _FakeMarkovDatabase) -> None:
"""Uningesting a single-word sentence does not error."""
await _ingest_sentence(db, 1, 100, "Hello.")
await _uningest_sentence(db, 1, 100, "Hello.")
await _ingest_sentence(markov_db, 1, 100, "Hello.")
await _uningest_sentence(markov_db, 1, 100, "Hello.")
assert markov_db.start_words == []
assert markov_db.transitions == []
class TestUningestParagraph:
"""Paragraph-level uningest."""
async def test_uningest_multiple_sentences(self, db: Database) -> None:
async def test_uningest_multiple_sentences(
self,
markov_db: _FakeMarkovDatabase,
) -> None:
"""Uningest reverses a multi-sentence paragraph."""
await ingest(db, 1, 100, "Hello world. Goodbye world!")
await uningest(db, 1, 100, "Hello world. Goodbye world!")
assert await db.get_random_start_word(1) is None
await ingest(markov_db, 1, 100, "Hello world. Goodbye world!")
await uningest(markov_db, 1, 100, "Hello world. Goodbye world!")
assert markov_db.start_words == []
assert markov_db.transitions == []
async def test_uningest_preserves_duplicate_data(self, db: Database) -> None:
async def test_uningest_preserves_duplicate_data(
self,
markov_db: _FakeMarkovDatabase,
) -> None:
"""Uningesting one copy leaves the other intact."""
await ingest(db, 1, 100, "Hello world.")
await ingest(db, 1, 100, "Hello world.")
await uningest(db, 1, 100, "Hello world.")
assert await db.get_random_start_word(1) == "Hello"
assert await db.get_random_next_word(1, "Hello") == "world."
await ingest(markov_db, 1, 100, "Hello world.")
await ingest(markov_db, 1, 100, "Hello world.")
await uningest(markov_db, 1, 100, "Hello world.")
assert markov_db.start_words == [StartWord(1, 100, "Hello")]
assert markov_db.transitions == [Transition(1, 100, "Hello", "world.")]
+9 -152
View File
@@ -14,44 +14,20 @@
"""Unit tests for the Prometheus metrics module.
Tests cover metric object registration and the MetricsServer HTTP endpoint.
Tests cover metric object registration.
"""
import errno
import socket
from dataclasses import dataclass
from http import HTTPStatus
from typing import TYPE_CHECKING, Literal, cast
import aiohttp
import pytest
from prometheus_client import generate_latest
from crabstero.metrics import MetricsServer, TcpMetricsAddress, UnixMetricsAddress
if TYPE_CHECKING:
from collections.abc import AsyncGenerator
from pathlib import Path
# Importing the module registers its Prometheus metrics with the default registry.
import crabstero.metrics # noqa: F401 - imported for registration side effects
type MetricsTransport = Literal["tcp", "unix-socket"]
@dataclass(frozen=True, slots=True)
class MetricsEndpoint:
"""Client details for one running metrics transport."""
base_url: str
unix_socket_path: str | None = None
def client_session(self) -> aiohttp.ClientSession:
"""Create an aiohttp client session for this metrics transport."""
connector = (
aiohttp.UnixConnector(path=self.unix_socket_path)
if self.unix_socket_path is not None
else None
)
return aiohttp.ClientSession(connector=connector)
@pytest.fixture(scope="module")
def prometheus_output() -> str:
"""Return Prometheus exposition text after module-level metric registration."""
return generate_latest().decode()
class TestMetricObjects:
@@ -93,125 +69,6 @@ class TestMetricObjects:
),
],
)
def test_metric_in_output(self, name: str) -> None:
def test_metric_in_output(self, prometheus_output: str, name: str) -> None:
"""Each declared metric appears in the Prometheus text output."""
output = generate_latest().decode()
assert name in output, f"{name} not found in Prometheus output"
@pytest.fixture(
params=[
pytest.param("tcp", id="tcp"),
pytest.param("unix-socket", id="unix-socket"),
],
)
async def metrics_endpoint(
request: pytest.FixtureRequest,
tmp_path: Path,
) -> AsyncGenerator[MetricsEndpoint]:
"""Start a MetricsServer on each supported transport."""
transport = cast("MetricsTransport", request.param)
match transport:
case "tcp":
server = MetricsServer(TcpMetricsAddress("127.0.0.1", 0))
await server.start()
endpoint = MetricsEndpoint(f"http://127.0.0.1:{server.port}")
case "unix-socket":
socket_path = tmp_path / "metrics.sock"
server = MetricsServer(UnixMetricsAddress(str(socket_path)))
await server.start()
endpoint = MetricsEndpoint(
"http://crabstero",
unix_socket_path=str(socket_path),
)
try:
yield endpoint
finally:
await server.stop()
class TestMetricsServer:
"""HTTP server serves Prometheus metrics on /metrics."""
async def test_serves_metrics_endpoint(
self,
metrics_endpoint: MetricsEndpoint,
) -> None:
"""GET /metrics returns 200 with metric output containing our metrics."""
async with (
metrics_endpoint.client_session() as session,
session.get(f"{metrics_endpoint.base_url}/metrics") as resp,
):
assert resp.status == HTTPStatus.OK
body = await resp.text()
assert "crabstero_build_info" in body
async def test_non_metrics_path_returns_404(
self,
metrics_endpoint: MetricsEndpoint,
) -> None:
"""GET on an unknown path returns 404."""
async with (
metrics_endpoint.client_session() as session,
session.get(f"{metrics_endpoint.base_url}/notfound") as resp,
):
assert resp.status == HTTPStatus.NOT_FOUND
async def test_unix_socket_mode_is_applied(self, tmp_path: Path) -> None:
"""Configured Unix socket mode is applied after startup."""
socket_path = tmp_path / "metrics.sock"
server = MetricsServer(UnixMetricsAddress(str(socket_path), mode=0o666))
await server.start()
try:
assert socket_path.stat().st_mode & 0o777 == 0o666
finally:
await server.stop()
async def test_stale_unix_socket_path_is_recovered(
self,
tmp_path: Path,
) -> None:
"""Startup removes an abandoned Unix socket file left by a crash."""
socket_path = tmp_path / "metrics.sock"
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as stale_socket:
stale_socket.bind(str(socket_path))
server = MetricsServer(UnixMetricsAddress(str(socket_path), mode=0o666))
await server.start()
try:
assert socket_path.stat().st_mode & 0o777 == 0o666
connector = aiohttp.UnixConnector(path=str(socket_path))
async with (
aiohttp.ClientSession(connector=connector) as session,
session.get("http://crabstero/metrics") as resp,
):
assert resp.status == HTTPStatus.OK
body = await resp.text()
assert "crabstero_build_info" in body
finally:
await server.stop()
async def test_active_unix_socket_path_fails(
self,
tmp_path: Path,
) -> None:
"""Startup refuses to replace a Unix socket path that is still in use."""
socket_path = tmp_path / "metrics.sock"
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as active_socket:
active_socket.bind(str(socket_path))
active_socket.listen(1)
server = MetricsServer(UnixMetricsAddress(str(socket_path)))
with pytest.raises(OSError, match="already in use") as exc_info:
await server.start()
assert exc_info.value.errno == errno.EADDRINUSE
async def test_non_socket_unix_path_fails(self, tmp_path: Path) -> None:
"""Startup refuses to replace a non-socket path."""
socket_path = tmp_path / "metrics.sock"
socket_path.write_text("")
server = MetricsServer(UnixMetricsAddress(str(socket_path)))
with pytest.raises(FileExistsError):
await server.start()
assert name in prometheus_output, f"{name} not found in Prometheus output"