oracle: $runy / $runa_dnia - Elder Futhark rune readings in-character
CI / compile (pull_request) Successful in 10s
CI / unit (pull_request) Successful in 14s
CI / integration (pull_request) Successful in 10s
build / build (push) Successful in 38s
CI / compile (push) Successful in 10s
CI / unit (push) Successful in 17s
CI / integration (push) Successful in 11s
CI / compile (pull_request) Successful in 10s
CI / unit (pull_request) Successful in 14s
CI / integration (pull_request) Successful in 10s
build / build (push) Successful in 38s
CI / compile (push) Successful in 10s
CI / unit (push) Successful in 17s
CI / integration (push) Successful in 11s
Fits the mythology pillar of the persona (Slavic/Norse/Celtic, Old Norse phrases). New always-loaded cog oracle_commands: * $runy [pytanie] draws three Elder Futhark runes (past/present/future, with upright/reversed orientation - the 8 symmetric runes are never reversed) and asks the ACTIVE AI backend to read the spread in Conjurer's voice. If the AI is down it still shows the drawn runes with their own meanings, so the command always answers. * $runa_dnia gives one rune, deterministic per user per day (sha256 seed), so it's stable if asked repeatedly - no AI call, no state file. The full 24-rune Futhark, the draw logic and the reversal rules are pure and unit-tested (distinct draw, non-invertible never reversed, per-day stability, meaning fallback). Also fixes a pre-existing unit-job breakage: test_bar_commands and test_lore_commands each stubbed `discord` with different completeness and shared sys.modules, so once both landed on main the one lacking `discord.ext.tasks` shadowed the one needing it and collection failed order-dependently. A new tests/unit/conftest.py stubs discord once, completely, before any test module - the per-file stubs then skip. Full unit job: 41 passed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit was merged in pull request #7.
This commit is contained in:
@@ -0,0 +1,72 @@
|
||||
"""Shared stubs for the unit tests.
|
||||
|
||||
Several bot modules under test import ``discord`` (and ``discord.ext.commands`` /
|
||||
``discord.ext.tasks``) at import time. The pytest-only unit CI job doesn't
|
||||
install discord, so the test modules stub it themselves - but each stubbed only
|
||||
the pieces IT needed, and because they share ``sys.modules`` the first one to run
|
||||
won (skip-if-present), leaving a later module that needs e.g. ``discord.ext.tasks``
|
||||
with an incomplete stub. That made collection order-dependent and flaky (e.g.
|
||||
test_bar_commands, whose stub omits ``tasks``, shadowing test_lore_commands,
|
||||
which needs it).
|
||||
|
||||
Stubbing ``discord`` here - once, completely, before any test module is imported
|
||||
- removes the ordering dependency. The per-file stubs then simply skip. Only
|
||||
``discord`` is stubbed centrally; ai_functions / communication_subroutine stay
|
||||
per-file because different modules legitimately want the real vs a stubbed one.
|
||||
"""
|
||||
import sys
|
||||
import types
|
||||
|
||||
|
||||
def _ensure_discord_stub() -> None:
|
||||
if "discord" in sys.modules:
|
||||
return
|
||||
try: # real discord present (local dev) -> use it
|
||||
import discord # noqa: F401
|
||||
|
||||
return
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
mod = types.ModuleType("discord")
|
||||
|
||||
class Object:
|
||||
def __init__(self, id=None): # noqa: A002 - mirrors discord.Object
|
||||
self.id = id
|
||||
|
||||
class Member:
|
||||
pass
|
||||
|
||||
mod.Object = Object
|
||||
mod.Member = Member
|
||||
|
||||
ext = types.ModuleType("discord.ext")
|
||||
commands = types.ModuleType("discord.ext.commands")
|
||||
tasks = types.ModuleType("discord.ext.tasks")
|
||||
|
||||
class Cog:
|
||||
pass
|
||||
|
||||
commands.Cog = Cog
|
||||
commands.hybrid_command = lambda **_kwargs: (lambda fn: fn)
|
||||
|
||||
def loop(**_kwargs):
|
||||
def decorator(fn):
|
||||
fn.before_loop = lambda f: f
|
||||
return fn
|
||||
|
||||
return decorator
|
||||
|
||||
tasks.loop = loop
|
||||
commands.tasks = tasks
|
||||
ext.commands = commands
|
||||
ext.tasks = tasks
|
||||
mod.ext = ext
|
||||
|
||||
sys.modules["discord"] = mod
|
||||
sys.modules["discord.ext"] = ext
|
||||
sys.modules["discord.ext.commands"] = commands
|
||||
sys.modules["discord.ext.tasks"] = tasks
|
||||
|
||||
|
||||
_ensure_discord_stub()
|
||||
@@ -0,0 +1,98 @@
|
||||
"""Unit tests for oracle_commands' pure rune-draw logic.
|
||||
|
||||
Heavy imports (discord, ai_functions) are stubbed only when genuinely absent,
|
||||
matching the pytest-only unit CI job.
|
||||
"""
|
||||
import random
|
||||
import sys
|
||||
import types
|
||||
|
||||
|
||||
def _stub_if_missing(name, build):
|
||||
if name in sys.modules:
|
||||
return
|
||||
try:
|
||||
__import__(name)
|
||||
except ImportError:
|
||||
sys.modules[name] = build()
|
||||
|
||||
|
||||
def _build_discord():
|
||||
mod = types.ModuleType("discord")
|
||||
|
||||
class Object:
|
||||
def __init__(self, id=None): # noqa: A002
|
||||
self.id = id
|
||||
|
||||
mod.Object = Object
|
||||
ext = types.ModuleType("discord.ext")
|
||||
commands = types.ModuleType("discord.ext.commands")
|
||||
|
||||
class Cog:
|
||||
pass
|
||||
|
||||
commands.Cog = Cog
|
||||
commands.hybrid_command = lambda **_k: (lambda fn: fn)
|
||||
ext.commands = commands
|
||||
mod.ext = ext
|
||||
sys.modules["discord.ext"] = ext
|
||||
sys.modules["discord.ext.commands"] = commands
|
||||
return mod
|
||||
|
||||
|
||||
def _build_ai_functions():
|
||||
mod = types.ModuleType("ai_functions")
|
||||
|
||||
async def handle_response(*_a, **_k):
|
||||
return ("stub reading", [])
|
||||
|
||||
mod.handle_response = handle_response
|
||||
return mod
|
||||
|
||||
|
||||
_stub_if_missing("discord", _build_discord)
|
||||
_stub_if_missing("ai_functions", _build_ai_functions)
|
||||
|
||||
import oracle_commands # noqa: E402 (after stubbing)
|
||||
|
||||
|
||||
def test_full_elder_futhark_present():
|
||||
assert len(oracle_commands.RUNES) == 24
|
||||
# every rune has the required fields
|
||||
for r in oracle_commands.RUNES:
|
||||
assert {"symbol", "nazwa", "znaczenie", "odwrocone"} <= set(r)
|
||||
|
||||
|
||||
def test_draw_returns_distinct_runes():
|
||||
drawn = oracle_commands.draw_runes(3, random.Random(1))
|
||||
names = [r["nazwa"] for r, _ in drawn]
|
||||
assert len(names) == 3
|
||||
assert len(set(names)) == 3 # distinct
|
||||
|
||||
|
||||
def test_non_invertible_runes_are_never_reversed():
|
||||
# Run many draws of the whole deck; any rune with empty 'odwrocone' must
|
||||
# never come up reversed.
|
||||
for seed in range(200):
|
||||
for rune, reversed_ in oracle_commands.draw_runes(24, random.Random(seed)):
|
||||
if not rune["odwrocone"]:
|
||||
assert reversed_ is False, f"{rune['nazwa']} was reversed but is non-invertible"
|
||||
|
||||
|
||||
def test_runa_dnia_is_stable_per_user_per_day():
|
||||
# Same user + same day -> identical draw; different day -> RNG differs.
|
||||
r1 = oracle_commands.draw_runes(1, oracle_commands._rng_for_day(42, "2026-07-31"))[0]
|
||||
r2 = oracle_commands.draw_runes(1, oracle_commands._rng_for_day(42, "2026-07-31"))[0]
|
||||
assert r1[0]["nazwa"] == r2[0]["nazwa"] and r1[1] == r2[1]
|
||||
|
||||
seeds = {
|
||||
oracle_commands._rng_for_day(uid, "2026-07-31").random()
|
||||
for uid in range(50)
|
||||
}
|
||||
assert len(seeds) > 1 # different users get different streams
|
||||
|
||||
|
||||
def test_meaning_falls_back_to_upright_for_non_invertible():
|
||||
gebo = next(r for r in oracle_commands.RUNES if r["nazwa"] == "Gebo")
|
||||
# even if asked "reversed", a non-invertible rune yields its upright meaning
|
||||
assert oracle_commands._meaning(gebo, True) == gebo["znaczenie"]
|
||||
Reference in New Issue
Block a user