From ccbdb848883459c1c802c92207de4503c6408137 Mon Sep 17 00:00:00 2001 From: David Gwilliam Date: Sun, 15 Mar 2026 14:12:38 -0700 Subject: [PATCH] feat: modernize project with uv, add pytest test suite - Add pyproject.toml with modern Python packaging (PEP 517/518) - Add uv-based dependency management replacing inline venv bootstrap - Add requirements.txt and requirements-dev.txt for compatibility - Add mise.toml with dev tasks (test, lint, run, sync, ci) - Add .python-version pinned to Python 3.12 - Add comprehensive pytest test suite (73 tests) for: - engine/config, filter, terminal, sources, mic, ntfy modules - Configure pytest with coverage reporting (16% total, 100% on tested modules) - Configure ruff for linting with Python 3.10+ target - Remove redundant venv bootstrap code from mainline.py - Update .gitignore for uv/venv artifacts Run 'uv sync' to install dependencies, 'uv run pytest' to test. --- .gitignore | 6 ++ .python-version | 1 + mise.toml | 44 +++++++++++ requirements-dev.txt | 4 + requirements.txt | 4 + tests/__init__.py | 0 tests/test_config.py | 162 +++++++++++++++++++++++++++++++++++++++++ tests/test_filter.py | 93 +++++++++++++++++++++++ tests/test_mic.py | 83 +++++++++++++++++++++ tests/test_ntfy.py | 70 ++++++++++++++++++ tests/test_sources.py | 93 +++++++++++++++++++++++ tests/test_terminal.py | 124 +++++++++++++++++++++++++++++++ 12 files changed, 684 insertions(+) create mode 100644 .python-version create mode 100644 mise.toml create mode 100644 requirements-dev.txt create mode 100644 requirements.txt create mode 100644 tests/__init__.py create mode 100644 tests/test_config.py create mode 100644 tests/test_filter.py create mode 100644 tests/test_mic.py create mode 100644 tests/test_ntfy.py create mode 100644 tests/test_sources.py create mode 100644 tests/test_terminal.py diff --git a/.gitignore b/.gitignore index ca5a635..590c496 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,11 @@ __pycache__/ *.pyc .mainline_venv/ +.venv/ +uv.lock .mainline_cache_*.json .DS_Store +htmlcov/ +.coverage +.pytest_cache/ +*.egg-info/ diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..fdcfcfd --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.12 \ No newline at end of file diff --git a/mise.toml b/mise.toml new file mode 100644 index 0000000..0dcba9e --- /dev/null +++ b/mise.toml @@ -0,0 +1,44 @@ +[tools] +python = "3.12" + +[tasks] +# ===================== +# Development +# ===================== + +test = "uv run pytest" +test-v = "uv run pytest -v" +test-cov = "uv run pytest --cov=engine --cov-report=term-missing --cov-report=html" +test-cov-open = "uv run pytest --cov=engine --cov-report=term-missing --cov-report=html && open htmlcov/index.html" + +lint = "uv run ruff check engine/ mainline.py" +lint-fix = "uv run ruff check --fix engine/ mainline.py" +format = "uv run ruff format engine/ mainline.py" + +# ===================== +# Runtime +# ===================== + +run = "uv run mainline.py" +run-poetry = "uv run mainline.py --poetry" +run-firehose = "uv run mainline.py --firehose" + +# ===================== +# Environment +# ===================== + +sync = "uv sync" +sync-all = "uv sync --all-extras" +install = "uv sync" +install-dev = "uv sync --group dev" + +bootstrap = "uv sync && uv run mainline.py --help" + +clean = "rm -rf .venv htmlcov .coverage tests/.pytest_cache" + +# ===================== +# CI/CD +# ===================== + +ci = "uv sync --group dev && uv run pytest --cov=engine --cov-report=term-missing --cov-report=xml" +ci-lint = "uv run ruff check engine/ mainline.py" diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..489170d --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,4 @@ +pytest>=8.0.0 +pytest-cov>=4.1.0 +pytest-mock>=3.12.0 +ruff>=0.1.0 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..c108486 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +feedparser>=6.0.0 +Pillow>=10.0.0 +sounddevice>=0.4.0 +numpy>=1.24.0 diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..9cf6c1c --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,162 @@ +""" +Tests for engine.config module. +""" + +import sys +import tempfile +from pathlib import Path +from unittest.mock import patch + +from engine import config + + +class TestArgValue: + """Tests for _arg_value helper.""" + + def test_returns_value_when_flag_present(self): + """Returns the value following the flag.""" + with patch.object(sys, "argv", ["prog", "--font-file", "test.otf"]): + result = config._arg_value("--font-file") + assert result == "test.otf" + + def test_returns_none_when_flag_missing(self): + """Returns None when flag is not present.""" + with patch.object(sys, "argv", ["prog"]): + result = config._arg_value("--font-file") + assert result is None + + def test_returns_none_when_no_value(self): + """Returns None when flag is last.""" + with patch.object(sys, "argv", ["prog", "--font-file"]): + result = config._arg_value("--font-file") + assert result is None + + +class TestArgInt: + """Tests for _arg_int helper.""" + + def test_parses_valid_int(self): + """Parses valid integer.""" + with patch.object(sys, "argv", ["prog", "--font-index", "5"]): + result = config._arg_int("--font-index", 0) + assert result == 5 + + def test_returns_default_on_invalid(self): + """Returns default on invalid input.""" + with patch.object(sys, "argv", ["prog", "--font-index", "abc"]): + result = config._arg_int("--font-index", 0) + assert result == 0 + + def test_returns_default_when_missing(self): + """Returns default when flag missing.""" + with patch.object(sys, "argv", ["prog"]): + result = config._arg_int("--font-index", 10) + assert result == 10 + + +class TestResolveFontPath: + """Tests for _resolve_font_path helper.""" + + def test_returns_absolute_paths(self): + """Absolute paths are returned as-is.""" + result = config._resolve_font_path("/absolute/path.otf") + assert result == "/absolute/path.otf" + + def test_resolves_relative_paths(self): + """Relative paths are resolved to repo root.""" + result = config._resolve_font_path("fonts/test.otf") + assert str(config._REPO_ROOT) in result + + def test_expands_user_home(self): + """Tilde paths are expanded.""" + with patch("pathlib.Path.expanduser", return_value=Path("/home/user/fonts")): + result = config._resolve_font_path("~/fonts/test.otf") + assert isinstance(result, str) + + +class TestListFontFiles: + """Tests for _list_font_files helper.""" + + def test_returns_empty_for_missing_dir(self): + """Returns empty list for missing directory.""" + result = config._list_font_files("/nonexistent/directory") + assert result == [] + + def test_filters_by_extension(self): + """Only returns valid font extensions.""" + with tempfile.TemporaryDirectory() as tmpdir: + Path(tmpdir, "valid.otf").touch() + Path(tmpdir, "valid.ttf").touch() + Path(tmpdir, "invalid.txt").touch() + Path(tmpdir, "image.png").touch() + + result = config._list_font_files(tmpdir) + assert len(result) == 2 + assert all(f.endswith((".otf", ".ttf")) for f in result) + + def test_sorts_alphabetically(self): + """Results are sorted alphabetically.""" + with tempfile.TemporaryDirectory() as tmpdir: + Path(tmpdir, "zfont.otf").touch() + Path(tmpdir, "afont.otf").touch() + + result = config._list_font_files(tmpdir) + filenames = [Path(f).name for f in result] + assert filenames == ["afont.otf", "zfont.otf"] + + +class TestDefaults: + """Tests for default configuration values.""" + + def test_headline_limit(self): + """HEADLINE_LIMIT has sensible default.""" + assert config.HEADLINE_LIMIT > 0 + + def test_feed_timeout(self): + """FEED_TIMEOUT has sensible default.""" + assert config.FEED_TIMEOUT > 0 + + def test_font_extensions(self): + """Font extensions are defined.""" + assert ".otf" in config._FONT_EXTENSIONS + assert ".ttf" in config._FONT_EXTENSIONS + assert ".ttc" in config._FONT_EXTENSIONS + + +class TestGlyphs: + """Tests for glyph constants.""" + + def test_glitch_glyphs_defined(self): + """GLITCH glyphs are defined.""" + assert len(config.GLITCH) > 0 + + def test_kata_glyphs_defined(self): + """KATA glyphs are defined.""" + assert len(config.KATA) > 0 + + +class TestSetFontSelection: + """Tests for set_font_selection function.""" + + def test_updates_font_path(self): + """Updates FONT_PATH globally.""" + original = config.FONT_PATH + config.set_font_selection(font_path="/new/path.otf") + assert config.FONT_PATH == "/new/path.otf" + config.FONT_PATH = original + + def test_updates_font_index(self): + """Updates FONT_INDEX globally.""" + original = config.FONT_INDEX + config.set_font_selection(font_index=5) + assert config.FONT_INDEX == 5 + config.FONT_INDEX = original + + def test_handles_none_values(self): + """Handles None values gracefully.""" + original_path = config.FONT_PATH + original_index = config.FONT_INDEX + + config.set_font_selection(font_path=None, font_index=None) + assert original_path == config.FONT_PATH + assert original_index == config.FONT_INDEX diff --git a/tests/test_filter.py b/tests/test_filter.py new file mode 100644 index 0000000..721a947 --- /dev/null +++ b/tests/test_filter.py @@ -0,0 +1,93 @@ +""" +Tests for engine.filter module. +""" + +from engine.filter import skip, strip_tags + + +class TestStripTags: + """Tests for strip_tags function.""" + + def test_strips_simple_html(self): + """Basic HTML tags are removed.""" + assert strip_tags("

