Audit / Dependencies (push) Successful in 8s
CI / Formatting (push) Successful in 5s
CI / Linting (push) Successful in 5s
CI / Tests (Python 3.12) (push) Successful in 19s
CI / Tests (Python 3.13) (push) Successful in 19s
CI / Tests (Python 3.14) (push) Successful in 16s
CI / Type Checking (push) Successful in 11s
CI / Spelling (push) Successful in 9s
- owlbot.testing.plugin: fixtures that mirror ModuleLoader's wiring (module_context, module_lifecycle, command/event/route dispatchers, in-memory storage, real Config on a tmp YAML, HttpClient with aioresponses interception, and an aiohttp route_client). - owlbot.testing.helpers: make_user, make_chat_event, and factories for every Owncast event type, with sensible defaults. - owlbot.testing.stubs: RecordingOwncastClient and RecordingOwncastAdminClient that log each call to a .calls list in place of hitting the API. - pyproject.toml: added a 'testing' extra for downstream module authors (pytest, pytest-asyncio, pytest-aiohttp, aioresponses). - tests: migrated in-tree tests to the new helpers, moved module tests under tests/builtin_modules/, and swapped freezegun for time-machine.
1239 lines
46 KiB
Python
1239 lines
46 KiB
Python
# 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.
|
|
|
|
"""End-to-end tests for custom command placeholder processing.
|
|
|
|
Tests exercise the public API (process_placeholders) with realistic
|
|
templates and user scenarios.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import random
|
|
from datetime import UTC, datetime
|
|
from typing import TYPE_CHECKING, Any
|
|
|
|
import pytest
|
|
import time_machine
|
|
|
|
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.types import Command
|
|
|
|
if TYPE_CHECKING:
|
|
from owlbot.api.storage import ModuleStorage
|
|
|
|
CREATE_COUNTERS = (
|
|
"CREATE TABLE IF NOT EXISTS counters ("
|
|
"name TEXT PRIMARY KEY NOT NULL, "
|
|
"value INTEGER DEFAULT 0)"
|
|
)
|
|
|
|
|
|
class _StorageCounterAccessor:
|
|
"""CounterAccessor backed by raw ModuleStorage for placeholder tests."""
|
|
|
|
def __init__(self, storage: ModuleStorage) -> None:
|
|
self._storage = storage
|
|
|
|
async def get_counter(self, name: str) -> int:
|
|
row = await self._storage.fetch_one(
|
|
"SELECT value FROM counters WHERE name = ?", (name,)
|
|
)
|
|
return int(row["value"]) if row else 0
|
|
|
|
async def set_counter(self, name: str, value: int) -> int:
|
|
result = await self._storage.fetch_value(
|
|
"INSERT OR REPLACE INTO counters (name, value) "
|
|
"VALUES (?, ?) RETURNING value",
|
|
(name, value),
|
|
)
|
|
return int(result) # type: ignore[arg-type]
|
|
|
|
async def adjust_counter(self, name: str, delta: int) -> int:
|
|
result = await self._storage.fetch_value(
|
|
"INSERT INTO counters (name, value) VALUES (?, ?) "
|
|
"ON CONFLICT(name) DO UPDATE SET value = value + ? "
|
|
"RETURNING value",
|
|
(name, delta, delta),
|
|
)
|
|
return int(result) # type: ignore[arg-type]
|
|
|
|
|
|
def _make_command(use_count: int = 1) -> Command:
|
|
"""Build a minimal Command snapshot for placeholder tests."""
|
|
return Command(
|
|
name="test",
|
|
response="",
|
|
use_count=use_count,
|
|
created_at="2026-01-01T00:00:00+00:00",
|
|
updated_at="2026-01-01T00:00:00+00:00",
|
|
requires_moderator=False,
|
|
cooldown=0,
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
async def placeholder_storage(storage: ModuleStorage) -> ModuleStorage:
|
|
"""Storage instance with the counters table created."""
|
|
await storage.execute(CREATE_COUNTERS)
|
|
return storage
|
|
|
|
|
|
async def process(
|
|
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."""
|
|
return await process_placeholders(
|
|
template,
|
|
args or [],
|
|
user,
|
|
_make_command(use_count),
|
|
_StorageCounterAccessor(storage),
|
|
max_depth=max_depth,
|
|
)
|
|
|
|
|
|
class TestSimpleSubstitution:
|
|
"""Basic happy-path usage of placeholders and plain text."""
|
|
|
|
@pytest.mark.parametrize(
|
|
("template", "kwargs", "expected"),
|
|
[
|
|
pytest.param(
|
|
"Just a plain message.", {}, "Just a plain message.", id="plain-text"
|
|
),
|
|
pytest.param("", {}, "", id="empty-string"),
|
|
pytest.param(" ", {}, " ", id="whitespace-only"),
|
|
pytest.param(
|
|
"Hello, $(user)! Welcome to the stream.",
|
|
{},
|
|
"Hello, Alice! Welcome to the stream.",
|
|
id="user-in-sentence",
|
|
),
|
|
pytest.param(
|
|
"The hype train has been called $(count) times! HYPE!",
|
|
{"use_count": 7},
|
|
"The hype train has been called 7 times! HYPE!",
|
|
id="count-in-sentence",
|
|
),
|
|
pytest.param("$(count)", {"use_count": 0}, "0", id="count-zero"),
|
|
pytest.param(
|
|
"Go check out $(1)! They're awesome.",
|
|
{"args": ["Bob"]},
|
|
"Go check out Bob! They're awesome.",
|
|
id="arg-in-sentence",
|
|
),
|
|
pytest.param("$(user)", {}, "Alice", id="user-only"),
|
|
pytest.param(
|
|
"$(1)$(2)$(3)",
|
|
{"args": ["a", "b", "c"]},
|
|
"abc",
|
|
id="three-args-adjacent",
|
|
),
|
|
pytest.param(
|
|
"$(user) likes $(1)",
|
|
{"args": ["cats"]},
|
|
"Alice likes cats",
|
|
id="user-and-arg",
|
|
),
|
|
pytest.param(
|
|
"Hello $(user)!",
|
|
{"user": "O'Brien [MOD]"},
|
|
"Hello O'Brien [MOD]!",
|
|
id="special-chars-in-user",
|
|
),
|
|
pytest.param(
|
|
"$(1)",
|
|
{"args": ["hello world & goodbye"]},
|
|
"hello world & goodbye",
|
|
id="special-chars-in-arg",
|
|
),
|
|
],
|
|
)
|
|
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."""
|
|
assert await process(template, placeholder_storage, **kwargs) == expected
|
|
|
|
async def test_multiple_placeholders_in_one_template(
|
|
self, placeholder_storage: ModuleStorage
|
|
) -> None:
|
|
"""Multiple different placeholder types resolve in a single template."""
|
|
await placeholder_storage.execute(
|
|
"INSERT INTO counters (name, value) VALUES (?, ?)", ("score", 42)
|
|
)
|
|
random.seed(99)
|
|
expected_roll = random.randint(1, 6)
|
|
random.seed(99)
|
|
result = await process(
|
|
"$(user) has $(getcount score) points (roll: $(rand 1 6))",
|
|
placeholder_storage,
|
|
)
|
|
assert result == f"Alice has 42 points (roll: {expected_roll})"
|
|
|
|
|
|
class TestArgPlaceholders:
|
|
"""Positional arguments $(1) through $(9)."""
|
|
|
|
@pytest.mark.parametrize(
|
|
("template", "args", "expected"),
|
|
[
|
|
pytest.param(
|
|
"$(1)$(2)$(3)$(4)$(5)$(6)$(7)$(8)$(9)",
|
|
["a", "b", "c", "d", "e", "f", "g", "h", "i"],
|
|
"abcdefghi",
|
|
id="all-nine-args",
|
|
),
|
|
pytest.param(
|
|
"Hello $(1) and $(2)",
|
|
["Alice"],
|
|
"Hello Alice and ",
|
|
id="missing-arg-empty",
|
|
),
|
|
pytest.param("[$(1)] [$(2)] [$(3)]", [], "[] [] []", id="all-args-empty"),
|
|
pytest.param("$(1)", [""], "", id="empty-string-arg"),
|
|
pytest.param(
|
|
"Hello $(1)world", [""], "Hello world", id="empty-arg-adjacent-text"
|
|
),
|
|
],
|
|
)
|
|
async def test_arg_substitution(
|
|
self,
|
|
placeholder_storage: ModuleStorage,
|
|
template: str,
|
|
args: list[str],
|
|
expected: str,
|
|
) -> None:
|
|
"""Positional arguments resolve correctly."""
|
|
assert await process(template, placeholder_storage, args=args) == expected
|
|
|
|
@pytest.mark.parametrize(
|
|
("template", "args", "expected"),
|
|
[
|
|
pytest.param("$(0)", ["first"], "$(0)", id="index-zero"),
|
|
pytest.param("$(10)", ["a"] * 10, "$(10)", id="index-ten"),
|
|
pytest.param("$(99)", [], "$(99)", id="index-ninety-nine"),
|
|
],
|
|
)
|
|
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."""
|
|
result = await process(template, placeholder_storage, args=args)
|
|
assert result == expected
|
|
|
|
@pytest.mark.parametrize(
|
|
("template", "args", "expected"),
|
|
[
|
|
pytest.param(
|
|
"$(1 extra)",
|
|
["hello"],
|
|
"Invalid $(1): does not accept arguments",
|
|
id="arg1-with-extra",
|
|
),
|
|
pytest.param(
|
|
"$(9 extra)",
|
|
[],
|
|
"Invalid $(9): does not accept arguments",
|
|
id="arg9-with-extra",
|
|
),
|
|
],
|
|
)
|
|
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."""
|
|
assert await process(template, placeholder_storage, args=args) == expected
|
|
|
|
|
|
class TestNamedCounters:
|
|
"""$(count name), $(getcount name), increments, resets, and deltas."""
|
|
|
|
async def test_named_death_counter_increments(
|
|
self, placeholder_storage: ModuleStorage
|
|
) -> None:
|
|
"""$(count name) increments the named counter by 1 each call."""
|
|
result = await process(
|
|
"We have died $(count deaths) times.",
|
|
placeholder_storage,
|
|
)
|
|
assert result == "We have died 1 times."
|
|
result = await process(
|
|
"We have died $(count deaths) times.",
|
|
placeholder_storage,
|
|
)
|
|
assert result == "We have died 2 times."
|
|
|
|
async def test_getcount_without_changing(
|
|
self, placeholder_storage: ModuleStorage
|
|
) -> None:
|
|
"""$(getcount name) reads the counter without modifying it."""
|
|
await placeholder_storage.execute(
|
|
"INSERT INTO counters (name, value) VALUES (?, ?)", ("deaths", 5)
|
|
)
|
|
result = await process(
|
|
"Total deaths so far: $(getcount deaths)",
|
|
placeholder_storage,
|
|
)
|
|
assert result == "Total deaths so far: 5"
|
|
row = await placeholder_storage.fetch_one(
|
|
"SELECT value FROM counters WHERE name = ?", ("deaths",)
|
|
)
|
|
assert row is not None
|
|
assert row["value"] == 5
|
|
|
|
@pytest.mark.parametrize(
|
|
("counter_values", "template", "expected", "db_name", "db_value"),
|
|
[
|
|
pytest.param(
|
|
{"deaths": 10},
|
|
"$(count deaths 0) deaths. Counter reset!",
|
|
"0 deaths. Counter reset!",
|
|
"deaths",
|
|
0,
|
|
id="reset-to-zero",
|
|
),
|
|
pytest.param({}, "$(count score 5)", "5", "score", 5, id="set-absolute"),
|
|
pytest.param({"x": 7}, "$(count x +0)", "7", "x", 7, id="plus-zero"),
|
|
pytest.param({"x": 7}, "$(count x -0)", "7", "x", 7, id="minus-zero"),
|
|
pytest.param(
|
|
{"x": 0}, "$(count x -1)", "-1", "x", -1, id="negative-result"
|
|
),
|
|
],
|
|
)
|
|
async def test_count_with_db_check(
|
|
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."""
|
|
for name, value in counter_values.items():
|
|
await placeholder_storage.execute(
|
|
"INSERT INTO counters (name, value) VALUES (?, ?)", (name, value)
|
|
)
|
|
result = await process(template, placeholder_storage)
|
|
assert result == expected
|
|
row = await placeholder_storage.fetch_one(
|
|
"SELECT value FROM counters WHERE name = ?", (db_name,)
|
|
)
|
|
assert row is not None
|
|
assert row["value"] == db_value
|
|
|
|
@pytest.mark.parametrize(
|
|
("counter_values", "template", "expected"),
|
|
[
|
|
pytest.param({}, "$(count deaths +5)", "5", id="plus-five"),
|
|
pytest.param(
|
|
{"lives": 3},
|
|
"$(count lives -1) lives remaining",
|
|
"2 lives remaining",
|
|
id="decrement",
|
|
),
|
|
pytest.param({}, "$(getcount missing)", "0", id="getcount-missing"),
|
|
pytest.param({}, "$(count boss_2_kills)", "1", id="underscore-in-name"),
|
|
],
|
|
)
|
|
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."""
|
|
for name, value in counter_values.items():
|
|
await placeholder_storage.execute(
|
|
"INSERT INTO counters (name, value) VALUES (?, ?)", (name, value)
|
|
)
|
|
result = await process(template, placeholder_storage)
|
|
assert result == expected
|
|
|
|
async def test_multiple_named_counters(
|
|
self, placeholder_storage: ModuleStorage
|
|
) -> None:
|
|
"""Different named counters are independent of each other."""
|
|
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(
|
|
"Wins: $(getcount wins) | Losses: $(getcount losses)",
|
|
placeholder_storage,
|
|
)
|
|
assert result == "Wins: 3 | Losses: 1"
|
|
|
|
async def test_adjacent_counters_both_increment(
|
|
self, placeholder_storage: ModuleStorage
|
|
) -> None:
|
|
"""Two different counters in one template both increment independently."""
|
|
result = await process(
|
|
"A: $(count a) B: $(count b)",
|
|
placeholder_storage,
|
|
)
|
|
assert result == "A: 1 B: 1"
|
|
row = await placeholder_storage.fetch_one(
|
|
"SELECT value FROM counters WHERE name = ?", ("a",)
|
|
)
|
|
assert row is not None
|
|
assert row["value"] == 1
|
|
row = await placeholder_storage.fetch_one(
|
|
"SELECT value FROM counters WHERE name = ?", ("b",)
|
|
)
|
|
assert row is not None
|
|
assert row["value"] == 1
|
|
|
|
async def test_same_counter_twice_in_one_template(
|
|
self, placeholder_storage: ModuleStorage
|
|
) -> None:
|
|
"""Two references to the same counter both increment."""
|
|
result = await process(
|
|
"$(count x) then $(count x)",
|
|
placeholder_storage,
|
|
)
|
|
assert result == "1 then 2"
|
|
|
|
async def test_count_and_getcount_same_counter(
|
|
self, placeholder_storage: ModuleStorage
|
|
) -> None:
|
|
"""$(count) increments, then $(getcount) reads the new value."""
|
|
result = await process(
|
|
"Now: $(count pts) Total: $(getcount pts)",
|
|
placeholder_storage,
|
|
)
|
|
assert result == "Now: 1 Total: 1"
|
|
|
|
async def test_count_absolute_set_then_read(
|
|
self, placeholder_storage: ModuleStorage
|
|
) -> None:
|
|
"""Absolute set followed by getcount confirms persistence."""
|
|
result = await process(
|
|
"$(count score 42) -> $(getcount score)",
|
|
placeholder_storage,
|
|
)
|
|
assert result == "42 -> 42"
|
|
|
|
@pytest.mark.parametrize(
|
|
"sign",
|
|
[
|
|
pytest.param("+", id="plus"),
|
|
pytest.param("-", id="minus"),
|
|
],
|
|
)
|
|
async def test_bare_sign_rejected(
|
|
self, placeholder_storage: ModuleStorage, sign: str
|
|
) -> None:
|
|
"""$(count x +) and $(count x -) report an error."""
|
|
result = await process(f"$(count x {sign})", placeholder_storage)
|
|
assert (
|
|
result == "Invalid $(count): modifier must be an integer (e.g., +5, -1, 0)"
|
|
)
|
|
|
|
|
|
class TestCounterNameValidation:
|
|
"""Counter names are lowercased and must match ^[a-z0-9_]+$."""
|
|
|
|
@pytest.mark.parametrize(
|
|
("template", "expected", "db_name", "db_value"),
|
|
[
|
|
pytest.param("$(count Deaths)", "1", "deaths", 1, id="uppercase-name"),
|
|
pytest.param("$(count 123)", "1", "123", 1, id="numeric-name"),
|
|
],
|
|
)
|
|
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."""
|
|
result = await process(template, placeholder_storage)
|
|
assert result == expected
|
|
row = await placeholder_storage.fetch_one(
|
|
"SELECT value FROM counters WHERE name = ?", (db_name,)
|
|
)
|
|
assert row is not None
|
|
assert row["value"] == db_value
|
|
|
|
@pytest.mark.parametrize(
|
|
"name",
|
|
[
|
|
pytest.param("boss-kills", id="hyphen"),
|
|
pytest.param("boss.kills", id="dot"),
|
|
pytest.param("+5", id="leading-plus"),
|
|
pytest.param("-3", id="leading-minus"),
|
|
],
|
|
)
|
|
async def test_count_invalid_name_rejected(
|
|
self, placeholder_storage: ModuleStorage, name: str
|
|
) -> None:
|
|
"""Names with hyphens, dots, or leading +/- are rejected."""
|
|
result = await process(f"$(count {name})", placeholder_storage)
|
|
assert result == (
|
|
"Invalid $(count): counter name may only contain "
|
|
"letters, numbers, and underscores"
|
|
)
|
|
|
|
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."""
|
|
result = await process("$(count my counter)", placeholder_storage)
|
|
assert (
|
|
result == "Invalid $(count): modifier must be an integer (e.g., +5, -1, 0)"
|
|
)
|
|
|
|
async def test_getcount_uppercase_name_lowered(
|
|
self, placeholder_storage: ModuleStorage
|
|
) -> None:
|
|
"""Uppercase getcount names are normalized to lowercase."""
|
|
await placeholder_storage.execute(
|
|
"INSERT INTO counters (name, value) VALUES (?, ?)", ("deaths", 5)
|
|
)
|
|
result = await process("$(getcount Deaths)", placeholder_storage)
|
|
assert result == "5"
|
|
|
|
async def test_getcount_hyphen_in_name_rejected(
|
|
self, placeholder_storage: ModuleStorage
|
|
) -> None:
|
|
"""Hyphens are not allowed in getcount names."""
|
|
result = await process("$(getcount boss-kills)", placeholder_storage)
|
|
assert result == (
|
|
"Invalid $(getcount): counter name may only contain "
|
|
"letters, numbers, and underscores"
|
|
)
|
|
|
|
|
|
class TestRand:
|
|
"""All $(rand) behavior: basic usage, negative ranges, and boundaries."""
|
|
|
|
async def test_dice_roll(self, placeholder_storage: ModuleStorage) -> None:
|
|
"""$(rand 1 6) produces a random integer in range."""
|
|
random.seed(0)
|
|
expected_roll = random.randint(1, 6)
|
|
random.seed(0)
|
|
result = await process("$(user) rolled a $(rand 1 6)!", placeholder_storage)
|
|
assert result == f"Alice rolled a {expected_roll}!"
|
|
|
|
@pytest.mark.parametrize(
|
|
("template", "expected"),
|
|
[
|
|
pytest.param("$(rand 5 5)", "5", id="equal-bounds"),
|
|
pytest.param("$(rand 0 0)", "0", id="zero-bounds"),
|
|
pytest.param("$(rand 1 1)", "1", id="extra-whitespace"),
|
|
],
|
|
)
|
|
async def test_rand_deterministic(
|
|
self, placeholder_storage: ModuleStorage, template: str, expected: str
|
|
) -> None:
|
|
"""$(rand) with fixed bounds produces a deterministic result."""
|
|
assert await process(template, placeholder_storage) == expected
|
|
|
|
async def test_rand_reversed_range(
|
|
self, placeholder_storage: ModuleStorage
|
|
) -> None:
|
|
"""$(rand 10 1) auto-sorts bounds and produces a value in range."""
|
|
random.seed(0)
|
|
expected = random.randint(1, 10)
|
|
random.seed(0)
|
|
result = await process("$(rand 10 1)", placeholder_storage)
|
|
assert result == str(expected)
|
|
|
|
async def test_both_negative(self, placeholder_storage: ModuleStorage) -> None:
|
|
"""$(rand) works with both bounds negative."""
|
|
random.seed(0)
|
|
expected = random.randint(-10, -1)
|
|
random.seed(0)
|
|
result = await process("$(rand -10 -1)", placeholder_storage)
|
|
assert result == str(expected)
|
|
|
|
async def test_negative_to_positive(
|
|
self, placeholder_storage: ModuleStorage
|
|
) -> None:
|
|
"""$(rand) works with a range crossing zero."""
|
|
random.seed(0)
|
|
expected = random.randint(-5, 5)
|
|
random.seed(0)
|
|
result = await process("$(rand -5 5)", placeholder_storage)
|
|
assert result == str(expected)
|
|
|
|
async def test_zero_crossing_reversed(
|
|
self, placeholder_storage: ModuleStorage
|
|
) -> None:
|
|
"""$(rand 5 -5) auto-sorts bounds to produce a valid range."""
|
|
random.seed(0)
|
|
expected = random.randint(-5, 5)
|
|
random.seed(0)
|
|
result = await process("$(rand 5 -5)", placeholder_storage)
|
|
assert result == str(expected)
|
|
|
|
|
|
class TestCountdownCountup:
|
|
"""Date/time placeholders and duration formatting."""
|
|
|
|
async def test_countdown_future_date(
|
|
self, placeholder_storage: ModuleStorage
|
|
) -> None:
|
|
"""$(countdown) with a future date returns the formatted duration."""
|
|
with time_machine.travel("2026-06-15 12:00:00+00:00", tick=False):
|
|
now = datetime.now(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)
|
|
expected = _format_duration(int((target - now).total_seconds()))
|
|
result = await process(
|
|
"$(countdown Dec 25 2099 12:00:00 AM EST)", placeholder_storage
|
|
)
|
|
assert result == expected
|
|
|
|
async def test_countup_past_date(self, placeholder_storage: ModuleStorage) -> None:
|
|
"""$(countup) with a past date returns the formatted elapsed duration."""
|
|
with time_machine.travel("2026-06-15 12:00:00+00:00", tick=False):
|
|
now = datetime.now(UTC)
|
|
target = datetime(2020, 1, 1, 0, 0, 0, tzinfo=UTC)
|
|
expected = _format_duration(int((now - target).total_seconds()))
|
|
result = await process(
|
|
"$(countup Jan 1 2020 12:00:00 AM UTC)", placeholder_storage
|
|
)
|
|
assert result == expected
|
|
|
|
@pytest.mark.parametrize(
|
|
"template",
|
|
[
|
|
pytest.param(
|
|
"$(countdown Jan 1 2000 12:00:00 AM UTC)", id="countdown-past"
|
|
),
|
|
pytest.param("$(countup Dec 25 2099 12:00:00 AM UTC)", id="countup-future"),
|
|
pytest.param("$(countdown Jan 1 2000 12:00:00 AM utc)", id="lowercase-tz"),
|
|
],
|
|
)
|
|
async def test_zero_seconds(
|
|
self, placeholder_storage: ModuleStorage, template: str
|
|
) -> None:
|
|
"""Elapsed/remaining time of zero returns '0 seconds'."""
|
|
assert await process(template, placeholder_storage) == "0 seconds"
|
|
|
|
@pytest.mark.parametrize(
|
|
("placeholder", "bad_date"),
|
|
[
|
|
pytest.param("countdown", "not a real date", id="gibberish"),
|
|
pytest.param("countdown", "monday", id="weekday-only"),
|
|
pytest.param("countdown", "Dec 25 2099 12:00:00 AM XYZ", id="unknown-tz"),
|
|
pytest.param("countup", "not a real date", id="countup-gibberish"),
|
|
pytest.param("countdown", "not a valid date UTC", id="gibberish-with-tz"),
|
|
],
|
|
)
|
|
async def test_invalid_date(
|
|
self, placeholder_storage: ModuleStorage, placeholder: str, bad_date: str
|
|
) -> None:
|
|
"""Placeholders with an unparsable date report an error."""
|
|
result = await process(f"$({placeholder} {bad_date})", placeholder_storage)
|
|
assert result == (
|
|
f"Invalid $({placeholder}): unrecognized date format, "
|
|
f"expected $({placeholder} Dec 25 2025 12:00:00 AM EST)"
|
|
)
|
|
|
|
async def test_fractional_timezone_offset(
|
|
self, placeholder_storage: ModuleStorage
|
|
) -> None:
|
|
"""Fractional offset like IST (+5:30) is applied.""" # codespell:ignore ist
|
|
with time_machine.travel("2026-06-15 12:00:00+00:00", tick=False):
|
|
now = datetime.now(UTC)
|
|
# IST = Dec 31 2019 18:30:00 UTC # codespell:ignore ist
|
|
target = datetime(2019, 12, 31, 18, 30, 0, tzinfo=UTC)
|
|
expected = _format_duration(int((now - target).total_seconds()))
|
|
result = await process(
|
|
"$(countup Jan 1 2020 12:00:00 AM IST)", # codespell:ignore ist
|
|
placeholder_storage,
|
|
)
|
|
assert result == expected
|
|
|
|
@pytest.mark.parametrize(
|
|
("seconds", "expected"),
|
|
[
|
|
pytest.param(0, "0 seconds", id="zero"),
|
|
pytest.param(1, "1 second", id="one-second"),
|
|
pytest.param(2, "2 seconds", id="two-seconds"),
|
|
pytest.param(60, "1 minute", id="one-minute"),
|
|
pytest.param(120, "2 minutes", id="two-minutes"),
|
|
pytest.param(3600, "1 hour", id="one-hour"),
|
|
pytest.param(7200, "2 hours", id="two-hours"),
|
|
pytest.param(86400, "1 day", id="one-day"),
|
|
pytest.param(172800, "2 days", id="two-days"),
|
|
pytest.param(3601, "1 hour 1 second", id="hour-and-second"),
|
|
pytest.param(86401, "1 day 1 second", id="day-and-second"),
|
|
pytest.param(86460, "1 day 1 minute", id="day-and-minute"),
|
|
pytest.param(
|
|
90061, "1 day 1 hour 1 minute 1 second", id="all-units-singular"
|
|
),
|
|
pytest.param(
|
|
180183, "2 days 2 hours 3 minutes 3 seconds", id="all-units-plural"
|
|
),
|
|
],
|
|
)
|
|
async def test_format_duration(self, seconds: int, expected: str) -> None:
|
|
"""_format_duration produces the correct human-readable string."""
|
|
assert _format_duration(seconds) == expected
|
|
|
|
|
|
class TestNesting:
|
|
"""Nested placeholder resolution and child whitespace behavior."""
|
|
|
|
async def test_rand_with_arg_placeholders(
|
|
self, placeholder_storage: ModuleStorage
|
|
) -> None:
|
|
"""$(rand $(1) $(2)) resolves inner arguments before calling rand."""
|
|
random.seed(0)
|
|
expected = random.randint(5, 10)
|
|
random.seed(0)
|
|
result = await process(
|
|
"$(rand $(1) $(2))", placeholder_storage, args=["5", "10"]
|
|
)
|
|
assert result == str(expected)
|
|
|
|
async def test_multi_level_nesting(
|
|
self, placeholder_storage: ModuleStorage
|
|
) -> None:
|
|
"""$(rand 1 $(rand 5 10)) resolves the inner rand first."""
|
|
random.seed(0)
|
|
inner = random.randint(5, 10)
|
|
outer = random.randint(1, inner)
|
|
random.seed(0)
|
|
result = await process("$(rand 1 $(rand 5 10))", placeholder_storage)
|
|
assert result == str(outer)
|
|
|
|
async def test_nested_in_sentence(self, placeholder_storage: ModuleStorage) -> None:
|
|
"""Nested placeholders work when surrounded by literal text."""
|
|
random.seed(0)
|
|
expected_roll = random.randint(1, 20)
|
|
random.seed(0)
|
|
result = await process(
|
|
"$(user) rolled $(rand $(1) $(2))!",
|
|
placeholder_storage,
|
|
args=["1", "20"],
|
|
)
|
|
assert result == f"Alice rolled {expected_roll}!"
|
|
|
|
async def test_nested_getcount_as_rand_bound(
|
|
self, placeholder_storage: ModuleStorage
|
|
) -> None:
|
|
"""$(getcount) result feeds into $(rand) as a bound."""
|
|
await placeholder_storage.execute(
|
|
"INSERT INTO counters (name, value) VALUES (?, ?)", ("max", 50)
|
|
)
|
|
random.seed(0)
|
|
expected = random.randint(1, 50)
|
|
random.seed(0)
|
|
result = await process("$(rand 1 $(getcount max))", placeholder_storage)
|
|
assert result == str(expected)
|
|
|
|
async def test_nested_count_as_rand_bound(
|
|
self, placeholder_storage: ModuleStorage
|
|
) -> None:
|
|
"""A counter result feeds into rand as an argument."""
|
|
await placeholder_storage.execute(
|
|
"INSERT INTO counters (name, value) VALUES (?, ?)", ("upper", 9)
|
|
)
|
|
# $(count upper) increments 9 -> 10.
|
|
random.seed(0)
|
|
expected = random.randint(1, 10)
|
|
random.seed(0)
|
|
result = await process(
|
|
"$(rand 1 $(count upper))",
|
|
placeholder_storage,
|
|
)
|
|
assert result == str(expected)
|
|
|
|
async def test_three_levels_deep(self, placeholder_storage: ModuleStorage) -> None:
|
|
"""$(rand 1 $(rand 1 $(rand 5 10))) resolves inside-out."""
|
|
random.seed(0)
|
|
innermost = random.randint(5, 10)
|
|
middle = random.randint(1, innermost)
|
|
outer = random.randint(1, middle)
|
|
random.seed(0)
|
|
result = await process("$(rand 1 $(rand 1 $(rand 5 10)))", placeholder_storage)
|
|
assert result == str(outer)
|
|
|
|
async def test_multiple_nested_placeholders_in_one_template(
|
|
self, placeholder_storage: ModuleStorage
|
|
) -> None:
|
|
"""Two separate nested expressions in one template both resolve."""
|
|
random.seed(0)
|
|
low = random.randint(1, 5)
|
|
high = random.randint(50, 100)
|
|
random.seed(0)
|
|
result = await process(
|
|
"Low: $(rand $(1) $(2)) High: $(rand $(3) $(4))",
|
|
placeholder_storage,
|
|
args=["1", "5", "50", "100"],
|
|
)
|
|
assert result == f"Low: {low} High: {high}"
|
|
|
|
async def test_nested_user_in_other_placeholder(
|
|
self, placeholder_storage: ModuleStorage
|
|
) -> None:
|
|
"""$(user) nested inside an unknown placeholder passes through."""
|
|
result = await process("$(greet $(user))", placeholder_storage)
|
|
assert result == "$(greet Alice)"
|
|
|
|
async def test_meta_nesting_degrades(
|
|
self, placeholder_storage: ModuleStorage
|
|
) -> None:
|
|
"""$($(1)) is not valid and degrades gracefully."""
|
|
result = await process("$($(1))", placeholder_storage, args=["test"])
|
|
assert result == "$(test)"
|
|
|
|
@pytest.mark.parametrize(
|
|
("template", "args"),
|
|
[
|
|
pytest.param("$(rand $(1) $(2))", [], id="no-args"),
|
|
pytest.param("$(rand $(1) $(2))", ["5"], id="one-arg"),
|
|
pytest.param("$(rand $(1)$(2))", ["5", "10"], id="no-space-between"),
|
|
],
|
|
)
|
|
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."""
|
|
result = await process(template, placeholder_storage, args=args)
|
|
assert (
|
|
result == "Invalid $(rand): too few arguments, expected $(rand start stop)"
|
|
)
|
|
|
|
async def test_adjacent_children_with_explicit_space(
|
|
self, placeholder_storage: ModuleStorage
|
|
) -> None:
|
|
"""$(rand $(1) $(2)) with space between children works normally."""
|
|
result = await process(
|
|
"$(rand $(1) $(2))", placeholder_storage, args=["3", "3"]
|
|
)
|
|
assert result == "3"
|
|
|
|
|
|
class TestEscaping:
|
|
"""Escape sequences and backslash boundary conditions."""
|
|
|
|
@pytest.mark.parametrize(
|
|
("template", "kwargs", "expected"),
|
|
[
|
|
pytest.param(
|
|
r"Use \$(user) to insert your name.",
|
|
{},
|
|
"Use $(user) to insert your name.",
|
|
id="escaped-user",
|
|
),
|
|
pytest.param(
|
|
r"\$(user) and \$(count)", {}, "$(user) and $(count)", id="two-escaped"
|
|
),
|
|
pytest.param(
|
|
r"\$(user) said hello to $(user)",
|
|
{},
|
|
"$(user) said hello to Alice",
|
|
id="escaped-and-live",
|
|
),
|
|
pytest.param(r"path\to\file", {}, "path\\to\\file", id="backslash-in-text"),
|
|
pytest.param("\\\\$(user)", {}, "\\$(user)", id="double-backslash"),
|
|
pytest.param(
|
|
"before \\$(unclosed", {}, "before \\$(unclosed", id="escaped-unclosed"
|
|
),
|
|
pytest.param(
|
|
"\\$(rand $(1) $(2))",
|
|
{"args": ["5", "10"]},
|
|
"$(rand $(1) $(2))",
|
|
id="escaped-nested",
|
|
),
|
|
pytest.param(r"\$()", {}, "$()", id="escaped-empty-parens"),
|
|
pytest.param("text \\$(", {}, "text \\$(", id="trailing-escaped-dollar"),
|
|
pytest.param(r"\$(rand 1)", {}, "$(rand 1)", id="escaped-with-body"),
|
|
pytest.param("hello\\", {}, "hello\\", id="trailing-backslash"),
|
|
pytest.param("\\\\\\$(user)", {}, "\\\\$(user)", id="triple-backslash"),
|
|
pytest.param("\\$5", {}, "\\$5", id="dollar-number"),
|
|
],
|
|
)
|
|
async def test_escaping(
|
|
self,
|
|
placeholder_storage: ModuleStorage,
|
|
template: str,
|
|
kwargs: dict[str, Any],
|
|
expected: str,
|
|
) -> None:
|
|
"""Escape sequences produce the expected literal text."""
|
|
assert await process(template, placeholder_storage, **kwargs) == expected
|
|
|
|
|
|
class TestCaseInsensitivity:
|
|
"""Placeholder names are lowercased before handler lookup."""
|
|
|
|
@pytest.mark.parametrize(
|
|
("template", "kwargs", "expected"),
|
|
[
|
|
pytest.param("$(USER)", {}, "Alice", id="all-upper-user"),
|
|
pytest.param("$(User)", {}, "Alice", id="title-case-user"),
|
|
pytest.param("$(Rand 1 1)", {}, "1", id="title-case-rand"),
|
|
pytest.param("$(COUNT)", {"use_count": 5}, "5", id="all-upper-count"),
|
|
pytest.param(
|
|
"$(COUNTDOWN Jan 1 2000 12:00:00 AM UTC)",
|
|
{},
|
|
"0 seconds",
|
|
id="all-upper-countdown",
|
|
),
|
|
],
|
|
)
|
|
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."""
|
|
assert await process(template, placeholder_storage, **kwargs) == expected
|
|
|
|
async def test_getcount_mixed_case(
|
|
self, placeholder_storage: ModuleStorage
|
|
) -> None:
|
|
"""$(GetCount) resolves the same as $(getcount)."""
|
|
await placeholder_storage.execute(
|
|
"INSERT INTO counters (name, value) VALUES (?, ?)", ("x", 3)
|
|
)
|
|
result = await process("$(GetCount x)", placeholder_storage)
|
|
assert result == "3"
|
|
|
|
async def test_countup_case_insensitive(
|
|
self, placeholder_storage: ModuleStorage
|
|
) -> None:
|
|
"""$(COUNTUP) resolves the same as $(countup)."""
|
|
with time_machine.travel("2026-06-15 12:00:00+00:00", tick=False):
|
|
now = datetime.now(UTC)
|
|
target = datetime(2020, 1, 1, 0, 0, 0, tzinfo=UTC)
|
|
expected = _format_duration(int((now - target).total_seconds()))
|
|
result = await process(
|
|
"$(COUNTUP Jan 1 2020 12:00:00 AM UTC)", placeholder_storage
|
|
)
|
|
assert result == expected
|
|
|
|
|
|
class TestMaxDepth:
|
|
"""Depth limiting behavior for nested placeholders."""
|
|
|
|
async def test_max_depth_limits_nesting(
|
|
self, placeholder_storage: ModuleStorage
|
|
) -> None:
|
|
"""With max_depth=0, all $( are treated as literal text."""
|
|
result = await process("$(user) $(rand 1 6)", placeholder_storage, max_depth=0)
|
|
assert result == "$(user) $(rand 1 6)"
|
|
|
|
async def test_max_depth_one_blocks_inner(
|
|
self, placeholder_storage: ModuleStorage
|
|
) -> None:
|
|
"""With max_depth=1, outer placeholder works but inner $( is literal."""
|
|
result = await process("$(user)", placeholder_storage, max_depth=1)
|
|
assert result == "Alice"
|
|
# Inner nesting should not resolve.
|
|
result = await process(
|
|
"$(rand $(1) $(2))",
|
|
placeholder_storage,
|
|
args=["5", "10"],
|
|
max_depth=1,
|
|
)
|
|
# rand gets broken args due to literal $( and early ) close.
|
|
assert (
|
|
result == "Invalid $(rand): too few arguments, expected $(rand start stop)"
|
|
)
|
|
|
|
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."""
|
|
result = await process(
|
|
"$(rand $(1) $(2))",
|
|
placeholder_storage,
|
|
args=["3", "3"],
|
|
max_depth=2,
|
|
)
|
|
assert result == "3"
|
|
|
|
async def test_nesting_at_default_depth_limit(
|
|
self, placeholder_storage: ModuleStorage
|
|
) -> None:
|
|
"""Four levels of nesting hits the default max_depth=4 boundary.
|
|
|
|
Levels: $(rand 1 $(rand 1 $(rand 1 $(rand 5 5))))
|
|
Depths: 0 1 2 3
|
|
All four should resolve since max_depth=4 allows depths 0-3.
|
|
"""
|
|
random.seed(0)
|
|
d3 = random.randint(5, 5) # innermost: rand 5 5
|
|
d2 = random.randint(1, d3)
|
|
d1 = random.randint(1, d2)
|
|
d0 = random.randint(1, d1)
|
|
random.seed(0)
|
|
result = await process(
|
|
"$(rand 1 $(rand 1 $(rand 1 $(rand 5 5))))", placeholder_storage
|
|
)
|
|
assert result == str(d0)
|
|
|
|
async def test_nesting_beyond_default_depth_limit(
|
|
self, placeholder_storage: ModuleStorage
|
|
) -> None:
|
|
"""Five levels of nesting exceeds default max_depth=4.
|
|
|
|
The innermost $( at depth 4 becomes literal text, breaking the
|
|
outer placeholders.
|
|
"""
|
|
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.
|
|
assert (
|
|
result == "Invalid $(rand): too many arguments, expected $(rand start stop)"
|
|
)
|
|
|
|
|
|
class TestParserBoundaries:
|
|
"""Parser edge cases around incomplete or unusual $( sequences."""
|
|
|
|
@pytest.mark.parametrize(
|
|
("template", "expected"),
|
|
[
|
|
pytest.param("$(rand 1", "$(rand 1", id="unclosed-rand"),
|
|
pytest.param(
|
|
"before $(rand 1 after", "before $(rand 1 after", id="unclosed-in-text"
|
|
),
|
|
pytest.param("$()", "$()", id="empty-parens"),
|
|
pytest.param("$", "$", id="lone-dollar"),
|
|
pytest.param("Price: $5", "Price: $5", id="dollar-amount"),
|
|
pytest.param("$x $y $z", "$x $y $z", id="dollar-variables"),
|
|
pytest.param("Hello :) world", "Hello :) world", id="smiley-face"),
|
|
pytest.param(":) :) :)", ":) :) :)", id="multiple-smileys"),
|
|
pytest.param("hello $(", "hello $(", id="trailing-dollar-paren"),
|
|
pytest.param("hello $(user", "hello $(user", id="unclosed-user"),
|
|
pytest.param("$( )", "$( )", id="space-only-parens"),
|
|
pytest.param("$(rand$(user))", "$(randAlice)", id="no-space-after-name"),
|
|
pytest.param("$(rand$5)", "$(rand$5)", id="dollar-in-name"),
|
|
pytest.param("$(rand$", "$(rand$", id="trailing-dollar-in-name"),
|
|
],
|
|
)
|
|
async def test_parser_boundary(
|
|
self, placeholder_storage: ModuleStorage, template: str, expected: str
|
|
) -> None:
|
|
"""Parser edge cases degrade to literal text."""
|
|
assert await process(template, placeholder_storage) == expected
|
|
|
|
|
|
class TestUnknownPlaceholders:
|
|
"""Unknown placeholder names pass through unchanged."""
|
|
|
|
@pytest.mark.parametrize(
|
|
("template", "expected"),
|
|
[
|
|
pytest.param("$(madeup stuff)", "$(madeup stuff)", id="with-args"),
|
|
pytest.param("$(banana)", "$(banana)", id="bare-name"),
|
|
pytest.param(
|
|
"$(madeup lots of space)",
|
|
"$(madeup lots of space)",
|
|
id="extra-spaces",
|
|
),
|
|
pytest.param("$(madeup)", "$(madeup)", id="no-args"),
|
|
pytest.param("$(café)", "$(café)", id="unicode-name"),
|
|
pytest.param("$(café latte)", "$(café latte)", id="unicode-with-args"),
|
|
],
|
|
)
|
|
async def test_unknown_placeholder_passthrough(
|
|
self, placeholder_storage: ModuleStorage, template: str, expected: str
|
|
) -> None:
|
|
"""Unrecognized placeholders pass through unchanged."""
|
|
result = await process(template, placeholder_storage)
|
|
assert result == expected
|
|
|
|
|
|
class TestEvaluationSafety:
|
|
"""Verify that resolved text is never re-parsed as placeholders."""
|
|
|
|
@pytest.mark.parametrize(
|
|
("template", "kwargs", "expected"),
|
|
[
|
|
pytest.param(
|
|
"$(1)", {"args": ["$(user)"]}, "$(user)", id="arg-contains-user"
|
|
),
|
|
pytest.param(
|
|
"$(1)",
|
|
{"args": ["$(rand 1 100)"]},
|
|
"$(rand 1 100)",
|
|
id="arg-contains-rand",
|
|
),
|
|
pytest.param(
|
|
"$(user)",
|
|
{"user": "$(rand 1 100)"},
|
|
"$(rand 1 100)",
|
|
id="user-contains-rand",
|
|
),
|
|
],
|
|
)
|
|
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."""
|
|
assert await process(template, placeholder_storage, **kwargs) == expected
|
|
|
|
async def test_nested_arg_placeholder_syntax_not_reprocessed(
|
|
self, placeholder_storage: ModuleStorage
|
|
) -> None:
|
|
"""An inner result containing $() syntax is not reprocessed."""
|
|
result = await process(
|
|
"$(rand $(1) $(2))", placeholder_storage, args=["5", "5"]
|
|
)
|
|
assert result == "5"
|
|
# Now with args that contain placeholder syntax.
|
|
result = await process(
|
|
"Say: $(1)",
|
|
placeholder_storage,
|
|
args=["$(count) times"],
|
|
)
|
|
assert result == "Say: $(count) times"
|
|
|
|
|
|
class TestRuntimeErrors:
|
|
"""A PlaceholderError cancels the entire render and returns the message."""
|
|
|
|
@pytest.mark.parametrize(
|
|
("template", "expected_error"),
|
|
[
|
|
pytest.param(
|
|
"$(rand 1)",
|
|
"Invalid $(rand): too few arguments, expected $(rand start stop)",
|
|
id="rand-too-few",
|
|
),
|
|
pytest.param(
|
|
"$(rand)",
|
|
"Invalid $(rand): too few arguments, expected $(rand start stop)",
|
|
id="rand-no-args",
|
|
),
|
|
pytest.param(
|
|
"$(rand 1 2 3)",
|
|
"Invalid $(rand): too many arguments, expected $(rand start stop)",
|
|
id="rand-too-many",
|
|
),
|
|
pytest.param(
|
|
"$(rand a b)",
|
|
"Invalid $(rand): arguments must be integers, e.g., $(rand 1 100)",
|
|
id="rand-non-integer",
|
|
),
|
|
pytest.param(
|
|
"$(rand 1.5 2.5)",
|
|
"Invalid $(rand): arguments must be integers, e.g., $(rand 1 100)",
|
|
id="rand-float-args",
|
|
),
|
|
pytest.param(
|
|
"$(user extra)",
|
|
"Invalid $(user): does not accept arguments",
|
|
id="user-extra-args",
|
|
),
|
|
pytest.param(
|
|
"$(getcount)",
|
|
"Invalid $(getcount): a counter name is required",
|
|
id="getcount-no-name",
|
|
),
|
|
pytest.param(
|
|
"$(getcount deaths extra)",
|
|
"Invalid $(getcount): too many arguments, expected $(getcount name)",
|
|
id="getcount-too-many",
|
|
),
|
|
pytest.param(
|
|
"$(count deaths abc)",
|
|
"Invalid $(count): modifier must be an integer (e.g., +5, -1, 0)",
|
|
id="count-bad-modifier",
|
|
),
|
|
pytest.param(
|
|
"$(count deaths +1 extra)",
|
|
"Invalid $(count): too many arguments,"
|
|
" expected $(count name [modifier])",
|
|
id="count-too-many",
|
|
),
|
|
pytest.param(
|
|
"$(countdown)",
|
|
"Invalid $(countdown): missing date,"
|
|
" expected $(countdown Dec 25 2025 12:00:00 AM EST)",
|
|
id="countdown-no-date",
|
|
),
|
|
pytest.param(
|
|
"$(countup)",
|
|
"Invalid $(countup): missing date,"
|
|
" expected $(countup Dec 25 2025 12:00:00 AM EST)",
|
|
id="countup-no-date",
|
|
),
|
|
],
|
|
)
|
|
async def test_error_message(
|
|
self, placeholder_storage: ModuleStorage, template: str, expected_error: str
|
|
) -> None:
|
|
"""Placeholder errors produce the correct diagnostic message."""
|
|
assert await process(template, placeholder_storage) == expected_error
|
|
|
|
async def test_error_cancels_entire_render(
|
|
self, placeholder_storage: ModuleStorage
|
|
) -> None:
|
|
"""An error in any placeholder cancels the whole template."""
|
|
result = await process("$(user) says $(rand 1)", placeholder_storage)
|
|
assert (
|
|
result == "Invalid $(rand): too few arguments, expected $(rand start stop)"
|
|
)
|
|
|
|
async def test_first_error_wins(self, placeholder_storage: ModuleStorage) -> None:
|
|
"""The first PlaceholderError encountered is returned."""
|
|
result = await process(
|
|
"$(rand 1) $(getcount) $(user extra)", placeholder_storage
|
|
)
|
|
# Evaluation is left-to-right; $(rand 1) fails first.
|
|
assert (
|
|
result == "Invalid $(rand): too few arguments, expected $(rand start stop)"
|
|
)
|
|
|
|
async def test_nested_inner_error_cancels_render(
|
|
self, placeholder_storage: ModuleStorage
|
|
) -> None:
|
|
"""An error in a nested placeholder cancels the whole render."""
|
|
result = await process("$(rand $(getcount) $(2))", placeholder_storage)
|
|
assert result == "Invalid $(getcount): a counter name is required"
|