Replaced MockStorage with real ModuleStorage and added storage test suite.
This commit is contained in:
+17
-6
@@ -71,6 +71,23 @@ class ModuleStorage:
|
|||||||
self._logger = logging.getLogger(f"owlbot.modules.{module_name}.storage")
|
self._logger = logging.getLogger(f"owlbot.modules.{module_name}.storage")
|
||||||
self._logger.info(f"Database initialized at: {self._db_path.absolute()}")
|
self._logger.info(f"Database initialized at: {self._db_path.absolute()}")
|
||||||
|
|
||||||
|
async def __aenter__(self) -> ModuleStorage:
|
||||||
|
"""Enter an async context manager that closes the storage on exit.
|
||||||
|
|
||||||
|
Enables ``async with ModuleStorage(...) as s:`` for scoped usage
|
||||||
|
(e.g. test fixtures).
|
||||||
|
|
||||||
|
In production, ``ModuleLoader`` manages storage lifecycle with explicit
|
||||||
|
``_close()`` calls because the storage is created during module load
|
||||||
|
but closed in separate code paths (unload, load failure, setup failure),
|
||||||
|
so there is no single scope an ``async with`` block could wrap.
|
||||||
|
"""
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, *exc_info: object) -> None:
|
||||||
|
"""Close the storage when leaving the ``async with`` block."""
|
||||||
|
await self._close()
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def transaction(self) -> AsyncIterator[ModuleStorage]:
|
async def transaction(self) -> AsyncIterator[ModuleStorage]:
|
||||||
"""
|
"""
|
||||||
@@ -307,16 +324,10 @@ class ModuleStorage:
|
|||||||
self._closed = True
|
self._closed = True
|
||||||
|
|
||||||
for conn in self._all_connections:
|
for conn in self._all_connections:
|
||||||
try:
|
|
||||||
await conn.close()
|
await conn.close()
|
||||||
except Exception as e:
|
|
||||||
self._logger.debug(f"Exception closing connection: {e}")
|
|
||||||
|
|
||||||
while not self._pool.empty():
|
while not self._pool.empty():
|
||||||
try:
|
|
||||||
self._pool.get_nowait()
|
self._pool.get_nowait()
|
||||||
except asyncio.QueueEmpty:
|
|
||||||
break
|
|
||||||
|
|
||||||
self._all_connections.clear()
|
self._all_connections.clear()
|
||||||
self._logger.info("All pool connections closed.")
|
self._logger.info("All pool connections closed.")
|
||||||
|
|||||||
@@ -57,7 +57,6 @@ python_version = "3.14"
|
|||||||
strict = true
|
strict = true
|
||||||
warn_unreachable = true
|
warn_unreachable = true
|
||||||
explicit_package_bases = true
|
explicit_package_bases = true
|
||||||
exclude = ["tests/"]
|
|
||||||
|
|
||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
asyncio_mode = "auto"
|
asyncio_mode = "auto"
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# 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.
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from owlbot.api.storage import ModuleStorage
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from collections.abc import AsyncIterator
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def storage(tmp_path: Path) -> AsyncIterator[ModuleStorage]:
|
||||||
|
async with ModuleStorage(tmp_path, "test_module") as s:
|
||||||
|
yield s
|
||||||
+352
-167
@@ -19,8 +19,8 @@ templates and user scenarios.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import random
|
import random
|
||||||
import sqlite3
|
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from freezegun import freeze_time
|
from freezegun import freeze_time
|
||||||
@@ -28,48 +28,38 @@ from freezegun import freeze_time
|
|||||||
from owlbot.builtin_modules.custom_commands.placeholder_handlers import _format_duration
|
from owlbot.builtin_modules.custom_commands.placeholder_handlers import _format_duration
|
||||||
from owlbot.builtin_modules.custom_commands.placeholders import process_placeholders
|
from owlbot.builtin_modules.custom_commands.placeholders import process_placeholders
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from owlbot.api.storage import ModuleStorage
|
||||||
|
|
||||||
class MockStorage:
|
CREATE_COUNTERS = (
|
||||||
"""In-memory SQLite storage that executes real SQL queries."""
|
"CREATE TABLE IF NOT EXISTS counters ("
|
||||||
|
|
||||||
def __init__(self, counter_values=None):
|
|
||||||
self._conn = sqlite3.connect(":memory:")
|
|
||||||
self._conn.row_factory = sqlite3.Row
|
|
||||||
self._conn.execute(
|
|
||||||
"CREATE TABLE counters ("
|
|
||||||
"name TEXT PRIMARY KEY NOT NULL, "
|
"name TEXT PRIMARY KEY NOT NULL, "
|
||||||
"value INTEGER DEFAULT 0)"
|
"value INTEGER DEFAULT 0)"
|
||||||
)
|
)
|
||||||
for name, value in (counter_values or {}).items():
|
|
||||||
self._conn.execute(
|
|
||||||
"INSERT INTO counters (name, value) VALUES (?, ?)",
|
|
||||||
(name, value),
|
|
||||||
)
|
|
||||||
self._conn.commit()
|
|
||||||
|
|
||||||
async def fetch_one(self, query, params=None):
|
|
||||||
cursor = self._conn.execute(query, params or ())
|
|
||||||
row = cursor.fetchone()
|
|
||||||
self._conn.commit()
|
|
||||||
return row
|
|
||||||
|
|
||||||
async def fetch_value(self, query, params=None):
|
@pytest.fixture
|
||||||
row = await self.fetch_one(query, params)
|
async def placeholder_storage(storage: ModuleStorage) -> ModuleStorage:
|
||||||
if row is None:
|
"""Storage instance with the counters table created."""
|
||||||
return None
|
await storage.execute(CREATE_COUNTERS)
|
||||||
return row[0]
|
return storage
|
||||||
|
|
||||||
|
|
||||||
async def process(
|
async def process(
|
||||||
template, args=None, user="Alice", use_count=1, storage=None, max_depth=4
|
template: str,
|
||||||
):
|
storage: ModuleStorage,
|
||||||
|
args: list[str] | None = None,
|
||||||
|
user: str = "Alice",
|
||||||
|
use_count: int = 1,
|
||||||
|
max_depth: int = 4,
|
||||||
|
) -> str:
|
||||||
"""Shorthand for process_placeholders with sensible defaults."""
|
"""Shorthand for process_placeholders with sensible defaults."""
|
||||||
return await process_placeholders(
|
return await process_placeholders(
|
||||||
template,
|
template,
|
||||||
args or [],
|
args or [],
|
||||||
user,
|
user,
|
||||||
use_count,
|
use_count,
|
||||||
storage or MockStorage(),
|
storage,
|
||||||
max_depth=max_depth,
|
max_depth=max_depth,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -131,19 +121,29 @@ class TestSimpleSubstitution:
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
async def test_substitution(self, template, kwargs, expected):
|
async def test_substitution(
|
||||||
|
self,
|
||||||
|
placeholder_storage: ModuleStorage,
|
||||||
|
template: str,
|
||||||
|
kwargs: dict[str, Any],
|
||||||
|
expected: str,
|
||||||
|
) -> None:
|
||||||
"""Placeholders and plain text resolve to the expected string."""
|
"""Placeholders and plain text resolve to the expected string."""
|
||||||
assert await process(template, **kwargs) == expected
|
assert await process(template, placeholder_storage, **kwargs) == expected
|
||||||
|
|
||||||
async def test_multiple_placeholders_in_one_template(self):
|
async def test_multiple_placeholders_in_one_template(
|
||||||
|
self, placeholder_storage: ModuleStorage
|
||||||
|
) -> None:
|
||||||
"""Multiple different placeholder types resolve in a single template."""
|
"""Multiple different placeholder types resolve in a single template."""
|
||||||
storage = MockStorage({"score": 42})
|
await placeholder_storage.execute(
|
||||||
|
"INSERT INTO counters (name, value) VALUES (?, ?)", ("score", 42)
|
||||||
|
)
|
||||||
random.seed(99)
|
random.seed(99)
|
||||||
expected_roll = random.randint(1, 6)
|
expected_roll = random.randint(1, 6)
|
||||||
random.seed(99)
|
random.seed(99)
|
||||||
result = await process(
|
result = await process(
|
||||||
"$(user) has $(getcount score) points (roll: $(rand 1 6))",
|
"$(user) has $(getcount score) points (roll: $(rand 1 6))",
|
||||||
storage=storage,
|
placeholder_storage,
|
||||||
)
|
)
|
||||||
assert result == f"Alice has 42 points (roll: {expected_roll})"
|
assert result == f"Alice has 42 points (roll: {expected_roll})"
|
||||||
|
|
||||||
@@ -173,9 +173,15 @@ class TestArgPlaceholders:
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
async def test_arg_substitution(self, template, args, expected):
|
async def test_arg_substitution(
|
||||||
|
self,
|
||||||
|
placeholder_storage: ModuleStorage,
|
||||||
|
template: str,
|
||||||
|
args: list[str],
|
||||||
|
expected: str,
|
||||||
|
) -> None:
|
||||||
"""Positional arguments resolve correctly."""
|
"""Positional arguments resolve correctly."""
|
||||||
assert await process(template, args=args) == expected
|
assert await process(template, placeholder_storage, args=args) == expected
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"template,args,expected",
|
"template,args,expected",
|
||||||
@@ -185,9 +191,15 @@ class TestArgPlaceholders:
|
|||||||
pytest.param("$(99)", [], "$(99)", id="index-ninety-nine"),
|
pytest.param("$(99)", [], "$(99)", id="index-ninety-nine"),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
async def test_invalid_arg_index_passthrough(self, template, args, expected):
|
async def test_invalid_arg_index_passthrough(
|
||||||
|
self,
|
||||||
|
placeholder_storage: ModuleStorage,
|
||||||
|
template: str,
|
||||||
|
args: list[str],
|
||||||
|
expected: str,
|
||||||
|
) -> None:
|
||||||
"""Out-of-range or unregistered numeric indices pass through unchanged."""
|
"""Out-of-range or unregistered numeric indices pass through unchanged."""
|
||||||
result = await process(template, args=args)
|
result = await process(template, placeholder_storage, args=args)
|
||||||
assert result == expected
|
assert result == expected
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
@@ -207,39 +219,51 @@ class TestArgPlaceholders:
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
async def test_arg_extra_args_error(self, template, args, expected):
|
async def test_arg_extra_args_error(
|
||||||
|
self,
|
||||||
|
placeholder_storage: ModuleStorage,
|
||||||
|
template: str,
|
||||||
|
args: list[str],
|
||||||
|
expected: str,
|
||||||
|
) -> None:
|
||||||
"""Positional arguments with extra tokens report an error."""
|
"""Positional arguments with extra tokens report an error."""
|
||||||
assert await process(template, args=args) == expected
|
assert await process(template, placeholder_storage, args=args) == expected
|
||||||
|
|
||||||
|
|
||||||
class TestNamedCounters:
|
class TestNamedCounters:
|
||||||
"""$(count name), $(getcount name), increments, resets, and deltas."""
|
"""$(count name), $(getcount name), increments, resets, and deltas."""
|
||||||
|
|
||||||
async def test_named_death_counter_increments(self):
|
async def test_named_death_counter_increments(
|
||||||
|
self, placeholder_storage: ModuleStorage
|
||||||
|
) -> None:
|
||||||
"""$(count name) increments the named counter by 1 each call."""
|
"""$(count name) increments the named counter by 1 each call."""
|
||||||
storage = MockStorage()
|
|
||||||
result = await process(
|
result = await process(
|
||||||
"We have died $(count deaths) times.",
|
"We have died $(count deaths) times.",
|
||||||
storage=storage,
|
placeholder_storage,
|
||||||
)
|
)
|
||||||
assert result == "We have died 1 times."
|
assert result == "We have died 1 times."
|
||||||
result = await process(
|
result = await process(
|
||||||
"We have died $(count deaths) times.",
|
"We have died $(count deaths) times.",
|
||||||
storage=storage,
|
placeholder_storage,
|
||||||
)
|
)
|
||||||
assert result == "We have died 2 times."
|
assert result == "We have died 2 times."
|
||||||
|
|
||||||
async def test_getcount_without_changing(self):
|
async def test_getcount_without_changing(
|
||||||
|
self, placeholder_storage: ModuleStorage
|
||||||
|
) -> None:
|
||||||
"""$(getcount name) reads the counter without modifying it."""
|
"""$(getcount name) reads the counter without modifying it."""
|
||||||
storage = MockStorage({"deaths": 5})
|
await placeholder_storage.execute(
|
||||||
|
"INSERT INTO counters (name, value) VALUES (?, ?)", ("deaths", 5)
|
||||||
|
)
|
||||||
result = await process(
|
result = await process(
|
||||||
"Total deaths so far: $(getcount deaths)",
|
"Total deaths so far: $(getcount deaths)",
|
||||||
storage=storage,
|
placeholder_storage,
|
||||||
)
|
)
|
||||||
assert result == "Total deaths so far: 5"
|
assert result == "Total deaths so far: 5"
|
||||||
row = await storage.fetch_one(
|
row = await placeholder_storage.fetch_one(
|
||||||
"SELECT value FROM counters WHERE name = ?", ("deaths",)
|
"SELECT value FROM counters WHERE name = ?", ("deaths",)
|
||||||
)
|
)
|
||||||
|
assert row is not None
|
||||||
assert row["value"] == 5
|
assert row["value"] == 5
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
@@ -262,15 +286,25 @@ class TestNamedCounters:
|
|||||||
],
|
],
|
||||||
)
|
)
|
||||||
async def test_count_with_db_check(
|
async def test_count_with_db_check(
|
||||||
self, counter_values, template, expected, db_name, db_value
|
self,
|
||||||
):
|
placeholder_storage: ModuleStorage,
|
||||||
|
counter_values: dict[str, int],
|
||||||
|
template: str,
|
||||||
|
expected: str,
|
||||||
|
db_name: str,
|
||||||
|
db_value: int,
|
||||||
|
) -> None:
|
||||||
"""$(count) modifies the counter and produces the expected text."""
|
"""$(count) modifies the counter and produces the expected text."""
|
||||||
storage = MockStorage(counter_values)
|
for name, value in counter_values.items():
|
||||||
result = await process(template, storage=storage)
|
await placeholder_storage.execute(
|
||||||
|
"INSERT INTO counters (name, value) VALUES (?, ?)", (name, value)
|
||||||
|
)
|
||||||
|
result = await process(template, placeholder_storage)
|
||||||
assert result == expected
|
assert result == expected
|
||||||
row = await storage.fetch_one(
|
row = await placeholder_storage.fetch_one(
|
||||||
"SELECT value FROM counters WHERE name = ?", (db_name,)
|
"SELECT value FROM counters WHERE name = ?", (db_name,)
|
||||||
)
|
)
|
||||||
|
assert row is not None
|
||||||
assert row["value"] == db_value
|
assert row["value"] == db_value
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
@@ -287,62 +321,84 @@ class TestNamedCounters:
|
|||||||
pytest.param({}, "$(count boss_2_kills)", "1", id="underscore-in-name"),
|
pytest.param({}, "$(count boss_2_kills)", "1", id="underscore-in-name"),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
async def test_count_simple(self, counter_values, template, expected):
|
async def test_count_simple(
|
||||||
|
self,
|
||||||
|
placeholder_storage: ModuleStorage,
|
||||||
|
counter_values: dict[str, int],
|
||||||
|
template: str,
|
||||||
|
expected: str,
|
||||||
|
) -> None:
|
||||||
"""Named counter operations produce the expected text."""
|
"""Named counter operations produce the expected text."""
|
||||||
storage = MockStorage(counter_values)
|
for name, value in counter_values.items():
|
||||||
result = await process(template, storage=storage)
|
await placeholder_storage.execute(
|
||||||
|
"INSERT INTO counters (name, value) VALUES (?, ?)", (name, value)
|
||||||
|
)
|
||||||
|
result = await process(template, placeholder_storage)
|
||||||
assert result == expected
|
assert result == expected
|
||||||
|
|
||||||
async def test_multiple_named_counters(self):
|
async def test_multiple_named_counters(
|
||||||
|
self, placeholder_storage: ModuleStorage
|
||||||
|
) -> None:
|
||||||
"""Different named counters are independent of each other."""
|
"""Different named counters are independent of each other."""
|
||||||
storage = MockStorage({"wins": 3, "losses": 1})
|
await placeholder_storage.execute(
|
||||||
|
"INSERT INTO counters (name, value) VALUES (?, ?)", ("wins", 3)
|
||||||
|
)
|
||||||
|
await placeholder_storage.execute(
|
||||||
|
"INSERT INTO counters (name, value) VALUES (?, ?)", ("losses", 1)
|
||||||
|
)
|
||||||
result = await process(
|
result = await process(
|
||||||
"Wins: $(getcount wins) | Losses: $(getcount losses)",
|
"Wins: $(getcount wins) | Losses: $(getcount losses)",
|
||||||
storage=storage,
|
placeholder_storage,
|
||||||
)
|
)
|
||||||
assert result == "Wins: 3 | Losses: 1"
|
assert result == "Wins: 3 | Losses: 1"
|
||||||
|
|
||||||
async def test_adjacent_counters_both_increment(self):
|
async def test_adjacent_counters_both_increment(
|
||||||
|
self, placeholder_storage: ModuleStorage
|
||||||
|
) -> None:
|
||||||
"""Two different counters in one template both increment independently."""
|
"""Two different counters in one template both increment independently."""
|
||||||
storage = MockStorage()
|
|
||||||
result = await process(
|
result = await process(
|
||||||
"A: $(count a) B: $(count b)",
|
"A: $(count a) B: $(count b)",
|
||||||
storage=storage,
|
placeholder_storage,
|
||||||
)
|
)
|
||||||
assert result == "A: 1 B: 1"
|
assert result == "A: 1 B: 1"
|
||||||
row = await storage.fetch_one(
|
row = await placeholder_storage.fetch_one(
|
||||||
"SELECT value FROM counters WHERE name = ?", ("a",)
|
"SELECT value FROM counters WHERE name = ?", ("a",)
|
||||||
)
|
)
|
||||||
|
assert row is not None
|
||||||
assert row["value"] == 1
|
assert row["value"] == 1
|
||||||
row = await storage.fetch_one(
|
row = await placeholder_storage.fetch_one(
|
||||||
"SELECT value FROM counters WHERE name = ?", ("b",)
|
"SELECT value FROM counters WHERE name = ?", ("b",)
|
||||||
)
|
)
|
||||||
|
assert row is not None
|
||||||
assert row["value"] == 1
|
assert row["value"] == 1
|
||||||
|
|
||||||
async def test_same_counter_twice_in_one_template(self):
|
async def test_same_counter_twice_in_one_template(
|
||||||
|
self, placeholder_storage: ModuleStorage
|
||||||
|
) -> None:
|
||||||
"""Two references to the same counter both increment."""
|
"""Two references to the same counter both increment."""
|
||||||
storage = MockStorage()
|
|
||||||
result = await process(
|
result = await process(
|
||||||
"$(count x) then $(count x)",
|
"$(count x) then $(count x)",
|
||||||
storage=storage,
|
placeholder_storage,
|
||||||
)
|
)
|
||||||
assert result == "1 then 2"
|
assert result == "1 then 2"
|
||||||
|
|
||||||
async def test_count_and_getcount_same_counter(self):
|
async def test_count_and_getcount_same_counter(
|
||||||
|
self, placeholder_storage: ModuleStorage
|
||||||
|
) -> None:
|
||||||
"""$(count) increments, then $(getcount) reads the new value."""
|
"""$(count) increments, then $(getcount) reads the new value."""
|
||||||
storage = MockStorage()
|
|
||||||
result = await process(
|
result = await process(
|
||||||
"Now: $(count pts) Total: $(getcount pts)",
|
"Now: $(count pts) Total: $(getcount pts)",
|
||||||
storage=storage,
|
placeholder_storage,
|
||||||
)
|
)
|
||||||
assert result == "Now: 1 Total: 1"
|
assert result == "Now: 1 Total: 1"
|
||||||
|
|
||||||
async def test_count_absolute_set_then_read(self):
|
async def test_count_absolute_set_then_read(
|
||||||
|
self, placeholder_storage: ModuleStorage
|
||||||
|
) -> None:
|
||||||
"""Absolute set followed by getcount confirms persistence."""
|
"""Absolute set followed by getcount confirms persistence."""
|
||||||
storage = MockStorage()
|
|
||||||
result = await process(
|
result = await process(
|
||||||
"$(count score 42) -> $(getcount score)",
|
"$(count score 42) -> $(getcount score)",
|
||||||
storage=storage,
|
placeholder_storage,
|
||||||
)
|
)
|
||||||
assert result == "42 -> 42"
|
assert result == "42 -> 42"
|
||||||
|
|
||||||
@@ -353,9 +409,11 @@ class TestNamedCounters:
|
|||||||
pytest.param("-", id="minus"),
|
pytest.param("-", id="minus"),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
async def test_bare_sign_rejected(self, sign):
|
async def test_bare_sign_rejected(
|
||||||
|
self, placeholder_storage: ModuleStorage, sign: str
|
||||||
|
) -> None:
|
||||||
"""$(count x +) and $(count x -) report an error."""
|
"""$(count x +) and $(count x -) report an error."""
|
||||||
result = await process(f"$(count x {sign})")
|
result = await process(f"$(count x {sign})", placeholder_storage)
|
||||||
assert (
|
assert (
|
||||||
result == "Invalid $(count): modifier must be an integer (e.g., +5, -1, 0)"
|
result == "Invalid $(count): modifier must be an integer (e.g., +5, -1, 0)"
|
||||||
)
|
)
|
||||||
@@ -371,14 +429,21 @@ class TestCounterNameValidation:
|
|||||||
pytest.param("$(count 123)", "1", "123", 1, id="numeric-name"),
|
pytest.param("$(count 123)", "1", "123", 1, id="numeric-name"),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
async def test_count_name_normalized(self, template, expected, db_name, db_value):
|
async def test_count_name_normalized(
|
||||||
|
self,
|
||||||
|
placeholder_storage: ModuleStorage,
|
||||||
|
template: str,
|
||||||
|
expected: str,
|
||||||
|
db_name: str,
|
||||||
|
db_value: int,
|
||||||
|
) -> None:
|
||||||
"""Counter names are normalized and accepted."""
|
"""Counter names are normalized and accepted."""
|
||||||
storage = MockStorage()
|
result = await process(template, placeholder_storage)
|
||||||
result = await process(template, storage=storage)
|
|
||||||
assert result == expected
|
assert result == expected
|
||||||
row = await storage.fetch_one(
|
row = await placeholder_storage.fetch_one(
|
||||||
"SELECT value FROM counters WHERE name = ?", (db_name,)
|
"SELECT value FROM counters WHERE name = ?", (db_name,)
|
||||||
)
|
)
|
||||||
|
assert row is not None
|
||||||
assert row["value"] == db_value
|
assert row["value"] == db_value
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
@@ -390,30 +455,40 @@ class TestCounterNameValidation:
|
|||||||
pytest.param("-3", id="leading-minus"),
|
pytest.param("-3", id="leading-minus"),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
async def test_count_invalid_name_rejected(self, name):
|
async def test_count_invalid_name_rejected(
|
||||||
|
self, placeholder_storage: ModuleStorage, name: str
|
||||||
|
) -> None:
|
||||||
"""Names with hyphens, dots, or leading +/- are rejected."""
|
"""Names with hyphens, dots, or leading +/- are rejected."""
|
||||||
result = await process(f"$(count {name})")
|
result = await process(f"$(count {name})", placeholder_storage)
|
||||||
assert result == (
|
assert result == (
|
||||||
"Invalid $(count): counter name may only contain "
|
"Invalid $(count): counter name may only contain "
|
||||||
"letters, numbers, and underscores"
|
"letters, numbers, and underscores"
|
||||||
)
|
)
|
||||||
|
|
||||||
async def test_count_space_in_name_rejected(self):
|
async def test_count_space_in_name_rejected(
|
||||||
|
self, placeholder_storage: ModuleStorage
|
||||||
|
) -> None:
|
||||||
"""A name with a space becomes two arguments; the second is a bad modifier."""
|
"""A name with a space becomes two arguments; the second is a bad modifier."""
|
||||||
result = await process("$(count my counter)")
|
result = await process("$(count my counter)", placeholder_storage)
|
||||||
assert (
|
assert (
|
||||||
result == "Invalid $(count): modifier must be an integer (e.g., +5, -1, 0)"
|
result == "Invalid $(count): modifier must be an integer (e.g., +5, -1, 0)"
|
||||||
)
|
)
|
||||||
|
|
||||||
async def test_getcount_uppercase_name_lowered(self):
|
async def test_getcount_uppercase_name_lowered(
|
||||||
|
self, placeholder_storage: ModuleStorage
|
||||||
|
) -> None:
|
||||||
"""Uppercase getcount names are normalized to lowercase."""
|
"""Uppercase getcount names are normalized to lowercase."""
|
||||||
storage = MockStorage({"deaths": 5})
|
await placeholder_storage.execute(
|
||||||
result = await process("$(getcount Deaths)", storage=storage)
|
"INSERT INTO counters (name, value) VALUES (?, ?)", ("deaths", 5)
|
||||||
|
)
|
||||||
|
result = await process("$(getcount Deaths)", placeholder_storage)
|
||||||
assert result == "5"
|
assert result == "5"
|
||||||
|
|
||||||
async def test_getcount_hyphen_in_name_rejected(self):
|
async def test_getcount_hyphen_in_name_rejected(
|
||||||
|
self, placeholder_storage: ModuleStorage
|
||||||
|
) -> None:
|
||||||
"""Hyphens are not allowed in getcount names."""
|
"""Hyphens are not allowed in getcount names."""
|
||||||
result = await process("$(getcount boss-kills)")
|
result = await process("$(getcount boss-kills)", placeholder_storage)
|
||||||
assert result == (
|
assert result == (
|
||||||
"Invalid $(getcount): counter name may only contain "
|
"Invalid $(getcount): counter name may only contain "
|
||||||
"letters, numbers, and underscores"
|
"letters, numbers, and underscores"
|
||||||
@@ -423,12 +498,12 @@ class TestCounterNameValidation:
|
|||||||
class TestRand:
|
class TestRand:
|
||||||
"""All $(rand) behavior: basic usage, negative ranges, and boundaries."""
|
"""All $(rand) behavior: basic usage, negative ranges, and boundaries."""
|
||||||
|
|
||||||
async def test_dice_roll(self):
|
async def test_dice_roll(self, placeholder_storage: ModuleStorage) -> None:
|
||||||
"""$(rand 1 6) produces a random integer in range."""
|
"""$(rand 1 6) produces a random integer in range."""
|
||||||
random.seed(0)
|
random.seed(0)
|
||||||
expected_roll = random.randint(1, 6)
|
expected_roll = random.randint(1, 6)
|
||||||
random.seed(0)
|
random.seed(0)
|
||||||
result = await process("$(user) rolled a $(rand 1 6)!")
|
result = await process("$(user) rolled a $(rand 1 6)!", placeholder_storage)
|
||||||
assert result == f"Alice rolled a {expected_roll}!"
|
assert result == f"Alice rolled a {expected_roll}!"
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
@@ -439,63 +514,77 @@ class TestRand:
|
|||||||
pytest.param("$(rand 1 1)", "1", id="extra-whitespace"),
|
pytest.param("$(rand 1 1)", "1", id="extra-whitespace"),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
async def test_rand_deterministic(self, template, expected):
|
async def test_rand_deterministic(
|
||||||
|
self, placeholder_storage: ModuleStorage, template: str, expected: str
|
||||||
|
) -> None:
|
||||||
"""$(rand) with fixed bounds produces a deterministic result."""
|
"""$(rand) with fixed bounds produces a deterministic result."""
|
||||||
assert await process(template) == expected
|
assert await process(template, placeholder_storage) == expected
|
||||||
|
|
||||||
async def test_rand_reversed_range(self):
|
async def test_rand_reversed_range(
|
||||||
|
self, placeholder_storage: ModuleStorage
|
||||||
|
) -> None:
|
||||||
"""$(rand 10 1) auto-sorts bounds and produces a value in range."""
|
"""$(rand 10 1) auto-sorts bounds and produces a value in range."""
|
||||||
random.seed(0)
|
random.seed(0)
|
||||||
expected = random.randint(1, 10)
|
expected = random.randint(1, 10)
|
||||||
random.seed(0)
|
random.seed(0)
|
||||||
result = await process("$(rand 10 1)")
|
result = await process("$(rand 10 1)", placeholder_storage)
|
||||||
assert result == str(expected)
|
assert result == str(expected)
|
||||||
|
|
||||||
async def test_both_negative(self):
|
async def test_both_negative(self, placeholder_storage: ModuleStorage) -> None:
|
||||||
"""$(rand) works with both bounds negative."""
|
"""$(rand) works with both bounds negative."""
|
||||||
random.seed(0)
|
random.seed(0)
|
||||||
expected = random.randint(-10, -1)
|
expected = random.randint(-10, -1)
|
||||||
random.seed(0)
|
random.seed(0)
|
||||||
result = await process("$(rand -10 -1)")
|
result = await process("$(rand -10 -1)", placeholder_storage)
|
||||||
assert result == str(expected)
|
assert result == str(expected)
|
||||||
|
|
||||||
async def test_negative_to_positive(self):
|
async def test_negative_to_positive(
|
||||||
|
self, placeholder_storage: ModuleStorage
|
||||||
|
) -> None:
|
||||||
"""$(rand) works with a range crossing zero."""
|
"""$(rand) works with a range crossing zero."""
|
||||||
random.seed(0)
|
random.seed(0)
|
||||||
expected = random.randint(-5, 5)
|
expected = random.randint(-5, 5)
|
||||||
random.seed(0)
|
random.seed(0)
|
||||||
result = await process("$(rand -5 5)")
|
result = await process("$(rand -5 5)", placeholder_storage)
|
||||||
assert result == str(expected)
|
assert result == str(expected)
|
||||||
|
|
||||||
async def test_zero_crossing_reversed(self):
|
async def test_zero_crossing_reversed(
|
||||||
|
self, placeholder_storage: ModuleStorage
|
||||||
|
) -> None:
|
||||||
"""$(rand 5 -5) auto-sorts bounds to produce a valid range."""
|
"""$(rand 5 -5) auto-sorts bounds to produce a valid range."""
|
||||||
random.seed(0)
|
random.seed(0)
|
||||||
expected = random.randint(-5, 5)
|
expected = random.randint(-5, 5)
|
||||||
random.seed(0)
|
random.seed(0)
|
||||||
result = await process("$(rand 5 -5)")
|
result = await process("$(rand 5 -5)", placeholder_storage)
|
||||||
assert result == str(expected)
|
assert result == str(expected)
|
||||||
|
|
||||||
|
|
||||||
class TestCountdownCountup:
|
class TestCountdownCountup:
|
||||||
"""Date/time placeholders and duration formatting."""
|
"""Date/time placeholders and duration formatting."""
|
||||||
|
|
||||||
async def test_countdown_future_date(self):
|
async def test_countdown_future_date(
|
||||||
|
self, placeholder_storage: ModuleStorage
|
||||||
|
) -> None:
|
||||||
"""$(countdown) with a future date returns the formatted duration."""
|
"""$(countdown) with a future date returns the formatted duration."""
|
||||||
with freeze_time("2026-06-15 12:00:00", tz_offset=0):
|
with freeze_time("2026-06-15 12:00:00", tz_offset=0):
|
||||||
now = datetime.now(UTC)
|
now = datetime.now(UTC)
|
||||||
# Dec 25 2099 00:00:00 EST = Dec 25 2099 05:00:00 UTC
|
# Dec 25 2099 00:00:00 EST = Dec 25 2099 05:00:00 UTC
|
||||||
target = datetime(2099, 12, 25, 5, 0, 0, tzinfo=UTC)
|
target = datetime(2099, 12, 25, 5, 0, 0, tzinfo=UTC)
|
||||||
expected = _format_duration(int((target - now).total_seconds()))
|
expected = _format_duration(int((target - now).total_seconds()))
|
||||||
result = await process("$(countdown Dec 25 2099 12:00:00 AM EST)")
|
result = await process(
|
||||||
|
"$(countdown Dec 25 2099 12:00:00 AM EST)", placeholder_storage
|
||||||
|
)
|
||||||
assert result == expected
|
assert result == expected
|
||||||
|
|
||||||
async def test_countup_past_date(self):
|
async def test_countup_past_date(self, placeholder_storage: ModuleStorage) -> None:
|
||||||
"""$(countup) with a past date returns the formatted elapsed duration."""
|
"""$(countup) with a past date returns the formatted elapsed duration."""
|
||||||
with freeze_time("2026-06-15 12:00:00", tz_offset=0):
|
with freeze_time("2026-06-15 12:00:00", tz_offset=0):
|
||||||
now = datetime.now(UTC)
|
now = datetime.now(UTC)
|
||||||
target = datetime(2020, 1, 1, 0, 0, 0, tzinfo=UTC)
|
target = datetime(2020, 1, 1, 0, 0, 0, tzinfo=UTC)
|
||||||
expected = _format_duration(int((now - target).total_seconds()))
|
expected = _format_duration(int((now - target).total_seconds()))
|
||||||
result = await process("$(countup Jan 1 2020 12:00:00 AM UTC)")
|
result = await process(
|
||||||
|
"$(countup Jan 1 2020 12:00:00 AM UTC)", placeholder_storage
|
||||||
|
)
|
||||||
assert result == expected
|
assert result == expected
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
@@ -508,9 +597,11 @@ class TestCountdownCountup:
|
|||||||
pytest.param("$(countdown Jan 1 2000 12:00:00 AM utc)", id="lowercase-tz"),
|
pytest.param("$(countdown Jan 1 2000 12:00:00 AM utc)", id="lowercase-tz"),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
async def test_zero_seconds(self, template):
|
async def test_zero_seconds(
|
||||||
|
self, placeholder_storage: ModuleStorage, template: str
|
||||||
|
) -> None:
|
||||||
"""Elapsed/remaining time of zero returns '0 seconds'."""
|
"""Elapsed/remaining time of zero returns '0 seconds'."""
|
||||||
assert await process(template) == "0 seconds"
|
assert await process(template, placeholder_storage) == "0 seconds"
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"placeholder,bad_date",
|
"placeholder,bad_date",
|
||||||
@@ -522,22 +613,28 @@ class TestCountdownCountup:
|
|||||||
pytest.param("countdown", "not a valid date UTC", id="gibberish-with-tz"),
|
pytest.param("countdown", "not a valid date UTC", id="gibberish-with-tz"),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
async def test_invalid_date(self, placeholder, bad_date):
|
async def test_invalid_date(
|
||||||
|
self, placeholder_storage: ModuleStorage, placeholder: str, bad_date: str
|
||||||
|
) -> None:
|
||||||
"""Placeholders with an unparseable date report an error."""
|
"""Placeholders with an unparseable date report an error."""
|
||||||
result = await process(f"$({placeholder} {bad_date})")
|
result = await process(f"$({placeholder} {bad_date})", placeholder_storage)
|
||||||
assert result == (
|
assert result == (
|
||||||
f"Invalid $({placeholder}): unrecognized date format, "
|
f"Invalid $({placeholder}): unrecognized date format, "
|
||||||
f"expected $({placeholder} Dec 25 2025 12:00:00 AM EST)"
|
f"expected $({placeholder} Dec 25 2025 12:00:00 AM EST)"
|
||||||
)
|
)
|
||||||
|
|
||||||
async def test_fractional_timezone_offset(self):
|
async def test_fractional_timezone_offset(
|
||||||
|
self, placeholder_storage: ModuleStorage
|
||||||
|
) -> None:
|
||||||
"""A fractional timezone offset like IST (UTC+5:30) is applied correctly."""
|
"""A fractional timezone offset like IST (UTC+5:30) is applied correctly."""
|
||||||
with freeze_time("2026-06-15 12:00:00", tz_offset=0):
|
with freeze_time("2026-06-15 12:00:00", tz_offset=0):
|
||||||
now = datetime.now(UTC)
|
now = datetime.now(UTC)
|
||||||
# Jan 1 2020 12:00:00 AM IST = Dec 31 2019 18:30:00 UTC
|
# Jan 1 2020 12:00:00 AM IST = Dec 31 2019 18:30:00 UTC
|
||||||
target = datetime(2019, 12, 31, 18, 30, 0, tzinfo=UTC)
|
target = datetime(2019, 12, 31, 18, 30, 0, tzinfo=UTC)
|
||||||
expected = _format_duration(int((now - target).total_seconds()))
|
expected = _format_duration(int((now - target).total_seconds()))
|
||||||
result = await process("$(countup Jan 1 2020 12:00:00 AM IST)")
|
result = await process(
|
||||||
|
"$(countup Jan 1 2020 12:00:00 AM IST)", placeholder_storage
|
||||||
|
)
|
||||||
assert result == expected
|
assert result == expected
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
@@ -563,7 +660,7 @@ class TestCountdownCountup:
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
async def test_format_duration(self, seconds, expected):
|
async def test_format_duration(self, seconds: int, expected: str) -> None:
|
||||||
"""_format_duration produces the correct human-readable string."""
|
"""_format_duration produces the correct human-readable string."""
|
||||||
assert _format_duration(seconds) == expected
|
assert _format_duration(seconds) == expected
|
||||||
|
|
||||||
@@ -571,67 +668,84 @@ class TestCountdownCountup:
|
|||||||
class TestNesting:
|
class TestNesting:
|
||||||
"""Nested placeholder resolution and child whitespace behavior."""
|
"""Nested placeholder resolution and child whitespace behavior."""
|
||||||
|
|
||||||
async def test_rand_with_arg_placeholders(self):
|
async def test_rand_with_arg_placeholders(
|
||||||
|
self, placeholder_storage: ModuleStorage
|
||||||
|
) -> None:
|
||||||
"""$(rand $(1) $(2)) resolves inner arguments before calling rand."""
|
"""$(rand $(1) $(2)) resolves inner arguments before calling rand."""
|
||||||
random.seed(0)
|
random.seed(0)
|
||||||
expected = random.randint(5, 10)
|
expected = random.randint(5, 10)
|
||||||
random.seed(0)
|
random.seed(0)
|
||||||
result = await process("$(rand $(1) $(2))", args=["5", "10"])
|
result = await process(
|
||||||
|
"$(rand $(1) $(2))", placeholder_storage, args=["5", "10"]
|
||||||
|
)
|
||||||
assert result == str(expected)
|
assert result == str(expected)
|
||||||
|
|
||||||
async def test_multi_level_nesting(self):
|
async def test_multi_level_nesting(
|
||||||
|
self, placeholder_storage: ModuleStorage
|
||||||
|
) -> None:
|
||||||
"""$(rand 1 $(rand 5 10)) resolves the inner rand first."""
|
"""$(rand 1 $(rand 5 10)) resolves the inner rand first."""
|
||||||
random.seed(0)
|
random.seed(0)
|
||||||
inner = random.randint(5, 10)
|
inner = random.randint(5, 10)
|
||||||
outer = random.randint(1, inner)
|
outer = random.randint(1, inner)
|
||||||
random.seed(0)
|
random.seed(0)
|
||||||
result = await process("$(rand 1 $(rand 5 10))")
|
result = await process("$(rand 1 $(rand 5 10))", placeholder_storage)
|
||||||
assert result == str(outer)
|
assert result == str(outer)
|
||||||
|
|
||||||
async def test_nested_in_sentence(self):
|
async def test_nested_in_sentence(self, placeholder_storage: ModuleStorage) -> None:
|
||||||
"""Nested placeholders work when surrounded by literal text."""
|
"""Nested placeholders work when surrounded by literal text."""
|
||||||
random.seed(0)
|
random.seed(0)
|
||||||
expected_roll = random.randint(1, 20)
|
expected_roll = random.randint(1, 20)
|
||||||
random.seed(0)
|
random.seed(0)
|
||||||
result = await process(
|
result = await process(
|
||||||
"$(user) rolled $(rand $(1) $(2))!",
|
"$(user) rolled $(rand $(1) $(2))!",
|
||||||
|
placeholder_storage,
|
||||||
args=["1", "20"],
|
args=["1", "20"],
|
||||||
)
|
)
|
||||||
assert result == f"Alice rolled {expected_roll}!"
|
assert result == f"Alice rolled {expected_roll}!"
|
||||||
|
|
||||||
async def test_nested_getcount_as_rand_bound(self):
|
async def test_nested_getcount_as_rand_bound(
|
||||||
|
self, placeholder_storage: ModuleStorage
|
||||||
|
) -> None:
|
||||||
"""$(getcount) result feeds into $(rand) as a bound."""
|
"""$(getcount) result feeds into $(rand) as a bound."""
|
||||||
storage = MockStorage({"max": 50})
|
await placeholder_storage.execute(
|
||||||
|
"INSERT INTO counters (name, value) VALUES (?, ?)", ("max", 50)
|
||||||
|
)
|
||||||
random.seed(0)
|
random.seed(0)
|
||||||
expected = random.randint(1, 50)
|
expected = random.randint(1, 50)
|
||||||
random.seed(0)
|
random.seed(0)
|
||||||
result = await process("$(rand 1 $(getcount max))", storage=storage)
|
result = await process("$(rand 1 $(getcount max))", placeholder_storage)
|
||||||
assert result == str(expected)
|
assert result == str(expected)
|
||||||
|
|
||||||
async def test_nested_count_as_rand_bound(self):
|
async def test_nested_count_as_rand_bound(
|
||||||
|
self, placeholder_storage: ModuleStorage
|
||||||
|
) -> None:
|
||||||
"""A counter result feeds into rand as an argument."""
|
"""A counter result feeds into rand as an argument."""
|
||||||
storage = MockStorage({"upper": 9})
|
await placeholder_storage.execute(
|
||||||
|
"INSERT INTO counters (name, value) VALUES (?, ?)", ("upper", 9)
|
||||||
|
)
|
||||||
# $(count upper) increments 9 -> 10.
|
# $(count upper) increments 9 -> 10.
|
||||||
random.seed(0)
|
random.seed(0)
|
||||||
expected = random.randint(1, 10)
|
expected = random.randint(1, 10)
|
||||||
random.seed(0)
|
random.seed(0)
|
||||||
result = await process(
|
result = await process(
|
||||||
"$(rand 1 $(count upper))",
|
"$(rand 1 $(count upper))",
|
||||||
storage=storage,
|
placeholder_storage,
|
||||||
)
|
)
|
||||||
assert result == str(expected)
|
assert result == str(expected)
|
||||||
|
|
||||||
async def test_three_levels_deep(self):
|
async def test_three_levels_deep(self, placeholder_storage: ModuleStorage) -> None:
|
||||||
"""$(rand 1 $(rand 1 $(rand 5 10))) resolves inside-out."""
|
"""$(rand 1 $(rand 1 $(rand 5 10))) resolves inside-out."""
|
||||||
random.seed(0)
|
random.seed(0)
|
||||||
innermost = random.randint(5, 10)
|
innermost = random.randint(5, 10)
|
||||||
middle = random.randint(1, innermost)
|
middle = random.randint(1, innermost)
|
||||||
outer = random.randint(1, middle)
|
outer = random.randint(1, middle)
|
||||||
random.seed(0)
|
random.seed(0)
|
||||||
result = await process("$(rand 1 $(rand 1 $(rand 5 10)))")
|
result = await process("$(rand 1 $(rand 1 $(rand 5 10)))", placeholder_storage)
|
||||||
assert result == str(outer)
|
assert result == str(outer)
|
||||||
|
|
||||||
async def test_multiple_nested_placeholders_in_one_template(self):
|
async def test_multiple_nested_placeholders_in_one_template(
|
||||||
|
self, placeholder_storage: ModuleStorage
|
||||||
|
) -> None:
|
||||||
"""Two separate nested expressions in one template both resolve."""
|
"""Two separate nested expressions in one template both resolve."""
|
||||||
random.seed(0)
|
random.seed(0)
|
||||||
low = random.randint(1, 5)
|
low = random.randint(1, 5)
|
||||||
@@ -639,18 +753,23 @@ class TestNesting:
|
|||||||
random.seed(0)
|
random.seed(0)
|
||||||
result = await process(
|
result = await process(
|
||||||
"Low: $(rand $(1) $(2)) High: $(rand $(3) $(4))",
|
"Low: $(rand $(1) $(2)) High: $(rand $(3) $(4))",
|
||||||
|
placeholder_storage,
|
||||||
args=["1", "5", "50", "100"],
|
args=["1", "5", "50", "100"],
|
||||||
)
|
)
|
||||||
assert result == f"Low: {low} High: {high}"
|
assert result == f"Low: {low} High: {high}"
|
||||||
|
|
||||||
async def test_nested_user_in_other_placeholder(self):
|
async def test_nested_user_in_other_placeholder(
|
||||||
|
self, placeholder_storage: ModuleStorage
|
||||||
|
) -> None:
|
||||||
"""$(user) nested inside an unknown placeholder passes through."""
|
"""$(user) nested inside an unknown placeholder passes through."""
|
||||||
result = await process("$(greet $(user))")
|
result = await process("$(greet $(user))", placeholder_storage)
|
||||||
assert result == "$(greet Alice)"
|
assert result == "$(greet Alice)"
|
||||||
|
|
||||||
async def test_meta_nesting_degrades(self):
|
async def test_meta_nesting_degrades(
|
||||||
|
self, placeholder_storage: ModuleStorage
|
||||||
|
) -> None:
|
||||||
"""$($(1)) is not valid and degrades gracefully."""
|
"""$($(1)) is not valid and degrades gracefully."""
|
||||||
result = await process("$($(1))", args=["test"])
|
result = await process("$($(1))", placeholder_storage, args=["test"])
|
||||||
assert result == "$(test)"
|
assert result == "$(test)"
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
@@ -661,16 +780,22 @@ class TestNesting:
|
|||||||
pytest.param("$(rand $(1)$(2))", ["5", "10"], id="no-space-between"),
|
pytest.param("$(rand $(1)$(2))", ["5", "10"], id="no-space-between"),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
async def test_nested_too_few_args(self, template, args):
|
async def test_nested_too_few_args(
|
||||||
|
self, placeholder_storage: ModuleStorage, template: str, args: list[str]
|
||||||
|
) -> None:
|
||||||
"""Nested $(rand) with insufficient tokens reports too few arguments."""
|
"""Nested $(rand) with insufficient tokens reports too few arguments."""
|
||||||
result = await process(template, args=args)
|
result = await process(template, placeholder_storage, args=args)
|
||||||
assert (
|
assert (
|
||||||
result == "Invalid $(rand): too few arguments, expected $(rand start stop)"
|
result == "Invalid $(rand): too few arguments, expected $(rand start stop)"
|
||||||
)
|
)
|
||||||
|
|
||||||
async def test_adjacent_children_with_explicit_space(self):
|
async def test_adjacent_children_with_explicit_space(
|
||||||
|
self, placeholder_storage: ModuleStorage
|
||||||
|
) -> None:
|
||||||
"""$(rand $(1) $(2)) with space between children works normally."""
|
"""$(rand $(1) $(2)) with space between children works normally."""
|
||||||
result = await process("$(rand $(1) $(2))", args=["3", "3"])
|
result = await process(
|
||||||
|
"$(rand $(1) $(2))", placeholder_storage, args=["3", "3"]
|
||||||
|
)
|
||||||
assert result == "3"
|
assert result == "3"
|
||||||
|
|
||||||
|
|
||||||
@@ -714,9 +839,15 @@ class TestEscaping:
|
|||||||
pytest.param("\\$5", {}, "\\$5", id="dollar-number"),
|
pytest.param("\\$5", {}, "\\$5", id="dollar-number"),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
async def test_escaping(self, template, kwargs, expected):
|
async def test_escaping(
|
||||||
|
self,
|
||||||
|
placeholder_storage: ModuleStorage,
|
||||||
|
template: str,
|
||||||
|
kwargs: dict[str, Any],
|
||||||
|
expected: str,
|
||||||
|
) -> None:
|
||||||
"""Escape sequences produce the expected literal text."""
|
"""Escape sequences produce the expected literal text."""
|
||||||
assert await process(template, **kwargs) == expected
|
assert await process(template, placeholder_storage, **kwargs) == expected
|
||||||
|
|
||||||
|
|
||||||
class TestCaseInsensitivity:
|
class TestCaseInsensitivity:
|
||||||
@@ -737,41 +868,60 @@ class TestCaseInsensitivity:
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
async def test_placeholder_name_case_ignored(self, template, kwargs, expected):
|
async def test_placeholder_name_case_ignored(
|
||||||
|
self,
|
||||||
|
placeholder_storage: ModuleStorage,
|
||||||
|
template: str,
|
||||||
|
kwargs: dict[str, Any],
|
||||||
|
expected: str,
|
||||||
|
) -> None:
|
||||||
"""Placeholder names are case-insensitive."""
|
"""Placeholder names are case-insensitive."""
|
||||||
assert await process(template, **kwargs) == expected
|
assert await process(template, placeholder_storage, **kwargs) == expected
|
||||||
|
|
||||||
async def test_getcount_mixed_case(self):
|
async def test_getcount_mixed_case(
|
||||||
|
self, placeholder_storage: ModuleStorage
|
||||||
|
) -> None:
|
||||||
"""$(GetCount) resolves the same as $(getcount)."""
|
"""$(GetCount) resolves the same as $(getcount)."""
|
||||||
storage = MockStorage({"x": 3})
|
await placeholder_storage.execute(
|
||||||
result = await process("$(GetCount x)", storage=storage)
|
"INSERT INTO counters (name, value) VALUES (?, ?)", ("x", 3)
|
||||||
|
)
|
||||||
|
result = await process("$(GetCount x)", placeholder_storage)
|
||||||
assert result == "3"
|
assert result == "3"
|
||||||
|
|
||||||
async def test_countup_case_insensitive(self):
|
async def test_countup_case_insensitive(
|
||||||
|
self, placeholder_storage: ModuleStorage
|
||||||
|
) -> None:
|
||||||
"""$(COUNTUP) resolves the same as $(countup)."""
|
"""$(COUNTUP) resolves the same as $(countup)."""
|
||||||
with freeze_time("2026-06-15 12:00:00", tz_offset=0):
|
with freeze_time("2026-06-15 12:00:00", tz_offset=0):
|
||||||
now = datetime.now(UTC)
|
now = datetime.now(UTC)
|
||||||
target = datetime(2020, 1, 1, 0, 0, 0, tzinfo=UTC)
|
target = datetime(2020, 1, 1, 0, 0, 0, tzinfo=UTC)
|
||||||
expected = _format_duration(int((now - target).total_seconds()))
|
expected = _format_duration(int((now - target).total_seconds()))
|
||||||
result = await process("$(COUNTUP Jan 1 2020 12:00:00 AM UTC)")
|
result = await process(
|
||||||
|
"$(COUNTUP Jan 1 2020 12:00:00 AM UTC)", placeholder_storage
|
||||||
|
)
|
||||||
assert result == expected
|
assert result == expected
|
||||||
|
|
||||||
|
|
||||||
class TestMaxDepth:
|
class TestMaxDepth:
|
||||||
"""Depth limiting behavior for nested placeholders."""
|
"""Depth limiting behavior for nested placeholders."""
|
||||||
|
|
||||||
async def test_max_depth_limits_nesting(self):
|
async def test_max_depth_limits_nesting(
|
||||||
|
self, placeholder_storage: ModuleStorage
|
||||||
|
) -> None:
|
||||||
"""With max_depth=0, all $( are treated as literal text."""
|
"""With max_depth=0, all $( are treated as literal text."""
|
||||||
result = await process("$(user) $(rand 1 6)", max_depth=0)
|
result = await process("$(user) $(rand 1 6)", placeholder_storage, max_depth=0)
|
||||||
assert result == "$(user) $(rand 1 6)"
|
assert result == "$(user) $(rand 1 6)"
|
||||||
|
|
||||||
async def test_max_depth_one_blocks_inner(self):
|
async def test_max_depth_one_blocks_inner(
|
||||||
|
self, placeholder_storage: ModuleStorage
|
||||||
|
) -> None:
|
||||||
"""With max_depth=1, outer placeholder works but inner $( is literal."""
|
"""With max_depth=1, outer placeholder works but inner $( is literal."""
|
||||||
result = await process("$(user)", max_depth=1)
|
result = await process("$(user)", placeholder_storage, max_depth=1)
|
||||||
assert result == "Alice"
|
assert result == "Alice"
|
||||||
# Inner nesting should not resolve.
|
# Inner nesting should not resolve.
|
||||||
result = await process(
|
result = await process(
|
||||||
"$(rand $(1) $(2))",
|
"$(rand $(1) $(2))",
|
||||||
|
placeholder_storage,
|
||||||
args=["5", "10"],
|
args=["5", "10"],
|
||||||
max_depth=1,
|
max_depth=1,
|
||||||
)
|
)
|
||||||
@@ -780,16 +930,21 @@ class TestMaxDepth:
|
|||||||
result == "Invalid $(rand): too few arguments, expected $(rand start stop)"
|
result == "Invalid $(rand): too few arguments, expected $(rand start stop)"
|
||||||
)
|
)
|
||||||
|
|
||||||
async def test_max_depth_two_allows_one_level(self):
|
async def test_max_depth_two_allows_one_level(
|
||||||
|
self, placeholder_storage: ModuleStorage
|
||||||
|
) -> None:
|
||||||
"""max_depth=2: depth-0 outer and depth-1 inner both resolve."""
|
"""max_depth=2: depth-0 outer and depth-1 inner both resolve."""
|
||||||
result = await process(
|
result = await process(
|
||||||
"$(rand $(1) $(2))",
|
"$(rand $(1) $(2))",
|
||||||
|
placeholder_storage,
|
||||||
args=["3", "3"],
|
args=["3", "3"],
|
||||||
max_depth=2,
|
max_depth=2,
|
||||||
)
|
)
|
||||||
assert result == "3"
|
assert result == "3"
|
||||||
|
|
||||||
async def test_nesting_at_default_depth_limit(self):
|
async def test_nesting_at_default_depth_limit(
|
||||||
|
self, placeholder_storage: ModuleStorage
|
||||||
|
) -> None:
|
||||||
"""Four levels of nesting hits the default max_depth=4 boundary.
|
"""Four levels of nesting hits the default max_depth=4 boundary.
|
||||||
|
|
||||||
Levels: $(rand 1 $(rand 1 $(rand 1 $(rand 5 5))))
|
Levels: $(rand 1 $(rand 1 $(rand 1 $(rand 5 5))))
|
||||||
@@ -802,16 +957,23 @@ class TestMaxDepth:
|
|||||||
d1 = random.randint(1, d2)
|
d1 = random.randint(1, d2)
|
||||||
d0 = random.randint(1, d1)
|
d0 = random.randint(1, d1)
|
||||||
random.seed(0)
|
random.seed(0)
|
||||||
result = await process("$(rand 1 $(rand 1 $(rand 1 $(rand 5 5))))")
|
result = await process(
|
||||||
|
"$(rand 1 $(rand 1 $(rand 1 $(rand 5 5))))", placeholder_storage
|
||||||
|
)
|
||||||
assert result == str(d0)
|
assert result == str(d0)
|
||||||
|
|
||||||
async def test_nesting_beyond_default_depth_limit(self):
|
async def test_nesting_beyond_default_depth_limit(
|
||||||
|
self, placeholder_storage: ModuleStorage
|
||||||
|
) -> None:
|
||||||
"""Five levels of nesting exceeds default max_depth=4.
|
"""Five levels of nesting exceeds default max_depth=4.
|
||||||
|
|
||||||
The innermost $( at depth 4 becomes literal text, breaking the
|
The innermost $( at depth 4 becomes literal text, breaking the
|
||||||
outer placeholders.
|
outer placeholders.
|
||||||
"""
|
"""
|
||||||
result = await process("$(rand 1 $(rand 1 $(rand 1 $(rand 1 $(rand 5 5)))))")
|
result = await process(
|
||||||
|
"$(rand 1 $(rand 1 $(rand 1 $(rand 1 $(rand 5 5)))))",
|
||||||
|
placeholder_storage,
|
||||||
|
)
|
||||||
# The depth-4 $( is literal, so the depth-3 rand gets malformed args.
|
# The depth-4 $( is literal, so the depth-3 rand gets malformed args.
|
||||||
assert (
|
assert (
|
||||||
result == "Invalid $(rand): too many arguments, expected $(rand start stop)"
|
result == "Invalid $(rand): too many arguments, expected $(rand start stop)"
|
||||||
@@ -842,9 +1004,11 @@ class TestParserBoundaries:
|
|||||||
pytest.param("$(rand$", "$(rand$", id="trailing-dollar-in-name"),
|
pytest.param("$(rand$", "$(rand$", id="trailing-dollar-in-name"),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
async def test_parser_boundary(self, template, expected):
|
async def test_parser_boundary(
|
||||||
|
self, placeholder_storage: ModuleStorage, template: str, expected: str
|
||||||
|
) -> None:
|
||||||
"""Parser edge cases degrade to literal text."""
|
"""Parser edge cases degrade to literal text."""
|
||||||
assert await process(template) == expected
|
assert await process(template, placeholder_storage) == expected
|
||||||
|
|
||||||
|
|
||||||
class TestUnknownPlaceholders:
|
class TestUnknownPlaceholders:
|
||||||
@@ -865,9 +1029,11 @@ class TestUnknownPlaceholders:
|
|||||||
pytest.param("$(café latte)", "$(café latte)", id="unicode-with-args"),
|
pytest.param("$(café latte)", "$(café latte)", id="unicode-with-args"),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
async def test_unknown_placeholder_passthrough(self, template, expected):
|
async def test_unknown_placeholder_passthrough(
|
||||||
|
self, placeholder_storage: ModuleStorage, template: str, expected: str
|
||||||
|
) -> None:
|
||||||
"""Unrecognized placeholders pass through unchanged."""
|
"""Unrecognized placeholders pass through unchanged."""
|
||||||
result = await process(template)
|
result = await process(template, placeholder_storage)
|
||||||
assert result == expected
|
assert result == expected
|
||||||
|
|
||||||
|
|
||||||
@@ -894,17 +1060,28 @@ class TestEvaluationSafety:
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
async def test_resolved_text_not_reparsed(self, template, kwargs, expected):
|
async def test_resolved_text_not_reparsed(
|
||||||
|
self,
|
||||||
|
placeholder_storage: ModuleStorage,
|
||||||
|
template: str,
|
||||||
|
kwargs: dict[str, Any],
|
||||||
|
expected: str,
|
||||||
|
) -> None:
|
||||||
"""Resolved text containing placeholder syntax is not re-evaluated."""
|
"""Resolved text containing placeholder syntax is not re-evaluated."""
|
||||||
assert await process(template, **kwargs) == expected
|
assert await process(template, placeholder_storage, **kwargs) == expected
|
||||||
|
|
||||||
async def test_nested_arg_placeholder_syntax_not_reprocessed(self):
|
async def test_nested_arg_placeholder_syntax_not_reprocessed(
|
||||||
|
self, placeholder_storage: ModuleStorage
|
||||||
|
) -> None:
|
||||||
"""An inner result containing $() syntax is not reprocessed."""
|
"""An inner result containing $() syntax is not reprocessed."""
|
||||||
result = await process("$(rand $(1) $(2))", args=["5", "5"])
|
result = await process(
|
||||||
|
"$(rand $(1) $(2))", placeholder_storage, args=["5", "5"]
|
||||||
|
)
|
||||||
assert result == "5"
|
assert result == "5"
|
||||||
# Now with args that contain placeholder syntax.
|
# Now with args that contain placeholder syntax.
|
||||||
result = await process(
|
result = await process(
|
||||||
"Say: $(1)",
|
"Say: $(1)",
|
||||||
|
placeholder_storage,
|
||||||
args=["$(count) times"],
|
args=["$(count) times"],
|
||||||
)
|
)
|
||||||
assert result == "Say: $(count) times"
|
assert result == "Say: $(count) times"
|
||||||
@@ -981,26 +1158,34 @@ class TestRuntimeErrors:
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
async def test_error_message(self, template, expected_error):
|
async def test_error_message(
|
||||||
|
self, placeholder_storage: ModuleStorage, template: str, expected_error: str
|
||||||
|
) -> None:
|
||||||
"""Placeholder errors produce the correct diagnostic message."""
|
"""Placeholder errors produce the correct diagnostic message."""
|
||||||
assert await process(template) == expected_error
|
assert await process(template, placeholder_storage) == expected_error
|
||||||
|
|
||||||
async def test_error_cancels_entire_render(self):
|
async def test_error_cancels_entire_render(
|
||||||
|
self, placeholder_storage: ModuleStorage
|
||||||
|
) -> None:
|
||||||
"""An error in any placeholder cancels the whole template."""
|
"""An error in any placeholder cancels the whole template."""
|
||||||
result = await process("$(user) says $(rand 1)")
|
result = await process("$(user) says $(rand 1)", placeholder_storage)
|
||||||
assert (
|
assert (
|
||||||
result == "Invalid $(rand): too few arguments, expected $(rand start stop)"
|
result == "Invalid $(rand): too few arguments, expected $(rand start stop)"
|
||||||
)
|
)
|
||||||
|
|
||||||
async def test_first_error_wins(self):
|
async def test_first_error_wins(self, placeholder_storage: ModuleStorage) -> None:
|
||||||
"""The first PlaceholderError encountered is returned."""
|
"""The first PlaceholderError encountered is returned."""
|
||||||
result = await process("$(rand 1) $(getcount) $(user extra)")
|
result = await process(
|
||||||
|
"$(rand 1) $(getcount) $(user extra)", placeholder_storage
|
||||||
|
)
|
||||||
# Evaluation is left-to-right; $(rand 1) fails first.
|
# Evaluation is left-to-right; $(rand 1) fails first.
|
||||||
assert (
|
assert (
|
||||||
result == "Invalid $(rand): too few arguments, expected $(rand start stop)"
|
result == "Invalid $(rand): too few arguments, expected $(rand start stop)"
|
||||||
)
|
)
|
||||||
|
|
||||||
async def test_nested_inner_error_cancels_render(self):
|
async def test_nested_inner_error_cancels_render(
|
||||||
|
self, placeholder_storage: ModuleStorage
|
||||||
|
) -> None:
|
||||||
"""An error in a nested placeholder cancels the whole render."""
|
"""An error in a nested placeholder cancels the whole render."""
|
||||||
result = await process("$(rand $(getcount) $(2))")
|
result = await process("$(rand $(getcount) $(2))", placeholder_storage)
|
||||||
assert result == "Invalid $(getcount): a counter name is required"
|
assert result == "Invalid $(getcount): a counter name is required"
|
||||||
|
|||||||
@@ -0,0 +1,420 @@
|
|||||||
|
# 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.
|
||||||
|
|
||||||
|
"""Tests for ModuleStorage — the async SQLite storage layer."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from collections.abc import Callable
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from owlbot.api.storage import ModuleStorage, StorageError
|
||||||
|
|
||||||
|
CREATE_TABLE = "CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT, value INTEGER)"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def storage_with_table(storage: ModuleStorage) -> ModuleStorage:
|
||||||
|
"""Storage instance with a pre-created items table."""
|
||||||
|
await storage.execute(CREATE_TABLE)
|
||||||
|
return storage
|
||||||
|
|
||||||
|
|
||||||
|
class TestLazyConnections:
|
||||||
|
"""No connections exist until the first operation."""
|
||||||
|
|
||||||
|
async def test_no_connections_before_use(self, storage: ModuleStorage) -> None:
|
||||||
|
"""Pool is empty immediately after construction."""
|
||||||
|
assert len(storage._all_connections) == 0
|
||||||
|
|
||||||
|
async def test_first_operation_creates_one_connection(
|
||||||
|
self, storage: ModuleStorage
|
||||||
|
) -> None:
|
||||||
|
"""The first execute creates exactly one pooled connection."""
|
||||||
|
await storage.execute("SELECT 1")
|
||||||
|
assert len(storage._all_connections) == 1
|
||||||
|
|
||||||
|
|
||||||
|
class TestPragmas:
|
||||||
|
"""WAL mode and foreign keys are enabled on new connections."""
|
||||||
|
|
||||||
|
async def test_wal_mode(self, storage: ModuleStorage) -> None:
|
||||||
|
"""journal_mode is set to WAL."""
|
||||||
|
value = await storage.fetch_value("PRAGMA journal_mode")
|
||||||
|
assert value == "wal"
|
||||||
|
|
||||||
|
async def test_foreign_keys_enabled(self, storage: ModuleStorage) -> None:
|
||||||
|
"""foreign_keys pragma is enabled."""
|
||||||
|
value = await storage.fetch_value("PRAGMA foreign_keys")
|
||||||
|
assert value == 1
|
||||||
|
|
||||||
|
|
||||||
|
class TestExecute:
|
||||||
|
"""execute() runs SQL statements and returns a cursor."""
|
||||||
|
|
||||||
|
async def test_ddl_creates_table(self, storage: ModuleStorage) -> None:
|
||||||
|
"""CREATE TABLE produces a table that can be queried."""
|
||||||
|
await storage.execute(CREATE_TABLE)
|
||||||
|
rows = await storage.fetch_all(
|
||||||
|
"SELECT name FROM sqlite_master WHERE type='table' AND name='items'"
|
||||||
|
)
|
||||||
|
assert len(rows) == 1
|
||||||
|
|
||||||
|
async def test_insert_returns_lastrowid(
|
||||||
|
self, storage_with_table: ModuleStorage
|
||||||
|
) -> None:
|
||||||
|
"""INSERT returns a cursor whose lastrowid is set."""
|
||||||
|
cursor = await storage_with_table.execute(
|
||||||
|
"INSERT INTO items (name, value) VALUES (?, ?)", ("a", 1)
|
||||||
|
)
|
||||||
|
assert cursor.lastrowid == 1
|
||||||
|
|
||||||
|
async def test_update_returns_rowcount(
|
||||||
|
self, storage_with_table: ModuleStorage
|
||||||
|
) -> None:
|
||||||
|
"""UPDATE returns a cursor whose rowcount reflects affected rows."""
|
||||||
|
await storage_with_table.execute(
|
||||||
|
"INSERT INTO items (name, value) VALUES (?, ?)", ("a", 1)
|
||||||
|
)
|
||||||
|
await storage_with_table.execute(
|
||||||
|
"INSERT INTO items (name, value) VALUES (?, ?)", ("b", 2)
|
||||||
|
)
|
||||||
|
cursor = await storage_with_table.execute(
|
||||||
|
"UPDATE items SET value = 0 WHERE value > 0"
|
||||||
|
)
|
||||||
|
assert cursor.rowcount == 2
|
||||||
|
|
||||||
|
|
||||||
|
class TestExecuteMany:
|
||||||
|
"""execute_many() batch-inserts multiple rows."""
|
||||||
|
|
||||||
|
async def test_batch_insert(self, storage_with_table: ModuleStorage) -> None:
|
||||||
|
"""Batch insert adds all rows."""
|
||||||
|
rows_data = [("a", 1), ("b", 2), ("c", 3)]
|
||||||
|
await storage_with_table.execute_many(
|
||||||
|
"INSERT INTO items (name, value) VALUES (?, ?)", rows_data
|
||||||
|
)
|
||||||
|
rows = await storage_with_table.fetch_all("SELECT * FROM items")
|
||||||
|
assert len(rows) == 3
|
||||||
|
|
||||||
|
|
||||||
|
class TestFetchOne:
|
||||||
|
"""fetch_one() returns a Row or None."""
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"accessor, expected",
|
||||||
|
[
|
||||||
|
pytest.param(lambda row: row["name"], "a", id="key-access"),
|
||||||
|
pytest.param(lambda row: row[1], "a", id="index-access"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
async def test_row_access(
|
||||||
|
self,
|
||||||
|
storage_with_table: ModuleStorage,
|
||||||
|
accessor: Callable[[Any], Any],
|
||||||
|
expected: str,
|
||||||
|
) -> None:
|
||||||
|
"""Returned row supports both key and index access."""
|
||||||
|
await storage_with_table.execute(
|
||||||
|
"INSERT INTO items (name, value) VALUES (?, ?)", ("a", 1)
|
||||||
|
)
|
||||||
|
row = await storage_with_table.fetch_one("SELECT * FROM items WHERE id = 1")
|
||||||
|
assert accessor(row) == expected
|
||||||
|
|
||||||
|
async def test_returns_none_when_empty(
|
||||||
|
self, storage_with_table: ModuleStorage
|
||||||
|
) -> None:
|
||||||
|
"""Returns None when no rows match."""
|
||||||
|
row = await storage_with_table.fetch_one("SELECT * FROM items WHERE id = 999")
|
||||||
|
assert row is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestFetchAll:
|
||||||
|
"""fetch_all() returns a list of Rows or an empty list."""
|
||||||
|
|
||||||
|
async def test_returns_all_rows(self, storage_with_table: ModuleStorage) -> None:
|
||||||
|
"""Returns all matching rows."""
|
||||||
|
await storage_with_table.execute_many(
|
||||||
|
"INSERT INTO items (name, value) VALUES (?, ?)",
|
||||||
|
[("a", 1), ("b", 2)],
|
||||||
|
)
|
||||||
|
rows = await storage_with_table.fetch_all("SELECT * FROM items ORDER BY id")
|
||||||
|
assert len(rows) == 2
|
||||||
|
assert rows[0]["name"] == "a"
|
||||||
|
assert rows[1]["name"] == "b"
|
||||||
|
|
||||||
|
async def test_returns_empty_list(self, storage_with_table: ModuleStorage) -> None:
|
||||||
|
"""Returns an empty list when no rows match."""
|
||||||
|
rows = await storage_with_table.fetch_all("SELECT * FROM items WHERE id = 999")
|
||||||
|
assert rows == []
|
||||||
|
|
||||||
|
|
||||||
|
class TestFetchValue:
|
||||||
|
"""fetch_value() returns a scalar or None."""
|
||||||
|
|
||||||
|
async def test_returns_scalar(self, storage_with_table: ModuleStorage) -> None:
|
||||||
|
"""Returns the first column of the first row."""
|
||||||
|
await storage_with_table.execute(
|
||||||
|
"INSERT INTO items (name, value) VALUES (?, ?)", ("a", 42)
|
||||||
|
)
|
||||||
|
value = await storage_with_table.fetch_value(
|
||||||
|
"SELECT value FROM items WHERE name = ?", ("a",)
|
||||||
|
)
|
||||||
|
assert value == 42
|
||||||
|
|
||||||
|
async def test_returns_none_when_empty(
|
||||||
|
self, storage_with_table: ModuleStorage
|
||||||
|
) -> None:
|
||||||
|
"""Returns None when no rows match."""
|
||||||
|
value = await storage_with_table.fetch_value(
|
||||||
|
"SELECT value FROM items WHERE id = 999"
|
||||||
|
)
|
||||||
|
assert value is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestAutoCommit:
|
||||||
|
"""Outside _checkout(), each operation auto-commits or auto-rolls-back."""
|
||||||
|
|
||||||
|
async def test_auto_commits_on_success(
|
||||||
|
self, storage_with_table: ModuleStorage
|
||||||
|
) -> None:
|
||||||
|
"""A successful write is visible from a second storage instance."""
|
||||||
|
await storage_with_table.execute(
|
||||||
|
"INSERT INTO items (name, value) VALUES (?, ?)", ("a", 1)
|
||||||
|
)
|
||||||
|
# Open a second storage pointing at the same file to verify commit.
|
||||||
|
async with ModuleStorage(
|
||||||
|
storage_with_table._db_path.parent, "test_module"
|
||||||
|
) as s2:
|
||||||
|
row = await s2.fetch_one("SELECT name FROM items WHERE id = 1")
|
||||||
|
assert row is not None
|
||||||
|
assert row["name"] == "a"
|
||||||
|
|
||||||
|
async def test_auto_rollback_on_error(
|
||||||
|
self, storage_with_table: ModuleStorage
|
||||||
|
) -> None:
|
||||||
|
"""A failed write does not persist partial changes."""
|
||||||
|
await storage_with_table.execute(
|
||||||
|
"INSERT INTO items (name, value) VALUES (?, ?)", ("a", 1)
|
||||||
|
)
|
||||||
|
with pytest.raises(StorageError):
|
||||||
|
# Duplicate primary key triggers an error.
|
||||||
|
await storage_with_table.execute(
|
||||||
|
"INSERT INTO items (id, name, value) VALUES (?, ?, ?)",
|
||||||
|
(1, "b", 2),
|
||||||
|
)
|
||||||
|
# Original row should still be intact.
|
||||||
|
row = await storage_with_table.fetch_one("SELECT name FROM items WHERE id = 1")
|
||||||
|
assert row is not None
|
||||||
|
assert row["name"] == "a"
|
||||||
|
|
||||||
|
|
||||||
|
class TestCheckout:
|
||||||
|
"""_checkout() pins a single connection for all operations."""
|
||||||
|
|
||||||
|
async def test_commit_persists(self, storage_with_table: ModuleStorage) -> None:
|
||||||
|
"""Writes inside _checkout() become visible after _commit()."""
|
||||||
|
async with storage_with_table._checkout():
|
||||||
|
await storage_with_table.execute(
|
||||||
|
"INSERT INTO items (name, value) VALUES (?, ?)", ("a", 1)
|
||||||
|
)
|
||||||
|
await storage_with_table._commit()
|
||||||
|
|
||||||
|
row = await storage_with_table.fetch_one("SELECT name FROM items WHERE id = 1")
|
||||||
|
assert row is not None
|
||||||
|
assert row["name"] == "a"
|
||||||
|
|
||||||
|
async def test_rollback_discards(self, storage_with_table: ModuleStorage) -> None:
|
||||||
|
"""Writes inside _checkout() are discarded after _rollback()."""
|
||||||
|
async with storage_with_table._checkout():
|
||||||
|
await storage_with_table.execute(
|
||||||
|
"INSERT INTO items (name, value) VALUES (?, ?)", ("a", 1)
|
||||||
|
)
|
||||||
|
await storage_with_table._rollback()
|
||||||
|
|
||||||
|
row = await storage_with_table.fetch_one("SELECT name FROM items WHERE id = 1")
|
||||||
|
assert row is None
|
||||||
|
|
||||||
|
async def test_uncommitted_writes_invisible_from_other_connection(
|
||||||
|
self, storage_with_table: ModuleStorage
|
||||||
|
) -> None:
|
||||||
|
"""Uncommitted writes are not visible from a separate connection."""
|
||||||
|
async with (
|
||||||
|
ModuleStorage(storage_with_table._db_path.parent, "test_module") as s2,
|
||||||
|
storage_with_table._checkout(),
|
||||||
|
):
|
||||||
|
await storage_with_table.execute(
|
||||||
|
"INSERT INTO items (name, value) VALUES (?, ?)", ("a", 1)
|
||||||
|
)
|
||||||
|
# Before commit, s2 should not see the row.
|
||||||
|
row = await s2.fetch_one("SELECT name FROM items WHERE id = 1")
|
||||||
|
assert row is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestTransaction:
|
||||||
|
"""transaction() context manager commits on clean exit, rolls back on exception."""
|
||||||
|
|
||||||
|
async def test_commits_on_clean_exit(
|
||||||
|
self, storage_with_table: ModuleStorage
|
||||||
|
) -> None:
|
||||||
|
"""Writes inside transaction() persist after clean exit."""
|
||||||
|
async with storage_with_table._checkout(), storage_with_table.transaction():
|
||||||
|
await storage_with_table.execute(
|
||||||
|
"INSERT INTO items (name, value) VALUES (?, ?)", ("a", 1)
|
||||||
|
)
|
||||||
|
|
||||||
|
row = await storage_with_table.fetch_one("SELECT name FROM items WHERE id = 1")
|
||||||
|
assert row is not None
|
||||||
|
assert row["name"] == "a"
|
||||||
|
|
||||||
|
async def test_rolls_back_on_exception(
|
||||||
|
self, storage_with_table: ModuleStorage
|
||||||
|
) -> None:
|
||||||
|
"""Writes inside transaction() are discarded if an exception is raised."""
|
||||||
|
with pytest.raises(RuntimeError, match="boom"):
|
||||||
|
async with storage_with_table._checkout(), storage_with_table.transaction():
|
||||||
|
await storage_with_table.execute(
|
||||||
|
"INSERT INTO items (name, value) VALUES (?, ?)",
|
||||||
|
("a", 1),
|
||||||
|
)
|
||||||
|
raise RuntimeError("boom")
|
||||||
|
|
||||||
|
row = await storage_with_table.fetch_one("SELECT name FROM items WHERE id = 1")
|
||||||
|
assert row is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestConnectionPool:
|
||||||
|
"""Connection pool reuses connections and respects pool_size."""
|
||||||
|
|
||||||
|
async def test_connections_are_reused(self, storage: ModuleStorage) -> None:
|
||||||
|
"""After release, the same connection is reused."""
|
||||||
|
await storage.execute("SELECT 1")
|
||||||
|
assert len(storage._all_connections) == 1
|
||||||
|
await storage.execute("SELECT 2")
|
||||||
|
assert len(storage._all_connections) == 1
|
||||||
|
|
||||||
|
async def test_pool_grows_up_to_pool_size(self, tmp_path: Path) -> None:
|
||||||
|
"""Concurrent checkouts grow the pool up to pool_size."""
|
||||||
|
async with (
|
||||||
|
ModuleStorage(tmp_path, "pool_test", pool_size=2) as s,
|
||||||
|
s._checkout(),
|
||||||
|
s._checkout(),
|
||||||
|
):
|
||||||
|
assert len(s._all_connections) == 2
|
||||||
|
|
||||||
|
async def test_pool_exhaustion_blocks(self, tmp_path: Path) -> None:
|
||||||
|
"""When the pool is exhausted, acquire blocks until a connection is released."""
|
||||||
|
async with (
|
||||||
|
ModuleStorage(tmp_path, "pool_block", pool_size=1) as s,
|
||||||
|
s._checkout(),
|
||||||
|
):
|
||||||
|
# Pool is exhausted (pool_size=1, one checked out).
|
||||||
|
# A second acquire should block, so wait_for should time out.
|
||||||
|
with pytest.raises(TimeoutError):
|
||||||
|
await asyncio.wait_for(s._acquire(), timeout=0.1)
|
||||||
|
|
||||||
|
|
||||||
|
class TestStorageError:
|
||||||
|
"""Invalid SQL raises StorageError wrapping the underlying error."""
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"method, args",
|
||||||
|
[
|
||||||
|
pytest.param(
|
||||||
|
"execute",
|
||||||
|
("INVALID SQL",),
|
||||||
|
id="execute",
|
||||||
|
),
|
||||||
|
pytest.param(
|
||||||
|
"fetch_one",
|
||||||
|
("INVALID SQL",),
|
||||||
|
id="fetch-one",
|
||||||
|
),
|
||||||
|
pytest.param(
|
||||||
|
"fetch_all",
|
||||||
|
("INVALID SQL",),
|
||||||
|
id="fetch-all",
|
||||||
|
),
|
||||||
|
pytest.param(
|
||||||
|
"fetch_value",
|
||||||
|
("INVALID SQL",),
|
||||||
|
id="fetch-value",
|
||||||
|
),
|
||||||
|
pytest.param(
|
||||||
|
"execute_many",
|
||||||
|
("INVALID SQL", [()]),
|
||||||
|
id="execute-many",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
async def test_invalid_sql_raises_storage_error(
|
||||||
|
self, storage: ModuleStorage, method: str, args: tuple[Any, ...]
|
||||||
|
) -> None:
|
||||||
|
"""Invalid SQL raises StorageError for all query methods."""
|
||||||
|
with pytest.raises(StorageError):
|
||||||
|
await getattr(storage, method)(*args)
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"method, args",
|
||||||
|
[
|
||||||
|
pytest.param("execute", ("SELECT 1",), id="execute"),
|
||||||
|
pytest.param("fetch_one", ("SELECT 1",), id="fetch-one"),
|
||||||
|
pytest.param("fetch_all", ("SELECT 1",), id="fetch-all"),
|
||||||
|
pytest.param("fetch_value", ("SELECT 1",), id="fetch-value"),
|
||||||
|
pytest.param("execute_many", ("SELECT 1", [()]), id="execute-many"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
async def test_closed_storage_raises_storage_error(
|
||||||
|
self, storage: ModuleStorage, method: str, args: tuple[Any, ...]
|
||||||
|
) -> None:
|
||||||
|
"""Operations on closed storage raise StorageError."""
|
||||||
|
await storage._close()
|
||||||
|
with pytest.raises(StorageError, match="Storage is closed"):
|
||||||
|
await getattr(storage, method)(*args)
|
||||||
|
|
||||||
|
async def test_storage_error_wraps_cause(self, storage: ModuleStorage) -> None:
|
||||||
|
"""StorageError.__cause__ is the underlying aiosqlite error."""
|
||||||
|
with pytest.raises(StorageError) as exc_info:
|
||||||
|
await storage.execute("INVALID SQL")
|
||||||
|
assert exc_info.value.__cause__ is not None
|
||||||
|
|
||||||
|
|
||||||
|
class TestClose:
|
||||||
|
"""_close() drains the pool and marks storage closed."""
|
||||||
|
|
||||||
|
async def test_close_drains_pool(self, storage: ModuleStorage) -> None:
|
||||||
|
"""After close, all connections are removed."""
|
||||||
|
await storage.execute("SELECT 1")
|
||||||
|
assert len(storage._all_connections) == 1
|
||||||
|
await storage._close()
|
||||||
|
assert len(storage._all_connections) == 0
|
||||||
|
assert storage._closed is True
|
||||||
|
|
||||||
|
async def test_double_close_is_safe(self, storage: ModuleStorage) -> None:
|
||||||
|
"""Calling _close() twice does not raise."""
|
||||||
|
await storage.execute("SELECT 1")
|
||||||
|
await storage._close()
|
||||||
|
await storage._close()
|
||||||
|
|
||||||
|
async def test_context_manager_closes_on_exit(self, tmp_path: Path) -> None:
|
||||||
|
"""Exiting the async with block closes all connections."""
|
||||||
|
async with ModuleStorage(tmp_path, "ctx_test") as s:
|
||||||
|
await s.execute("SELECT 1")
|
||||||
|
assert len(s._all_connections) == 1
|
||||||
|
assert len(s._all_connections) == 0
|
||||||
|
assert s._closed is True
|
||||||
Reference in New Issue
Block a user