refactor: phase 1 - testability improvements

- Add Config dataclass with get_config()/set_config() for injection
- Add Config.from_args() for CLI argument parsing (testable)
- Add platform font path detection (Darwin/Linux)
- Bound translate cache with @lru_cache(maxsize=500)
- Add fixtures for external dependencies (network, feeds, config)
- Add 15 tests for Config class, from_args, and platform detection

This enables testability by:
- Allowing config injection instead of global mutable state
- Supporting custom argv in from_args() for testing
- Providing reusable fixtures for mocking network/filesystem
- Preventing unbounded memory growth in translation cache

Fixes: _arg_value/_arg_int not accepting custom argv
This commit is contained in:
2026-03-15 15:44:39 -07:00
parent f6f177590b
commit b5d6eeedc0
4 changed files with 531 additions and 22 deletions

View File

@@ -1,25 +1,28 @@
""" """
Configuration constants, CLI flags, and glyph tables. Configuration constants, CLI flags, and glyph tables.
Supports both global constants (backward compatible) and injected config for testing.
""" """
import sys import sys
from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
_REPO_ROOT = Path(__file__).resolve().parent.parent _REPO_ROOT = Path(__file__).resolve().parent.parent
_FONT_EXTENSIONS = {".otf", ".ttf", ".ttc"} _FONT_EXTENSIONS = {".otf", ".ttf", ".ttc"}
def _arg_value(flag): def _arg_value(flag, argv: list[str] | None = None):
"""Get value following a CLI flag, if present.""" """Get value following a CLI flag, if present."""
if flag not in sys.argv: argv = argv or sys.argv
if flag not in argv:
return None return None
i = sys.argv.index(flag) i = argv.index(flag)
return sys.argv[i + 1] if i + 1 < len(sys.argv) else None return argv[i + 1] if i + 1 < len(argv) else None
def _arg_int(flag, default): def _arg_int(flag, default, argv: list[str] | None = None):
"""Get int CLI argument with safe fallback.""" """Get int CLI argument with safe fallback."""
raw = _arg_value(flag) raw = _arg_value(flag, argv)
if raw is None: if raw is None:
return default return default
try: try:
@@ -53,6 +56,134 @@ def list_repo_font_files():
return _list_font_files(FONT_DIR) return _list_font_files(FONT_DIR)
def _get_platform_font_paths() -> dict[str, str]:
"""Get platform-appropriate font paths for non-Latin scripts."""
import platform
system = platform.system()
if system == "Darwin":
return {
"zh-cn": "/System/Library/Fonts/STHeiti Medium.ttc",
"ja": "/System/Library/Fonts/ヒラギノ角ゴシック W9.ttc",
"ko": "/System/Library/Fonts/AppleSDGothicNeo.ttc",
"ru": "/System/Library/Fonts/Supplemental/Arial.ttf",
"uk": "/System/Library/Fonts/Supplemental/Arial.ttf",
"el": "/System/Library/Fonts/Supplemental/Arial.ttf",
"he": "/System/Library/Fonts/Supplemental/Arial.ttf",
"ar": "/System/Library/Fonts/GeezaPro.ttc",
"fa": "/System/Library/Fonts/GeezaPro.ttc",
"hi": "/System/Library/Fonts/Kohinoor.ttc",
"th": "/System/Library/Fonts/ThonburiUI.ttc",
}
elif system == "Linux":
return {
"zh-cn": "/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc",
"ja": "/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc",
"ko": "/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc",
"ru": "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
"uk": "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
"el": "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
"he": "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
"ar": "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
"fa": "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
"hi": "/usr/share/fonts/truetype/noto/NotoSansDevanagari-Regular.ttf",
"th": "/usr/share/fonts/truetype/noto/NotoSansThai-Regular.ttf",
}
else:
return {}
@dataclass(frozen=True)
class Config:
"""Immutable configuration container for injected config."""
headline_limit: int = 1000
feed_timeout: int = 10
mic_threshold_db: int = 50
mode: str = "news"
firehose: bool = False
ntfy_topic: str = "https://ntfy.sh/klubhaus_terminal_mainline/json"
ntfy_reconnect_delay: int = 5
message_display_secs: int = 30
font_dir: str = "fonts"
font_path: str = ""
font_index: int = 0
font_picker: bool = True
font_sz: int = 60
render_h: int = 8
ssaa: int = 4
scroll_dur: float = 5.625
frame_dt: float = 0.05
firehose_h: int = 12
grad_speed: float = 0.08
glitch_glyphs: str = "░▒▓█▌▐╌╍╎╏┃┆┇┊┋"
kata_glyphs: str = "ハミヒーウシナモニサワツオリアホテマケメエカキムユラセネスタヌヘ"
script_fonts: dict[str, str] = field(default_factory=_get_platform_font_paths)
@classmethod
def from_args(cls, argv: list[str] | None = None) -> "Config":
"""Create Config from CLI arguments (or custom argv for testing)."""
argv = argv or sys.argv
font_dir = _resolve_font_path(_arg_value("--font-dir", argv) or "fonts")
font_file_arg = _arg_value("--font-file", argv)
font_files = _list_font_files(font_dir)
font_path = (
_resolve_font_path(font_file_arg)
if font_file_arg
else (font_files[0] if font_files else "")
)
return cls(
headline_limit=1000,
feed_timeout=10,
mic_threshold_db=50,
mode="poetry" if "--poetry" in argv or "-p" in argv else "news",
firehose="--firehose" in argv,
ntfy_topic="https://ntfy.sh/klubhaus_terminal_mainline/json",
ntfy_reconnect_delay=5,
message_display_secs=30,
font_dir=font_dir,
font_path=font_path,
font_index=max(0, _arg_int("--font-index", 0, argv)),
font_picker="--no-font-picker" not in argv,
font_sz=60,
render_h=8,
ssaa=4,
scroll_dur=5.625,
frame_dt=0.05,
firehose_h=12,
grad_speed=0.08,
glitch_glyphs="░▒▓█▌▐╌╍╎╏┃┆┇┊┋",
kata_glyphs="ハミヒーウシナモニサワツオリアホテマケメエカキムユラセネスタヌヘ",
script_fonts=_get_platform_font_paths(),
)
_config: Config | None = None
def get_config() -> Config:
"""Get the global config instance (lazy-loaded)."""
global _config
if _config is None:
_config = Config.from_args()
return _config
def set_config(config: Config) -> None:
"""Set the global config instance (for testing)."""
global _config
_config = config
# ─── RUNTIME ────────────────────────────────────────────── # ─── RUNTIME ──────────────────────────────────────────────
HEADLINE_LIMIT = 1000 HEADLINE_LIMIT = 1000
FEED_TIMEOUT = 10 FEED_TIMEOUT = 10