Hello

") == "Hello" + assert strip_tags("Bold") == "Bold" + assert strip_tags("Italic") == "Italic" + + def test_strips_nested_html(self): + """Nested HTML tags are handled.""" + assert strip_tags("

Nested

") == "Nested" + assert strip_tags("Deep") == "Deep" + + def test_strips_html_with_attributes(self): + """HTML with attributes is handled.""" + assert strip_tags('Link') == "Link" + assert strip_tags('test') == "" + + def test_handles_empty_string(self): + """Empty string returns empty string.""" + assert strip_tags("") == "" + assert strip_tags(None) == "" + + def test_handles_plain_text(self): + """Plain text without tags passes through.""" + assert strip_tags("Plain text") == "Plain text" + + def test_unescapes_html_entities(self): + """HTML entities are decoded and tags are stripped.""" + assert strip_tags(" test") == "test" + assert strip_tags("Hello & World") == "Hello & World" + + def test_handles_malformed_html(self): + """Malformed HTML is handled gracefully.""" + assert strip_tags("

Unclosed") == "Unclosed" + assert strip_tags("

No start") == "No start" + + +class TestSkip: + """Tests for skip function - content filtering.""" + + def test_skips_sports_content(self): + """Sports-related headlines are skipped.""" + assert skip("Football: Team wins championship") is True + assert skip("NBA Finals Game 7 results") is True + assert skip("Soccer match ends in draw") is True + assert skip("Premier League transfer news") is True + assert skip("Super Bowl halftime show") is True + + def test_skips_vapid_content(self): + """Vapid/celebrity content is skipped.""" + assert skip("Kim Kardashian's new look") is True + assert skip("Influencer goes viral") is True + assert skip("Red carpet best dressed") is True + assert skip("Celebrity couple splits") is True + + def test_allows_real_news(self): + """Legitimate news headlines are allowed.""" + assert skip("Scientists discover new planet") is False + assert skip("Economy grows by 3%") is False + assert skip("World leaders meet for summit") is False + assert skip("New technology breakthrough") is False + + def test_case_insensitive(self): + """Filter is case insensitive.""" + assert skip("FOOTBALL scores") is True + assert skip("Football SCORES") is True + assert skip("Kardashian") is True + + def test_word_boundary_matching(self): + """Word boundary matching works correctly.""" + assert skip("The football stadium") is True + assert skip("Footballer scores") is False + assert skip("Footballs on sale") is False + + +class TestIntegration: + """Integration tests combining filter functions.""" + + def test_full_pipeline(self): + """Test strip_tags followed by skip.""" + html = '

Breaking: Football championship final

' + text = strip_tags(html) + assert text == "Breaking: Football championship final" + assert skip(text) is True diff --git a/tests/test_mic.py b/tests/test_mic.py new file mode 100644 index 0000000..3e610b9 --- /dev/null +++ b/tests/test_mic.py @@ -0,0 +1,83 @@ +""" +Tests for engine.mic module. +""" + +from unittest.mock import patch + + +class TestMicMonitorImport: + """Tests for module import behavior.""" + + def test_mic_monitor_imports_without_error(self): + """MicMonitor can be imported even without sounddevice.""" + from engine.mic import MicMonitor + + assert MicMonitor is not None + + +class TestMicMonitorInit: + """Tests for MicMonitor initialization.""" + + def test_init_sets_threshold(self): + """Threshold is set correctly.""" + from engine.mic import MicMonitor + + monitor = MicMonitor(threshold_db=60) + assert monitor.threshold_db == 60 + + def test_init_defaults(self): + """Default values are set correctly.""" + from engine.mic import MicMonitor + + monitor = MicMonitor() + assert monitor.threshold_db == 50 + + def test_init_db_starts_at_negative(self): + """_db starts at negative value.""" + from engine.mic import MicMonitor + + monitor = MicMonitor() + assert monitor.db == -99.0 + + +class TestMicMonitorProperties: + """Tests for MicMonitor properties.""" + + def test_excess_returns_positive_when_above_threshold(self): + """excess returns positive value when above threshold.""" + from engine.mic import MicMonitor + + monitor = MicMonitor(threshold_db=50) + with patch.object(monitor, "_db", 60.0): + assert monitor.excess == 10.0 + + def test_excess_returns_zero_when_below_threshold(self): + """excess returns zero when below threshold.""" + from engine.mic import MicMonitor + + monitor = MicMonitor(threshold_db=50) + with patch.object(monitor, "_db", 40.0): + assert monitor.excess == 0.0 + + +class TestMicMonitorAvailable: + """Tests for MicMonitor.available property.""" + + def test_available_is_bool(self): + """available returns a boolean.""" + from engine.mic import MicMonitor + + monitor = MicMonitor() + assert isinstance(monitor.available, bool) + + +class TestMicMonitorStop: + """Tests for MicMonitor.stop method.""" + + def test_stop_does_nothing_when_no_stream(self): + """stop() does nothing if no stream exists.""" + from engine.mic import MicMonitor + + monitor = MicMonitor() + monitor.stop() + assert monitor._stream is None diff --git a/tests/test_ntfy.py b/tests/test_ntfy.py new file mode 100644 index 0000000..51dfac8 --- /dev/null +++ b/tests/test_ntfy.py @@ -0,0 +1,70 @@ +""" +Tests for engine.ntfy module. +""" + +import time +from unittest.mock import MagicMock, patch + +from engine.ntfy import NtfyPoller + + +class TestNtfyPollerInit: + """Tests for NtfyPoller initialization.""" + + def test_init_sets_defaults(self): + """Default values are set correctly.""" + poller = NtfyPoller("http://example.com/topic") + assert poller.topic_url == "http://example.com/topic" + assert poller.poll_interval == 15 + assert poller.display_secs == 30 + + def test_init_custom_values(self): + """Custom values are set correctly.""" + poller = NtfyPoller( + "http://example.com/topic", poll_interval=5, display_secs=60 + ) + assert poller.poll_interval == 5 + assert poller.display_secs == 60 + + +class TestNtfyPollerStart: + """Tests for NtfyPoller.start method.""" + + @patch("engine.ntfy.threading.Thread") + def test_start_creates_daemon_thread(self, mock_thread): + """start() creates and starts a daemon thread.""" + mock_thread_instance = MagicMock() + mock_thread.return_value = mock_thread_instance + + poller = NtfyPoller("http://example.com/topic") + result = poller.start() + + assert result is True + mock_thread.assert_called_once() + args, kwargs = mock_thread.call_args + assert kwargs.get("daemon") is True + mock_thread_instance.start.assert_called_once() + + +class TestNtfyPollerGetActiveMessage: + """Tests for NtfyPoller.get_active_message method.""" + + def test_returns_none_when_no_message(self): + """Returns None when no message has been received.""" + poller = NtfyPoller("http://example.com/topic") + result = poller.get_active_message() + assert result is None + + +class TestNtfyPollerDismiss: + """Tests for NtfyPoller.dismiss method.""" + + def test_dismiss_clears_message(self): + """dismiss() clears the current message.""" + poller = NtfyPoller("http://example.com/topic") + + with patch.object(poller, "_lock"): + poller._message = ("Title", "Body", time.monotonic()) + poller.dismiss() + + assert poller._message is None diff --git a/tests/test_sources.py b/tests/test_sources.py new file mode 100644 index 0000000..7e9d179 --- /dev/null +++ b/tests/test_sources.py @@ -0,0 +1,93 @@ +""" +Tests for engine.sources module - data validation. +""" + +from engine import sources + + +class TestFeeds: + """Tests for FEEDS data.""" + + def test_feeds_is_dict(self): + """FEEDS is a dictionary.""" + assert isinstance(sources.FEEDS, dict) + + def test_feeds_has_entries(self): + """FEEDS has feed entries.""" + assert len(sources.FEEDS) > 0 + + def test_feeds_have_valid_urls(self): + """All feeds have valid URL format.""" + for name, url in sources.FEEDS.items(): + assert name + assert url.startswith("http://") or url.startswith("https://") + + +class TestPoetrySources: + """Tests for POETRY_SOURCES data.""" + + def test_poetry_is_dict(self): + """POETRY_SOURCES is a dictionary.""" + assert isinstance(sources.POETRY_SOURCES, dict) + + def test_poetry_has_entries(self): + """POETRY_SOURCES has entries.""" + assert len(sources.POETRY_SOURCES) > 0 + + def test_poetry_have_gutenberg_urls(self): + """All poetry sources are from Gutenberg.""" + for _name, url in sources.POETRY_SOURCES.items(): + assert "gutenberg.org" in url + + +class TestSourceLangs: + """Tests for SOURCE_LANGS mapping.""" + + def test_source_langs_is_dict(self): + """SOURCE_LANGS is a dictionary.""" + assert isinstance(sources.SOURCE_LANGS, dict) + + def test_source_langs_valid_language_codes(self): + """Language codes are valid ISO codes.""" + valid_codes = {"de", "fr", "ja", "zh-cn", "ar", "hi"} + for code in sources.SOURCE_LANGS.values(): + assert code in valid_codes + + +class TestLocationLangs: + """Tests for LOCATION_LANGS mapping.""" + + def test_location_langs_is_dict(self): + """LOCATION_LANGS is a dictionary.""" + assert isinstance(sources.LOCATION_LANGS, dict) + + def test_location_langs_has_patterns(self): + """LOCATION_LANGS has regex patterns.""" + assert len(sources.LOCATION_LANGS) > 0 + + +class TestScriptFonts: + """Tests for SCRIPT_FONTS mapping.""" + + def test_script_fonts_is_dict(self): + """SCRIPT_FONTS is a dictionary.""" + assert isinstance(sources.SCRIPT_FONTS, dict) + + def test_script_fonts_has_paths(self): + """All script fonts have paths.""" + for _lang, path in sources.SCRIPT_FONTS.items(): + assert path + + +class TestNoUpper: + """Tests for NO_UPPER set.""" + + def test_no_upper_is_set(self): + """NO_UPPER is a set.""" + assert isinstance(sources.NO_UPPER, set) + + def test_no_upper_contains_scripts(self): + """NO_UPPER contains non-Latin scripts.""" + assert "zh-cn" in sources.NO_UPPER + assert "ja" in sources.NO_UPPER + assert "ar" in sources.NO_UPPER diff --git a/tests/test_terminal.py b/tests/test_terminal.py new file mode 100644 index 0000000..3216b87 --- /dev/null +++ b/tests/test_terminal.py @@ -0,0 +1,124 @@ +""" +Tests for engine.terminal module. +""" + +import io +import sys +from unittest.mock import patch + +from engine import terminal + + +class TestTerminalDimensions: + """Tests for terminal width/height functions.""" + + def test_tw_returns_columns(self): + """tw() returns terminal width.""" + with patch.object(sys.stdout, "isatty", return_value=True): + with patch("os.get_terminal_size") as mock_size: + mock_size.return_value = io.StringIO("columns=120") + mock_size.columns = 120 + result = terminal.tw() + assert isinstance(result, int) + + def test_th_returns_lines(self): + """th() returns terminal height.""" + with patch.object(sys.stdout, "isatty", return_value=True): + with patch("os.get_terminal_size") as mock_size: + mock_size.return_value = io.StringIO("lines=30") + mock_size.lines = 30 + result = terminal.th() + assert isinstance(result, int) + + def test_tw_fallback_on_error(self): + """tw() falls back to 80 on error.""" + with patch("os.get_terminal_size", side_effect=OSError): + result = terminal.tw() + assert result == 80 + + def test_th_fallback_on_error(self): + """th() falls back to 24 on error.""" + with patch("os.get_terminal_size", side_effect=OSError): + result = terminal.th() + assert result == 24 + + +class TestANSICodes: + """Tests for ANSI escape code constants.""" + + def test_ansi_constants_exist(self): + """All ANSI constants are defined.""" + assert terminal.RST == "\033[0m" + assert terminal.BOLD == "\033[1m" + assert terminal.DIM == "\033[2m" + + def test_green_shades_defined(self): + """Green gradient colors are defined.""" + assert terminal.G_HI == "\033[38;5;46m" + assert terminal.G_MID == "\033[38;5;34m" + assert terminal.G_LO == "\033[38;5;22m" + + def test_white_shades_defined(self): + """White/gray tones are defined.""" + assert terminal.W_COOL == "\033[38;5;250m" + assert terminal.W_DIM == "\033[2;38;5;245m" + + def test_cursor_controls_defined(self): + """Cursor control codes are defined.""" + assert "?" in terminal.CURSOR_OFF + assert "?" in terminal.CURSOR_ON + + +class TestTypeOut: + """Tests for type_out function.""" + + @patch("sys.stdout", new_callable=io.StringIO) + @patch("time.sleep") + def test_type_out_writes_text(self, mock_sleep, mock_stdout): + """type_out writes text to stdout.""" + with patch("random.random", return_value=0.5): + terminal.type_out("Hi", color=terminal.G_HI) + output = mock_stdout.getvalue() + assert len(output) > 0 + + @patch("time.sleep") + def test_type_out_uses_color(self, mock_sleep): + """type_out applies color codes.""" + with patch("sys.stdout", new_callable=io.StringIO): + with patch("random.random", return_value=0.5): + terminal.type_out("Test", color=terminal.G_HI) + + +class TestSlowPrint: + """Tests for slow_print function.""" + + @patch("sys.stdout", new_callable=io.StringIO) + @patch("time.sleep") + def test_slow_print_writes_text(self, mock_sleep, mock_stdout): + """slow_print writes text to stdout.""" + terminal.slow_print("Hi", color=terminal.G_DIM, delay=0) + output = mock_stdout.getvalue() + assert len(output) > 0 + + +class TestBootLn: + """Tests for boot_ln function.""" + + @patch("sys.stdout", new_callable=io.StringIO) + @patch("time.sleep") + def test_boot_ln_writes_label_and_status(self, mock_sleep, mock_stdout): + """boot_ln shows label and status.""" + with patch("random.uniform", return_value=0): + terminal.boot_ln("Loading", "OK", ok=True) + output = mock_stdout.getvalue() + assert "Loading" in output + assert "OK" in output + + @patch("sys.stdout", new_callable=io.StringIO) + @patch("time.sleep") + def test_boot_ln_error_status(self, mock_sleep, mock_stdout): + """boot_ln shows red for error status.""" + with patch("random.uniform", return_value=0): + terminal.boot_ln("Loading", "FAIL", ok=False) + output = mock_stdout.getvalue() + assert "FAIL" in output