Added emoji wall module to provide OBS overlay for floating chat emojis and emotes.
CI / Formatting (push) Successful in 5s
CI / Linting (push) Successful in 9s
CI / Tests (Python 3.12) (push) Successful in 38s
CI / Tests (Python 3.13) (push) Successful in 22s
CI / Tests (Python 3.14) (push) Successful in 18s
CI / Type Checking (push) Successful in 10s
CI / Spelling (push) Successful in 5s

This commit is contained in:
2026-03-31 11:52:25 -04:00
parent 7a253985c4
commit 5425241f74
7 changed files with 992 additions and 1 deletions
+22
View File
@@ -81,6 +81,28 @@ modules:
# Maximum nesting depth for placeholders in command responses. # Maximum nesting depth for placeholders in command responses.
#max_nesting_depth: 4 #max_nesting_depth: 4
emoji_wall:
enabled: true
# Minimum emoji size in pixels.
#min_size: 20
# Maximum emoji size in pixels.
#max_size: 60
# Minimum animation duration in seconds.
#min_duration: 3
# Maximum animation duration in seconds.
#max_duration: 10
# Maximum number of emojis on screen at once.
#max_count: 50
# Direction emojis travel. Named presets: up, up-right, right,
# down-right, down, down-left, left, up-left, random.
# Also accepts a degree value (0-359, where 0 is up, clockwise).
#direction: "up"
# Maximum rotation angle in degrees. Each emoji gets a random
# rotation between -max_rotation and +max_rotation. Set to 0 to
# disable rotation.
#max_rotation: 25
quotes: quotes:
enabled: true enabled: true
+1 -1
Submodule docs updated: 3c26a82898...2f7c73138a
+1
View File
@@ -18,6 +18,7 @@ BUILTIN_MODULE_NAMES: frozenset[str] = frozenset(
{ {
"clips", "clips",
"custom_commands", "custom_commands",
"emoji_wall",
"quotes", "quotes",
"timers", "timers",
} }
@@ -0,0 +1,395 @@
# 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.
"""Emoji wall module for Owlbot.
Listens to chat events, extracts Unicode emojis and custom Owncast emotes,
and broadcasts them via Server-Sent Events (SSE) to connected browser sources
for rendering as floating animations in an OBS overlay.
"""
from __future__ import annotations
import asyncio
import contextlib
import json
import secrets
from html.parser import HTMLParser
from pathlib import Path
from typing import TYPE_CHECKING
import emoji
from aiohttp import web
if TYPE_CHECKING:
import logging
from owlbot.api import (
ChatEvent,
EventContext,
EventType,
ModuleContext,
RouteContext,
on_event,
on_route,
on_setup,
on_teardown,
)
__all__ = [
"events_stream",
"on_chat",
"setup",
"static_wall_js",
"teardown",
"wall_page",
]
class _EmoteParser(HTMLParser):
"""HTML parser that extracts ``src`` from ``<img>`` tags with an emoji class."""
def __init__(self) -> None:
super().__init__()
self.urls: list[str] = []
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
"""Collect src from img tags whose class is exactly ``emoji``."""
if tag != "img":
return
attr_dict = dict(attrs)
src = attr_dict.get("src")
if attr_dict.get("class") == "emoji" and src:
self.urls.append(src)
_WALL_JS_PATH: Path = Path(__file__).resolve().parent / "static" / "wall.js"
_DIRECTION_PRESETS: dict[str, int] = {
"up": 0,
"up-right": 45,
"right": 90,
"down-right": 135,
"down": 180,
"down-left": 225,
"left": 270,
"up-left": 315,
}
def _resolve_direction(value: str | int) -> int | str:
"""Resolve a direction value to a degree integer or ``"random"``.
Accepts named presets (case-insensitive), the string ``"random"``,
or a numeric degree value (0-359). Invalid values fall back to 0.
:param value: Direction preset name, ``"random"``, or degree value.
:return: Integer degrees (0-359) or the string ``"random"``.
"""
if isinstance(value, str):
normalized = value.strip().lower()
if normalized == "random":
return "random"
if normalized in _DIRECTION_PRESETS:
return _DIRECTION_PRESETS[normalized]
try:
value = int(normalized)
except ValueError:
return 0
return max(0, min(359, int(value)))
def _resolve_settings(
config: dict[str, object],
query: dict[str, str],
) -> dict[str, int | str]:
"""Build a validated settings dict from config values and query overrides.
For each known setting key, the query parameter value (if present and
parseable) takes precedence over the config value. All numeric values
are clamped to their allowed ranges. Min/max pairs are swapped if
inverted.
:param config: Module config dict from ``ctx.config.as_dict()``.
:param query: Query parameters from the HTTP request.
:return: Validated settings dict ready for template injection.
"""
def _int(key: str) -> int:
if key in query:
try:
return int(query[key])
except (ValueError, TypeError):
pass
try:
return int(str(config[key]))
except (ValueError, TypeError, KeyError):
return 0
min_size = max(1, min(500, _int("min_size")))
max_size = max(1, min(500, _int("max_size")))
if min_size > max_size:
min_size, max_size = max_size, min_size
min_duration = max(1, min(60, _int("min_duration")))
max_duration = max(1, min(60, _int("max_duration")))
if min_duration > max_duration:
min_duration, max_duration = max_duration, min_duration
raw_direction = query.get("direction") or str(config.get("direction", "up"))
return {
"min_size": min_size,
"max_size": max_size,
"min_duration": min_duration,
"max_duration": max_duration,
"max_count": max(1, min(1000, _int("max_count"))),
"direction": _resolve_direction(raw_direction),
"max_rotation": max(0, min(360, _int("max_rotation"))),
}
def _extract_emojis(
raw_body: str, html_body: str, owncast_url: str
) -> list[dict[str, str]]:
"""Extract Unicode emojis and custom Owncast emotes from a chat message.
Unicode emojis are detected from the raw body (original user input)
to avoid matching emojis inside HTML attributes. Custom emotes are
extracted from the rendered HTML body, which contains ``<img>`` tags
with a class of ``emoji``.
:param raw_body: The plain-text message body.
:param html_body: The rendered HTML message body from the chat event.
:param owncast_url: The base URL of the Owncast server.
:return: List of SSE event dicts ready for broadcast.
"""
events: list[dict[str, str]] = [
{"type": "unicode", "emoji": e}
for e in (item["emoji"] for item in emoji.emoji_list(raw_body))
]
parser = _EmoteParser()
parser.feed(html_body)
events.extend(
{"type": "emote", "url": f"{owncast_url}/{src.lstrip('/')}"}
for src in parser.urls
)
return events
async def _broadcast(
clients: set[web.StreamResponse],
events: list[dict[str, str]],
logger: logging.Logger,
) -> None:
"""Broadcast SSE events to all connected clients.
Serializes each event as JSON, concatenates them into a single
payload, and writes it to every client in one call. Clients
that fail to receive the message are removed from the set.
:param clients: The set of connected SSE stream responses.
:param events: The event data dicts to broadcast.
:param logger: Logger instance from the module context.
"""
payload = "".join(f"data: {json.dumps(event)}\n\n" for event in events).encode()
snapshot = list(clients)
results = await asyncio.gather(
*(client.write(payload) for client in snapshot),
return_exceptions=True,
)
failed = 0
for client, result in zip(snapshot, results, strict=True):
if isinstance(result, Exception):
clients.discard(client)
failed += 1
if failed:
logger.debug("Removed %d failed client(s) during broadcast.", failed)
@on_setup
async def setup(ctx: ModuleContext) -> None:
"""Initialize the emoji wall module.
Registers config defaults, generates token if needed,
and creates the SSE client tracking set.
:param ctx: Module context with config, storage, and other services.
"""
ctx.config.register_defaults(
{
"min_size": 20,
"max_size": 60,
"min_duration": 3,
"max_duration": 10,
"max_count": 50,
"direction": "up",
"max_rotation": 25,
}
)
ctx.state["clients"] = set[web.StreamResponse]()
token = ctx.config.get("token")
if not token:
token = secrets.token_urlsafe(32)
ctx.config.set("token", token)
ctx.logger.info("Generated emoji wall token and saved to config.")
ctx.state["token"] = token
overlay_url = ctx.routes.url_for(f"/{token}")
ctx.logger.info("Emoji wall overlay URL: %s", overlay_url)
@on_teardown
async def teardown(ctx: ModuleContext) -> None:
"""Clean up the emoji wall module.
Closes all connected SSE clients and clears the client set.
:param ctx: Module context.
"""
clients: set[web.StreamResponse] = ctx.state["clients"]
ctx.logger.debug("Closing %d SSE client(s).", len(clients))
for client in clients:
with contextlib.suppress(Exception):
await client.write_eof()
clients.clear()
ctx.logger.info("Emoji wall module cleaned up.")
@on_event(EventType.CHAT)
async def on_chat(ctx: EventContext[ChatEvent]) -> None:
"""Handle chat events by extracting emojis and broadcasting them via SSE.
Extracts both Unicode emojis and custom Owncast emotes from the chat
message and sends each one as an SSE event to all connected overlay
clients.
:param ctx: The event context containing the chat event.
"""
clients: set[web.StreamResponse] = ctx.module.state["clients"]
if not clients:
ctx.module.logger.debug(
"Skipping chat %s: no SSE clients connected.",
ctx.event.message_id,
)
return
owncast_url: str = ctx.module.owncast_client.base_url
events = _extract_emojis(ctx.event.raw_body, ctx.event.body, owncast_url)
if events:
ctx.module.logger.debug(
"Broadcasting %d emoji(s) from %s to %d client(s).",
len(events),
ctx.event.user.display_name,
len(clients),
)
await _broadcast(clients, events, ctx.module.logger)
@on_route("/{token}")
async def wall_page(ctx: RouteContext) -> web.Response:
"""Serve the emoji wall overlay page.
Validates the token and serves the overlay HTML. Invalid tokens return 401.
:param ctx: The route context.
:return: HTML response for the overlay page, or 401 if token is invalid.
"""
expected_token = ctx.module.state.get("token")
if not secrets.compare_digest(
ctx.match_info.get("token", ""), expected_token or ""
):
ctx.logger.debug("Wall page rejected: invalid token.")
return web.Response(
status=401,
text="Invalid token. Check the Owlbot log for the correct emoji wall URL.",
)
events_url = ctx.routes.url_for(f"/{expected_token}/events")
js_url = ctx.routes.url_for("/static/wall.js")
settings = _resolve_settings(ctx.config.as_dict(), dict(ctx.request.query))
ctx.logger.debug("Serving wall page with settings: %s.", settings)
page = ctx.templates.render(
"wall.html",
events_url=events_url,
js_url=js_url,
settings=settings,
)
return web.Response(text=page, content_type="text/html")
@on_route("/{token}/events", streaming=True)
async def events_stream(ctx: RouteContext) -> web.StreamResponse | web.Response:
"""Server-Sent Events endpoint for emoji wall updates.
Validates the token and establishes a persistent SSE connection.
Sends keepalive comments every 15 seconds to prevent proxy timeouts.
:param ctx: The route context.
:return: SSE stream response, or 401 if token is invalid.
"""
expected_token = ctx.module.state.get("token")
if not secrets.compare_digest(
ctx.match_info.get("token", ""), expected_token or ""
):
ctx.logger.debug("SSE stream rejected: invalid token.")
return web.Response(
status=401,
text="Invalid token. Check the Owlbot log for the correct emoji wall URL.",
)
response = web.StreamResponse(
status=200,
headers={
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)
await response.prepare(ctx.request)
clients: set[web.StreamResponse] = ctx.module.state["clients"]
clients.add(response)
ctx.logger.debug("SSE client connected (%d total).", len(clients))
try:
while True:
await asyncio.sleep(15)
try:
await response.write(b": keepalive\n\n")
except Exception:
ctx.logger.debug("SSE keepalive failed, disconnecting client.")
break
finally:
clients.discard(response)
ctx.logger.debug("SSE client disconnected (%d remaining).", len(clients))
return response
@on_route("/static/wall.js", methods=["GET"])
async def static_wall_js(ctx: RouteContext) -> web.FileResponse:
"""Serve the emoji wall JavaScript file.
:param ctx: The route context.
:return: The wall.js file with caching headers.
"""
return web.FileResponse(
_WALL_JS_PATH,
headers={"Cache-Control": "max-age=86400"},
)
@@ -0,0 +1,157 @@
(function () {
"use strict";
const easings = ["ease", "ease-in", "ease-out", "ease-in-out", "linear"];
const activeEmojis = [];
const DEG_TO_RAD = Math.PI / 180;
function randomBetween(min, max) {
return Math.random() * (max - min) + min;
}
function computeTrajectory(angleDeg) {
// 0 = up, 90 = right, 180 = down, 270 = left (clockwise).
const rad = angleDeg * DEG_TO_RAD;
const dx = Math.sin(rad);
const dy = -Math.cos(rad);
// Starting position: opposite edge, randomized along that edge.
// Ending position: off-screen in travel direction.
// Units are in vw/vh for viewport-relative positioning.
const travel = 120;
let startX, startY;
const absDx = Math.abs(dx);
const absDy = Math.abs(dy);
if (absDy >= absDx) {
// Primarily vertical movement.
startX = Math.random() * 100;
startY = dy < 0 ? 100 : -10;
} else {
// Primarily horizontal movement.
startX = dx > 0 ? -10 : 100;
startY = Math.random() * 100;
}
// Drift perpendicular to travel direction.
const drift = (Math.random() - 0.5) * 15;
const perpDx = -dy;
const perpDy = dx;
return {
startX,
startY,
endDx: dx * travel + perpDx * drift,
endDy: dy * travel + perpDy * drift
};
}
function spawnEmoji(element) {
const size = randomBetween(SETTINGS.min_size, SETTINGS.max_size);
const duration = randomBetween(SETTINGS.min_duration, SETTINGS.max_duration);
const delay = Math.random() * 1.5;
const easing = easings[Math.floor(Math.random() * easings.length)];
const angle = SETTINGS.direction === "random"
? Math.floor(Math.random() * 360)
: SETTINGS.direction;
const traj = computeTrajectory(angle);
let rotStart = 0;
let rotEnd = 0;
if (SETTINGS.max_rotation > 0) {
rotStart = randomBetween(-SETTINGS.max_rotation, SETTINGS.max_rotation);
rotEnd = randomBetween(-SETTINGS.max_rotation, SETTINGS.max_rotation);
}
element.classList.add("emoji_item");
element.style.left = `${traj.startX}vw`;
element.style.top = `${traj.startY}vh`;
if (element.tagName === "IMG") {
element.style.width = `${size}px`;
element.style.height = `${size}px`;
} else {
element.style.fontSize = `${size}px`;
}
// Enforce max count by removing oldest.
while (activeEmojis.length >= SETTINGS.max_count) {
const oldest = activeEmojis.shift();
if (oldest) {
oldest.remove();
}
}
activeEmojis.push(element);
document.body.append(element);
const keyframes = [
{
transform: `translate(0, 0) rotate(${rotStart}deg)`,
opacity: 1
},
{
transform: `translate(${traj.endDx}vw, ${traj.endDy}vh) rotate(${rotEnd}deg)`,
opacity: 0
}
];
const timing = {
duration: duration * 1000,
delay: delay * 1000,
easing,
fill: "forwards"
};
const animation = element.animate(keyframes, timing);
animation.onfinish = () => {
element.remove();
const index = activeEmojis.indexOf(element);
if (index !== -1) {
activeEmojis.splice(index, 1);
}
};
}
function handleEvent(data) {
let element;
if (data.type === "unicode") {
element = document.createElement("span");
element.textContent = data.emoji;
} else if (data.type === "emote") {
element = document.createElement("img");
element.src = data.url;
element.alt = "emote";
} else {
return;
}
spawnEmoji(element);
}
const RECONNECT_DELAY = 5000;
function connect() {
const source = new EventSource(EVENTS_URL);
source.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
handleEvent(data);
} catch (e) {
// Ignore malformed events.
}
};
source.onerror = () => {
// EventSource auto-reconnects on transient network errors
// (readyState stays CONNECTING). However, HTTP-level errors
// like 502/503 from a reverse proxy set readyState to CLOSED
// and the browser gives up. Reconnect manually so the overlay
// survives bot restarts behind a proxy.
if (source.readyState === EventSource.CLOSED) {
source.close();
setTimeout(connect, RECONNECT_DELAY);
}
};
}
connect();
})();
@@ -0,0 +1,34 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Emoji Wall</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html, body {
width: 100%;
height: 100%;
overflow: hidden;
background: transparent;
}
.emoji_item {
position: fixed;
line-height: 1;
will-change: transform, opacity;
pointer-events: none;
}
</style>
</head>
<body>
<script>
const EVENTS_URL = {{ events_url | tojson }};
const SETTINGS = {{ settings | tojson }};
</script>
<script src="{{ js_url }}?v={{ owlbot_version }}"></script>
</body>
</html>
+382
View File
@@ -0,0 +1,382 @@
# 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 the emoji wall built-in module."""
from __future__ import annotations
import json
import logging
from typing import TYPE_CHECKING, ClassVar
import pytest
if TYPE_CHECKING:
from aiohttp import web
from owlbot.builtin_modules.emoji_wall import (
_broadcast,
_extract_emojis,
_resolve_direction,
_resolve_settings,
)
def _img(src: str, class_: str = "emoji") -> str:
"""Build an ``<img>`` tag matching Owncast's emote rendering."""
return f'<img src="{src}" class="{class_}" />'
class _FakeStreamResponse:
"""Minimal stand-in for ``web.StreamResponse`` that records writes."""
def __init__(self, *, fail: bool = False) -> None:
self.written: list[bytes] = []
self._fail = fail
async def write(self, data: bytes) -> None:
"""Record *data* or raise if this client is configured to fail."""
if self._fail:
raise ConnectionResetError("client gone")
self.written.append(data)
_URL = "https://stream.example.com"
class TestExtractEmojis:
"""Tests ``_extract_emojis`` from the emoji wall module."""
@pytest.mark.parametrize(
("raw_body", "expected"),
[
pytest.param("no emojis here", [], id="plain-text"),
pytest.param(
"hello 🔥 world",
[{"type": "unicode", "emoji": "🔥"}],
id="single-emoji",
),
pytest.param(
"🎉🎊✨",
[
{"type": "unicode", "emoji": "🎉"},
{"type": "unicode", "emoji": "🎊"},
{"type": "unicode", "emoji": "✨"},
],
id="multiple-emojis",
),
pytest.param(
"hi 👋🏽",
[{"type": "unicode", "emoji": "👋🏽"}],
id="skin-tone-modifier",
),
pytest.param(
"family 👨‍👩‍👧‍👦 time",
[{"type": "unicode", "emoji": "👨‍👩‍👧‍👦"}],
id="zwj-sequence",
),
pytest.param(
"flag 🇺🇸 here",
[{"type": "unicode", "emoji": "🇺🇸"}],
id="flag-sequence",
),
pytest.param("", [], id="empty-string"),
],
)
def test_unicode(self, raw_body: str, expected: list[dict[str, str]]) -> None:
"""Unicode emojis are extracted from the raw body."""
assert _extract_emojis(raw_body, "", _URL) == expected
def test_custom_emote(self) -> None:
"""Custom emote img tags are resolved against the Owncast URL."""
html = _img("/img/emoji/catjam.gif")
result = _extract_emojis("", html, _URL)
assert result == [
{"type": "emote", "url": f"{_URL}/img/emoji/catjam.gif"},
]
def test_multiple_custom_emotes(self) -> None:
"""Multiple emotes in a single message are all extracted."""
html = _img("/img/emoji/a.gif") + _img("/img/emoji/b.gif")
result = _extract_emojis("", html, _URL)
assert result == [
{"type": "emote", "url": f"{_URL}/img/emoji/a.gif"},
{"type": "emote", "url": f"{_URL}/img/emoji/b.gif"},
]
def test_non_emoji_img_ignored(self) -> None:
"""An <img> without the emoji class is not matched."""
html = '<img src="/photo.jpg" class="avatar" />'
assert _extract_emojis("", html, _URL) == []
def test_class_before_src(self) -> None:
"""Handles <img> where class appears before src."""
html = '<img class="emoji" src="/img/emoji/wave.gif" />'
result = _extract_emojis("", html, _URL)
assert result == [
{"type": "emote", "url": f"{_URL}/img/emoji/wave.gif"},
]
def test_extra_class_ignored(self) -> None:
"""An <img> with a class other than exactly 'emoji' is not matched."""
html = '<img src="/img/emoji/wave.gif" class="emoji custom" />'
assert _extract_emojis("", html, _URL) == []
def test_img_without_src_ignored(self) -> None:
"""An <img> with class emoji but no src is not matched."""
html = '<img class="emoji" />'
assert _extract_emojis("", html, _URL) == []
def test_leading_slashes_deduped(self) -> None:
"""Extra leading slashes on the src don't double up."""
html = _img("///img/emoji/test.gif")
result = _extract_emojis("", html, _URL)
assert result == [
{"type": "emote", "url": f"{_URL}/img/emoji/test.gif"},
]
def test_unicode_and_custom_combined(self) -> None:
"""Unicode emojis and custom emotes are returned together."""
html = f"<p>🔥 {_img('/img/emoji/catjam.gif')}</p>"
result = _extract_emojis("🔥 :catjam:", html, _URL)
assert result == [
{"type": "unicode", "emoji": "🔥"},
{"type": "emote", "url": f"{_URL}/img/emoji/catjam.gif"},
]
def test_empty_message(self) -> None:
"""Empty raw body and HTML body returns an empty list."""
assert _extract_emojis("", "", _URL) == []
class TestResolveDirection:
"""Tests ``_resolve_direction`` from the emoji wall module."""
@pytest.mark.parametrize(
("value", "expected"),
[
pytest.param("up", 0, id="preset-up"),
pytest.param("down", 180, id="preset-down"),
pytest.param("left", 270, id="preset-left"),
pytest.param("right", 90, id="preset-right"),
pytest.param("up-right", 45, id="preset-up-right"),
pytest.param("down-left", 225, id="preset-down-left"),
pytest.param("UP", 0, id="case-insensitive"),
pytest.param(" Down ", 180, id="whitespace-trimmed"),
pytest.param("random", "random", id="random-string"),
pytest.param("RANDOM", "random", id="random-case-insensitive"),
pytest.param("45", 45, id="numeric-string"),
pytest.param("0", 0, id="zero-string"),
pytest.param("359", 359, id="max-degrees-string"),
pytest.param("nonsense", 0, id="invalid-string-fallback"),
pytest.param(90, 90, id="int-value"),
pytest.param(0, 0, id="int-zero"),
pytest.param(359, 359, id="int-max"),
pytest.param(-10, 0, id="negative-clamped"),
pytest.param(500, 359, id="over-max-clamped"),
pytest.param("999", 359, id="numeric-string-clamped-high"),
pytest.param("-5", 0, id="numeric-string-clamped-low"),
],
)
def test_resolution(self, value: str | int, expected: int | str) -> None:
"""Direction values resolve to the expected degree or 'random'."""
assert _resolve_direction(value) == expected
class TestResolveSettings:
"""Tests ``_resolve_settings`` from the emoji wall module."""
_DEFAULTS: ClassVar[dict[str, object]] = {
"min_size": 20,
"max_size": 60,
"min_duration": 3,
"max_duration": 10,
"max_count": 50,
"direction": "up",
"max_rotation": 25,
}
def test_defaults(self) -> None:
"""Config defaults produce expected settings."""
result = _resolve_settings(self._DEFAULTS, {})
assert result == {
"min_size": 20,
"max_size": 60,
"min_duration": 3,
"max_duration": 10,
"max_count": 50,
"direction": 0,
"max_rotation": 25,
}
def test_query_overrides_config(self) -> None:
"""Query parameters take precedence over config values."""
result = _resolve_settings(
self._DEFAULTS,
{"min_size": "30", "max_size": "80"},
)
assert result["min_size"] == 30
assert result["max_size"] == 80
def test_invalid_query_falls_back_to_config(self) -> None:
"""Unparsable query values fall back to config."""
result = _resolve_settings(
self._DEFAULTS,
{"min_size": "not_a_number"},
)
assert result["min_size"] == 20
def test_min_max_size_swapped(self) -> None:
"""Inverted min/max sizes are swapped."""
result = _resolve_settings(
{**self._DEFAULTS, "min_size": 100, "max_size": 10},
{},
)
assert result["min_size"] == 10
assert result["max_size"] == 100
def test_min_max_duration_swapped(self) -> None:
"""Inverted min/max durations are swapped."""
result = _resolve_settings(
{**self._DEFAULTS, "min_duration": 30, "max_duration": 1},
{},
)
assert result["min_duration"] == 1
assert result["max_duration"] == 30
@pytest.mark.parametrize(
("key", "value", "expected"),
[
pytest.param("min_size", -10, 1, id="size-floor"),
pytest.param("max_size", 9999, 500, id="size-ceiling"),
pytest.param("min_duration", -1, 1, id="duration-floor"),
pytest.param("max_duration", 999, 60, id="duration-ceiling"),
pytest.param("max_count", -1, 1, id="count-floor"),
pytest.param("max_count", 5000, 1000, id="count-ceiling"),
pytest.param("max_rotation", -5, 0, id="rotation-floor"),
pytest.param("max_rotation", 999, 360, id="rotation-ceiling"),
],
)
def test_clamping(self, key: str, value: int, expected: int) -> None:
"""Values outside allowed ranges are clamped."""
result = _resolve_settings({**self._DEFAULTS, key: value}, {})
assert result[key] == expected
def test_direction_random(self) -> None:
"""The 'random' direction string passes through."""
result = _resolve_settings(
{**self._DEFAULTS, "direction": "random"},
{},
)
assert result["direction"] == "random"
def test_direction_query_override(self) -> None:
"""Query parameter overrides the config direction."""
result = _resolve_settings(self._DEFAULTS, {"direction": "down"})
assert result["direction"] == 180
def test_empty_config_uses_zero_defaults(self) -> None:
"""Missing config keys produce clamped-to-minimum values."""
result = _resolve_settings({}, {})
assert result["min_size"] == 1
assert result["max_size"] == 1
assert result["min_duration"] == 1
assert result["max_duration"] == 1
assert result["max_count"] == 1
assert result["max_rotation"] == 0
class TestBroadcast:
"""Tests ``_broadcast`` from the emoji wall module."""
async def test_sends_to_all_clients(self) -> None:
"""Payload is written to every connected client."""
c1 = _FakeStreamResponse()
c2 = _FakeStreamResponse()
clients: set[web.StreamResponse] = {c1, c2} # type: ignore[arg-type]
events = [{"type": "unicode", "emoji": "🔥"}]
await _broadcast(clients, events, logging.getLogger("test"))
expected = f"data: {json.dumps(events[0])}\n\n".encode()
assert c1.written == [expected]
assert c2.written == [expected]
assert len(clients) == 2
async def test_failed_client_removed(self) -> None:
"""A client that raises on write is removed from the set."""
good = _FakeStreamResponse()
bad = _FakeStreamResponse(fail=True)
clients: set[web.StreamResponse] = {good, bad} # type: ignore[arg-type]
events = [{"type": "unicode", "emoji": "😀"}]
await _broadcast(clients, events, logging.getLogger("test"))
assert good in clients
assert bad not in clients
assert len(clients) == 1
async def test_failed_client_logged(self, caplog: pytest.LogCaptureFixture) -> None:
"""Removal of failed clients is logged at DEBUG level."""
bad = _FakeStreamResponse(fail=True)
clients: set[web.StreamResponse] = {bad} # type: ignore[arg-type]
with caplog.at_level(logging.DEBUG, logger="test"):
await _broadcast(
clients,
[{"type": "unicode", "emoji": "😀"}],
logging.getLogger("test"),
)
assert "Removed 1 failed client(s) during broadcast" in caplog.text
async def test_multiple_events_concatenated(self) -> None:
"""Multiple events are sent as a single concatenated payload."""
client = _FakeStreamResponse()
clients: set[web.StreamResponse] = {client} # type: ignore[arg-type]
events = [
{"type": "unicode", "emoji": "🎉"},
{"type": "emote", "url": "https://example.com/emote.gif"},
]
await _broadcast(clients, events, logging.getLogger("test"))
assert len(client.written) == 1
payload = client.written[0].decode()
assert f"data: {json.dumps(events[0])}\n\n" in payload
assert f"data: {json.dumps(events[1])}\n\n" in payload
async def test_empty_clients_noop(self) -> None:
"""Broadcasting to no clients completes without error."""
clients: set[web.StreamResponse] = set()
await _broadcast(
clients,
[{"type": "unicode", "emoji": "😀"}],
logging.getLogger("test"),
)
assert len(clients) == 0
async def test_all_clients_fail(self) -> None:
"""When every client fails, the set is emptied."""
b1 = _FakeStreamResponse(fail=True)
b2 = _FakeStreamResponse(fail=True)
clients: set[web.StreamResponse] = {b1, b2} # type: ignore[arg-type]
await _broadcast(
clients,
[{"type": "unicode", "emoji": "😀"}],
logging.getLogger("test"),
)
assert len(clients) == 0