View File

@@ -7,26 +7,16 @@ import json
import re import re
import urllib.parse import urllib.parse
import urllib.request import urllib.request
from functools import lru_cache
from engine.sources import LOCATION_LANGS from engine.sources import LOCATION_LANGS
_TRANSLATE_CACHE = {} TRANSLATE_CACHE_SIZE = 500
def detect_location_language(title): @lru_cache(maxsize=TRANSLATE_CACHE_SIZE)
"""Detect if headline mentions a location, return target language.""" def _translate_cached(title: str, target_lang: str) -> str:
title_lower = title.lower() """Cached translation implementation."""
for pattern, lang in LOCATION_LANGS.items():
if re.search(pattern, title_lower):
return lang
return None
def translate_headline(title, target_lang):
"""Translate headline via Google Translate API (zero dependencies)."""
key = (title, target_lang)
if key in _TRANSLATE_CACHE:
return _TRANSLATE_CACHE[key]
try: try:
q = urllib.parse.quote(title) q = urllib.parse.quote(title)
url = ( url = (
@@ -39,5 +29,18 @@ def translate_headline(title, target_lang):
result = "".join(p[0] for p in data[0] if p[0]) or title result = "".join(p[0] for p in data[0] if p[0]) or title
except Exception: except Exception:
result = title result = title
_TRANSLATE_CACHE[key] = result
return result return result
def detect_location_language(title):
"""Detect if headline mentions a location, return target language."""
title_lower = title.lower()
for pattern, lang in LOCATION_LANGS.items():
if re.search(pattern, title_lower):
return lang
return None
def translate_headline(title: str, target_lang: str) -> str:
"""Translate headline via Google Translate API (zero dependencies)."""
return _translate_cached(title, target_lang)

236
tests/fixtures/__init__.py vendored Normal file
View File

@@ -0,0 +1,236 @@
"""
Pytest fixtures for mocking external dependencies (network, filesystem).
"""
import json
from unittest.mock import MagicMock
import pytest
@pytest.fixture
def mock_feed_response():
"""Mock RSS feed response data."""
return b"""<?xml version="1.0" encoding="UTF-8" ?>
<rss version="2.0">
<channel>
<title>Test Feed</title>
<link>https://example.com</link>
<item>
<title>Test Headline One</title>
<pubDate>Sat, 15 Mar 2025 12:00:00 GMT</pubDate>
</item>
<item>
<title>Test Headline Two</title>
<pubDate>Sat, 15 Mar 2025 11:00:00 GMT</pubDate>
</item>
<item>
<title>Sports: Team Wins Championship</title>
<pubDate>Sat, 15 Mar 2025 10:00:00 GMT</pubDate>
</item>
</channel>
</rss>"""
@pytest.fixture
def mock_gutenberg_response():
"""Mock Project Gutenberg text response."""
return """Project Gutenberg's Collection, by Various
*** START OF SOME TEXT ***
This is a test poem with multiple lines
that should be parsed as stanzas.
Another stanza here with different content
and more lines to test the parsing logic.
Yet another stanza for variety
in the test data.
*** END OF SOME TEXT ***"""
@pytest.fixture
def mock_gutenberg_empty():
"""Mock Gutenberg response with no valid stanzas."""
return """Project Gutenberg's Collection
*** START OF TEXT ***
THIS IS ALL CAPS AND SHOULD BE SKIPPED
I.
*** END OF TEXT ***"""
@pytest.fixture
def mock_ntfy_message():
"""Mock ntfy.sh SSE message."""
return json.dumps(
{
"id": "test123",
"event": "message",
"title": "Test Title",
"message": "Test message body",
"time": 1234567890,
}
).encode()
@pytest.fixture
def mock_ntfy_keepalive():
"""Mock ntfy.sh keepalive message."""
return b'data: {"event":"keepalive"}\n\n'
@pytest.fixture
def mock_google_translate_response():
"""Mock Google Translate API response."""
return json.dumps(
[
[["Translated text", "Original text", None, 0.8], None, "en"],
None,
None,
[],
[],
[],
[],
]
)
@pytest.fixture
def mock_feedparser():
"""Create a mock feedparser.parse function."""
def _mock(data):
mock_result = MagicMock()
mock_result.bozo = False
mock_result.entries = [
{
"title": "Test Headline",
"published_parsed": (2025, 3, 15, 12, 0, 0, 0, 0, 0),
},
{
"title": "Another Headline",
"updated_parsed": (2025, 3, 15, 11, 0, 0, 0, 0, 0),
},
]
return mock_result
return _mock
@pytest.fixture
def mock_urllib_open(mock_feed_response):
"""Create a mock urllib.request.urlopen that returns feed data."""
def _mock(url):
mock_response = MagicMock()
mock_response.read.return_value = mock_feed_response
return mock_response
return _mock
@pytest.fixture
def sample_items():
"""Sample items as returned by fetch module (title, source, timestamp)."""
return [
("Headline One", "Test Source", "12:00"),
("Headline Two", "Another Source", "11:30"),
("Headline Three", "Third Source", "10:45"),
]
@pytest.fixture
def sample_config():
"""Sample config for testing."""
from engine.config import Config
return Config(
headline_limit=100,
feed_timeout=10,
mic_threshold_db=50,
mode="news",
firehose=False,
ntfy_topic="https://ntfy.sh/test/json",
ntfy_reconnect_delay=5,
message_display_secs=30,
font_dir="fonts",
font_path="",
font_index=0,
font_picker=False,
font_sz=60,
render_h=8,
ssaa=4,
scroll_dur=5.625,
frame_dt=0.05,
firehose_h=12,
grad_speed=0.08,
glitch_glyphs="░▒▓█▌▐",
kata_glyphs="ハミヒーウ",
script_fonts={},
)
@pytest.fixture
def poetry_config():
"""Sample config for poetry mode."""
from engine.config import Config
return Config(
headline_limit=100,
feed_timeout=10,
mic_threshold_db=50,
mode="poetry",
firehose=False,
ntfy_topic="https://ntfy.sh/test/json",
ntfy_reconnect_delay=5,
message_display_secs=30,
font_dir="fonts",
font_path="",
font_index=0,
font_picker=False,
font_sz=60,
render_h=8,
ssaa=4,
scroll_dur=5.625,
frame_dt=0.05,
firehose_h=12,
grad_speed=0.08,
glitch_glyphs="░▒▓█▌▐",
kata_glyphs="ハミヒーウ",
script_fonts={},
)
@pytest.fixture
def firehose_config():
"""Sample config with firehose enabled."""
from engine.config import Config
return Config(
headline_limit=100,
feed_timeout=10,
mic_threshold_db=50,
mode="news",
firehose=True,
ntfy_topic="https://ntfy.sh/test/json",
ntfy_reconnect_delay=5,
message_display_secs=30,
font_dir="fonts",
font_path="",
font_index=0,
font_picker=False,
font_sz=60,
render_h=8,
ssaa=4,
scroll_dur=5.625,
frame_dt=0.05,
firehose_h=12,
grad_speed=0.08,
glitch_glyphs="░▒▓█▌▐",
kata_glyphs="ハミヒーウ",
script_fonts={},
)

View File

@@ -7,6 +7,8 @@ import tempfile
from pathlib import Path from pathlib import Path
from unittest.mock import patch from unittest.mock import patch
import pytest
from engine import config from engine import config
@@ -160,3 +162,140 @@ class TestSetFontSelection:
config.set_font_selection(font_path=None, font_index=None) config.set_font_selection(font_path=None, font_index=None)
assert original_path == config.FONT_PATH assert original_path == config.FONT_PATH
assert original_index == config.FONT_INDEX assert original_index == config.FONT_INDEX
class TestConfigDataclass:
"""Tests for Config dataclass."""
def test_config_has_required_fields(self):
"""Config has all required fields."""
c = config.Config()
assert hasattr(c, "headline_limit")
assert hasattr(c, "feed_timeout")
assert hasattr(c, "mic_threshold_db")
assert hasattr(c, "mode")
assert hasattr(c, "firehose")
assert hasattr(c, "ntfy_topic")
assert hasattr(c, "ntfy_reconnect_delay")
assert hasattr(c, "message_display_secs")
assert hasattr(c, "font_dir")
assert hasattr(c, "font_path")
assert hasattr(c, "font_index")
assert hasattr(c, "font_picker")
assert hasattr(c, "font_sz")
assert hasattr(c, "render_h")
assert hasattr(c, "ssaa")
assert hasattr(c, "scroll_dur")
assert hasattr(c, "frame_dt")
assert hasattr(c, "firehose_h")
assert hasattr(c, "grad_speed")
assert hasattr(c, "glitch_glyphs")
assert hasattr(c, "kata_glyphs")
assert hasattr(c, "script_fonts")
def test_config_defaults(self):
"""Config has sensible defaults."""
c = config.Config()
assert c.headline_limit == 1000
assert c.feed_timeout == 10
assert c.mic_threshold_db == 50
assert c.mode == "news"
assert c.firehose is False
assert c.ntfy_reconnect_delay == 5
assert c.message_display_secs == 30
def test_config_is_immutable(self):
"""Config is frozen (immutable)."""
c = config.Config()
with pytest.raises(AttributeError):
c.headline_limit = 500 # type: ignore
def test_config_custom_values(self):
"""Config accepts custom values."""
c = config.Config(
headline_limit=500,
mode="poetry",
firehose=True,
ntfy_topic="https://ntfy.sh/test",
)
assert c.headline_limit == 500
assert c.mode == "poetry"
assert c.firehose is True
assert c.ntfy_topic == "https://ntfy.sh/test"
class TestConfigFromArgs:
"""Tests for Config.from_args method."""
def test_from_args_defaults(self):
"""from_args creates config with defaults from empty argv."""
c = config.Config.from_args(["prog"])
assert c.mode == "news"
assert c.firehose is False
assert c.font_picker is True
def test_from_args_poetry_mode(self):
"""from_args detects --poetry flag."""
c = config.Config.from_args(["prog", "--poetry"])
assert c.mode == "poetry"
def test_from_args_poetry_short_flag(self):
"""from_args detects -p short flag."""
c = config.Config.from_args(["prog", "-p"])
assert c.mode == "poetry"
def test_from_args_firehose(self):
"""from_args detects --firehose flag."""
c = config.Config.from_args(["prog", "--firehose"])
assert c.firehose is True
def test_from_args_no_font_picker(self):
"""from_args detects --no-font-picker flag."""
c = config.Config.from_args(["prog", "--no-font-picker"])
assert c.font_picker is False
def test_from_args_font_index(self):
"""from_args parses --font-index."""
c = config.Config.from_args(["prog", "--font-index", "3"])
assert c.font_index == 3
class TestGetSetConfig:
"""Tests for get_config and set_config functions."""
def test_get_config_returns_config(self):
"""get_config returns a Config instance."""
c = config.get_config()
assert isinstance(c, config.Config)
def test_set_config_allows_injection(self):
"""set_config allows injecting a custom config."""
custom = config.Config(mode="poetry", headline_limit=100)
config.set_config(custom)
assert config.get_config().mode == "poetry"
assert config.get_config().headline_limit == 100
def test_set_config_then_get_config(self):
"""set_config followed by get_config returns the set config."""
original = config.get_config()
test_config = config.Config(headline_limit=42)
config.set_config(test_config)
result = config.get_config()
assert result.headline_limit == 42
config.set_config(original)
class TestPlatformFontPaths:
"""Tests for platform font path detection."""
def test_get_platform_font_paths_returns_dict(self):
"""_get_platform_font_paths returns a dictionary."""
fonts = config._get_platform_font_paths()
assert isinstance(fonts, dict)
def test_platform_font_paths_common_languages(self):
"""Common language font mappings exist."""
fonts = config._get_platform_font_paths()
common = {"ja", "zh-cn", "ko", "ru", "ar", "hi"}
found = set(fonts.keys()) & common
assert len(found) > 0