Simplified MockStorage and consolidated repetitive tests into parametrize blocks.
Format / ruff (push) Successful in 10s
Lint / ruff (push) Successful in 10s
Unit Tests / pytest (push) Successful in 16s
Type Check / mypy (push) Successful in 18s

This commit is contained in:
2026-02-16 14:32:51 -05:00
parent 141d0db1c6
commit e499e4250b
+139 -261
View File
@@ -19,10 +19,9 @@ templates and user scenarios.
""" """
import random import random
import sqlite3
from datetime import UTC, datetime from datetime import UTC, datetime
from typing import ClassVar
import aiosqlite
import pytest import pytest
from freezegun import freeze_time from freezegun import freeze_time
@@ -33,52 +32,27 @@ from owlbot.builtin_modules.custom_commands.placeholders import process_placehol
class MockStorage: class MockStorage:
"""In-memory SQLite storage that executes real SQL queries.""" """In-memory SQLite storage that executes real SQL queries."""
_instances: ClassVar[list[MockStorage]] = []
def __init__(self, counter_values=None): def __init__(self, counter_values=None):
self._seed_data = counter_values or {} self._conn = sqlite3.connect(":memory:")
self._conn = None self._conn.row_factory = sqlite3.Row
MockStorage._instances.append(self) self._conn.execute(
async def _ensure_conn(self):
if self._conn is not None:
return
self._conn = await aiosqlite.connect(":memory:")
self._conn.row_factory = aiosqlite.Row
await self._conn.execute(
"CREATE TABLE counters (" "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 self._seed_data.items(): for name, value in (counter_values or {}).items():
await self._conn.execute( self._conn.execute(
"INSERT INTO counters (name, value) VALUES (?, ?)", "INSERT INTO counters (name, value) VALUES (?, ?)",
(name, value), (name, value),
) )
await self._conn.commit() self._conn.commit()
async def fetch_one(self, query, params=None): async def fetch_one(self, query, params=None):
await self._ensure_conn() cursor = self._conn.execute(query, params or ())
cursor = await self._conn.execute(query, params or ()) row = cursor.fetchone()
row = await cursor.fetchone() self._conn.commit()
await self._conn.commit()
return row return row
async def close(self):
if self._conn is not None:
await self._conn.close()
self._conn = None
@pytest.fixture(autouse=True)
async def _close_mock_storages():
"""Close all MockStorage connections after each test."""
MockStorage._instances.clear()
yield
for storage in MockStorage._instances:
await storage.close()
MockStorage._instances.clear()
async def process( async def process(
template, args=None, user="Alice", use_count=1, storage=None, max_depth=4 template, args=None, user="Alice", use_count=1, storage=None, max_depth=4
@@ -198,20 +172,18 @@ class TestArgPlaceholders:
result = await process("[$(1)] [$(2)] [$(3)]") result = await process("[$(1)] [$(2)] [$(3)]")
assert result == "[] [] []" assert result == "[] [] []"
async def test_zero_is_not_a_valid_arg_index(self): @pytest.mark.parametrize(
"""$(0) is not a valid positional argument and passes through unchanged.""" "template,args,expected",
result = await process("$(0)", args=["first"]) [
assert result == "$(0)" ("$(0)", ["first"], "$(0)"),
("$(10)", ["a"] * 10, "$(10)"),
async def test_out_of_range_numeric_placeholder(self): ("$(99)", [], "$(99)"),
"""$(10) is not registered (only 1-9), so it passes through.""" ],
result = await process("$(10)", args=["a"] * 10) )
assert result == "$(10)" async def test_invalid_arg_index_passthrough(self, template, args, expected):
"""Out-of-range or unregistered numeric indices pass through unchanged."""
async def test_high_numeric_placeholder_passthrough(self): result = await process(template, args=args)
"""$(99) passes through as an unknown placeholder.""" assert result == expected
result = await process("$(99)")
assert result == "$(99)"
async def test_empty_string_argument(self): async def test_empty_string_argument(self):
"""$(1) with an empty string argument returns an empty string.""" """$(1) with an empty string argument returns an empty string."""
@@ -417,17 +389,10 @@ class TestCounterNameValidation:
) )
assert row["value"] == 1 assert row["value"] == 1
async def test_count_hyphen_in_name_rejected(self): @pytest.mark.parametrize("name", ["boss-kills", "boss.kills", "+5", "-3"])
"""Hyphens are not allowed in counter names.""" async def test_count_invalid_name_rejected(self, name):
result = await process("$(count boss-kills)") """Names with hyphens, dots, or leading +/- are rejected."""
assert result == ( result = await process(f"$(count {name})")
"Invalid $(count): counter name may only contain "
"letters, numbers, and underscores"
)
async def test_count_dot_in_name_rejected(self):
"""Dots are not allowed in counter names."""
result = await process("$(count boss.kills)")
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"
@@ -454,22 +419,6 @@ class TestCounterNameValidation:
"letters, numbers, and underscores" "letters, numbers, and underscores"
) )
async def test_count_modifier_syntax_as_name_rejected(self):
"""$(count +5) treats '+5' as the counter name, which fails validation."""
result = await process("$(count +5)")
assert result == (
"Invalid $(count): counter name may only contain "
"letters, numbers, and underscores"
)
async def test_count_negative_modifier_syntax_as_name_rejected(self):
"""$(count -3) treats '-3' as the counter name, which fails validation."""
result = await process("$(count -3)")
assert result == (
"Invalid $(count): counter name may only contain "
"letters, numbers, and underscores"
)
async def test_count_numeric_only_name_accepted(self): async def test_count_numeric_only_name_accepted(self):
"""A counter name consisting of only digits is valid.""" """A counter name consisting of only digits is valid."""
storage = MockStorage() storage = MockStorage()
@@ -575,9 +524,17 @@ class TestCountdownCountup:
result = await process("$(countdown Jan 1 2000 12:00:00 AM UTC)") result = await process("$(countdown Jan 1 2000 12:00:00 AM UTC)")
assert result == "0 seconds" assert result == "0 seconds"
async def test_countdown_invalid_date(self): @pytest.mark.parametrize(
"bad_date",
[
"not a real date",
"monday",
"Dec 25 2099 12:00:00 AM XYZ",
],
)
async def test_countdown_invalid_date(self, bad_date):
"""$(countdown) with an unparseable date reports an error.""" """$(countdown) with an unparseable date reports an error."""
result = await process("$(countdown not a real date)") result = await process(f"$(countdown {bad_date})")
assert result == ( assert result == (
"Invalid $(countdown): unrecognized date format, " "Invalid $(countdown): unrecognized date format, "
"expected $(countdown Dec 25 2025 12:00:00 AM EST)" "expected $(countdown Dec 25 2025 12:00:00 AM EST)"
@@ -591,22 +548,6 @@ class TestCountdownCountup:
"expected $(countup Dec 25 2025 12:00:00 AM EST)" "expected $(countup Dec 25 2025 12:00:00 AM EST)"
) )
async def test_countdown_single_word_date(self):
"""$(countdown) with a single-word date reports an error."""
result = await process("$(countdown monday)")
assert result == (
"Invalid $(countdown): unrecognized date format, "
"expected $(countdown Dec 25 2025 12:00:00 AM EST)"
)
async def test_unrecognized_timezone(self):
"""$(countdown) with an unknown timezone abbreviation reports an error."""
result = await process("$(countdown Dec 25 2099 12:00:00 AM XYZ)")
assert result == (
"Invalid $(countdown): unrecognized date format, "
"expected $(countdown Dec 25 2025 12:00:00 AM EST)"
)
async def test_countup_future_date_returns_zero(self): async def test_countup_future_date_returns_zero(self):
"""$(countup) with a future date returns '0 seconds'.""" """$(countup) with a future date returns '0 seconds'."""
result = await process("$(countup Dec 25 2099 12:00:00 AM UTC)") result = await process("$(countup Dec 25 2099 12:00:00 AM UTC)")
@@ -627,35 +568,28 @@ class TestCountdownCountup:
result = await process("$(countdown Jan 1 2000 12:00:00 AM utc)") result = await process("$(countdown Jan 1 2000 12:00:00 AM utc)")
assert result == "0 seconds" assert result == "0 seconds"
async def test_countdown_exactly_one_day(self): @pytest.mark.parametrize(
"""Exactly 86400 seconds formats as '1 day' in singular form.""" "seconds,expected",
assert _format_duration(86400) == "1 day" [
(0, "0 seconds"),
async def test_duration_singular_forms(self): (1, "1 second"),
"""Each time unit uses singular form when the value is exactly 1.""" (2, "2 seconds"),
assert _format_duration(1) == "1 second" (60, "1 minute"),
assert _format_duration(60) == "1 minute" (120, "2 minutes"),
assert _format_duration(3600) == "1 hour" (3600, "1 hour"),
assert _format_duration(86400) == "1 day" (7200, "2 hours"),
assert _format_duration(90061) == "1 day 1 hour 1 minute 1 second" (86400, "1 day"),
(172800, "2 days"),
async def test_duration_plural_forms(self): (3601, "1 hour 1 second"),
"""Each unit uses plural form when the value is greater than 1.""" (86401, "1 day 1 second"),
assert _format_duration(172800) == "2 days" (86460, "1 day 1 minute"),
assert _format_duration(7200) == "2 hours" (90061, "1 day 1 hour 1 minute 1 second"),
assert _format_duration(120) == "2 minutes" (180183, "2 days 2 hours 3 minutes 3 seconds"),
assert _format_duration(2) == "2 seconds" ],
assert _format_duration(180183) == "2 days 2 hours 3 minutes 3 seconds" )
async def test_format_duration(self, seconds, expected):
async def test_duration_skipped_intermediate_units(self): """_format_duration produces the correct human-readable string."""
"""Units with zero value are omitted from the output.""" assert _format_duration(seconds) == expected
assert _format_duration(86401) == "1 day 1 second"
assert _format_duration(3601) == "1 hour 1 second"
assert _format_duration(86460) == "1 day 1 minute"
async def test_duration_zero(self):
"""Zero seconds produces '0 seconds'."""
assert _format_duration(0) == "0 seconds"
class TestNesting: class TestNesting:
@@ -845,25 +779,19 @@ class TestEscaping:
class TestCaseInsensitivity: class TestCaseInsensitivity:
"""Placeholder names are lowercased before handler lookup.""" """Placeholder names are lowercased before handler lookup."""
async def test_user_uppercase(self): @pytest.mark.parametrize(
"""$(USER) resolves the same as $(user).""" "template,kwargs,expected",
result = await process("$(USER)") [
assert result == "Alice" ("$(USER)", {}, "Alice"),
("$(User)", {}, "Alice"),
async def test_user_mixed_case(self): ("$(Rand 1 1)", {}, "1"),
"""$(User) resolves the same as $(user).""" ("$(COUNT)", {"use_count": 5}, "5"),
result = await process("$(User)") ("$(COUNTDOWN Jan 1 2000 12:00:00 AM UTC)", {}, "0 seconds"),
assert result == "Alice" ],
)
async def test_rand_mixed_case(self): async def test_placeholder_name_case_ignored(self, template, kwargs, expected):
"""$(Rand) resolves the same as $(rand).""" """Placeholder names are case-insensitive."""
result = await process("$(Rand 1 1)") assert await process(template, **kwargs) == expected
assert result == "1"
async def test_count_uppercase(self):
"""$(COUNT) resolves the same as $(count)."""
result = await process("$(COUNT)", use_count=5)
assert result == "5"
async def test_getcount_mixed_case(self): async def test_getcount_mixed_case(self):
"""$(GetCount) resolves the same as $(getcount).""" """$(GetCount) resolves the same as $(getcount)."""
@@ -871,11 +799,6 @@ class TestCaseInsensitivity:
result = await process("$(GetCount x)", storage=storage) result = await process("$(GetCount x)", storage=storage)
assert result == "3" assert result == "3"
async def test_countdown_uppercase(self):
"""$(COUNTDOWN) resolves the same as $(countdown)."""
result = await process("$(COUNTDOWN Jan 1 2000 12:00:00 AM UTC)")
assert result == "0 seconds"
async def test_countup_case_insensitive(self): async def test_countup_case_insensitive(self):
"""$(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):
@@ -1041,36 +964,21 @@ class TestParserBoundaries:
class TestUnknownPlaceholders: class TestUnknownPlaceholders:
"""Unknown placeholder names pass through unchanged.""" """Unknown placeholder names pass through unchanged."""
async def test_unknown_placeholder_passthrough(self): @pytest.mark.parametrize(
"""An unrecognized placeholder with arguments passes through unchanged.""" "template,expected",
result = await process("$(madeup stuff)") [
assert result == "$(madeup stuff)" ("$(madeup stuff)", "$(madeup stuff)"),
("$(banana)", "$(banana)"),
async def test_unknown_no_args_passthrough(self): ("$(madeup lots of space)", "$(madeup lots of space)"),
"""An unrecognized placeholder with no arguments passes through unchanged.""" ("$(madeup)", "$(madeup)"),
result = await process("$(banana)") ("$(café)", "$(café)"),
assert result == "$(banana)" ("$(café latte)", "$(café latte)"),
],
async def test_multiple_spaces_normalized(self): )
"""Extra whitespace inside an unknown placeholder is collapsed.""" async def test_unknown_placeholder_passthrough(self, template, expected):
result = await process("$(madeup lots of space)") """Unrecognized placeholders pass through unchanged."""
assert result == "$(madeup lots of space)" result = await process(template)
assert result == expected
async def test_unknown_no_args_preserved(self):
"""An unknown placeholder with no arguments stays as-is."""
result = await process("$(madeup)")
assert result == "$(madeup)"
async def test_unicode_name_passes_through(self):
"""$(café) is parsed as a valid name but unknown,
and passes through unchanged."""
result = await process("$(café)")
assert result == "$(café)"
async def test_unicode_name_with_args(self):
"""$(café latte) is unknown and passes through with arguments reconstructed."""
result = await process("$(café latte)")
assert result == "$(café latte)"
class TestEvaluationSafety: class TestEvaluationSafety:
@@ -1106,89 +1014,59 @@ class TestEvaluationSafety:
class TestRuntimeErrors: class TestRuntimeErrors:
"""A PlaceholderError cancels the entire render and returns the message.""" """A PlaceholderError cancels the entire render and returns the message."""
async def test_rand_one_arg(self): @pytest.mark.parametrize(
"""$(rand) with only one argument reports too few.""" "template,expected_error",
result = await process("$(rand 1)") [
assert ( (
result == "Invalid $(rand): too few arguments, expected $(rand start stop)" "$(rand 1)",
) "Invalid $(rand): too few arguments, expected $(rand start stop)",
),
async def test_rand_no_args(self): (
"""$(rand) with no arguments reports too few.""" "$(rand)",
result = await process("$(rand)") "Invalid $(rand): too few arguments, expected $(rand start stop)",
assert ( ),
result == "Invalid $(rand): too few arguments, expected $(rand start stop)" (
) "$(rand 1 2 3)",
"Invalid $(rand): too many arguments, expected $(rand start stop)",
async def test_rand_three_args(self): ),
"""$(rand) with three arguments reports too many.""" (
result = await process("$(rand 1 2 3)") "$(rand a b)",
assert ( "Invalid $(rand): arguments must be integers, e.g., $(rand 1 100)",
result == "Invalid $(rand): too many arguments, expected $(rand start stop)" ),
) (
"$(rand 1.5 2.5)",
async def test_rand_non_integer(self): "Invalid $(rand): arguments must be integers, e.g., $(rand 1 100)",
"""$(rand) with non-integer arguments reports a type error.""" ),
result = await process("$(rand a b)") ("$(user extra)", "Invalid $(user): does not accept arguments"),
assert ( ("$(getcount)", "Invalid $(getcount): a counter name is required"),
result == "Invalid $(rand): arguments must be integers, e.g., $(rand 1 100)" (
) "$(getcount deaths extra)",
"Invalid $(getcount): too many arguments, expected $(getcount name)",
async def test_rand_float_args(self): ),
"""$(rand) with float arguments reports a type error.""" (
result = await process("$(rand 1.5 2.5)") "$(count deaths abc)",
assert ( "Invalid $(count): modifier must be an integer (e.g., +5, -1, 0)",
result == "Invalid $(rand): arguments must be integers, e.g., $(rand 1 100)" ),
) (
"$(count deaths +1 extra)",
async def test_user_with_args(self): "Invalid $(count): too many arguments,"
"""$(user) with unexpected arguments reports an error.""" " expected $(count name [modifier])",
result = await process("$(user extra)") ),
assert result == "Invalid $(user): does not accept arguments" (
"$(countdown)",
async def test_getcount_missing_name(self): "Invalid $(countdown): missing date,"
"""$(getcount) with no counter name reports the omission.""" " expected $(countdown Dec 25 2025 12:00:00 AM EST)",
result = await process("$(getcount)") ),
assert result == "Invalid $(getcount): a counter name is required" (
"$(countup)",
async def test_getcount_extra_args(self): "Invalid $(countup): missing date,"
"""$(getcount) with extra arguments reports too many.""" " expected $(countup Dec 25 2025 12:00:00 AM EST)",
result = await process("$(getcount deaths extra)") ),
assert ( ],
result )
== "Invalid $(getcount): too many arguments, expected $(getcount name)" async def test_error_message(self, template, expected_error):
) """Placeholder errors produce the correct diagnostic message."""
assert await process(template) == expected_error
async def test_count_bad_modifier(self):
"""$(count) with a non-integer modifier reports a type error."""
result = await process("$(count deaths abc)")
assert (
result == "Invalid $(count): modifier must be an integer (e.g., +5, -1, 0)"
)
async def test_count_too_many_args(self):
"""$(count) with three arguments reports too many."""
result = await process("$(count deaths +1 extra)")
assert (
result
== "Invalid $(count): too many arguments, expected $(count name [modifier])"
)
async def test_countdown_no_date(self):
"""$(countdown) with no date argument reports the omission."""
result = await process("$(countdown)")
assert (
result == "Invalid $(countdown): missing date, expected"
" $(countdown Dec 25 2025 12:00:00 AM EST)"
)
async def test_countup_no_date(self):
"""$(countup) with no date argument reports the omission."""
result = await process("$(countup)")
assert (
result == "Invalid $(countup): missing date, expected"
" $(countup Dec 25 2025 12:00:00 AM EST)"
)
async def test_error_cancels_entire_render(self): async def test_error_cancels_entire_render(self):
"""An error in any placeholder cancels the whole template.""" """An error in any placeholder cancels the whole template."""