Expanded integration coverage and enforced test categories.
This commit is contained in:
+77
-66
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user