diff --git a/tests/integration/test_browser_launch.py b/tests/integration/test_browser_launch.py index 2b44e06..b783cf5 100644 --- a/tests/integration/test_browser_launch.py +++ b/tests/integration/test_browser_launch.py @@ -1,7 +1,9 @@ +from unittest.mock import AsyncMock, patch + import pytest -from unittest.mock import AsyncMock, patch, MagicMock + from src.scraper.browser_factory import create_persistent_context -from src.core.config import BrowserConfig + @pytest.mark.asyncio async def test_browser_factory_respects_headless_true(): @@ -9,19 +11,19 @@ async def test_browser_factory_respects_headless_true(): with patch("src.scraper.browser_factory.async_playwright") as mock_ap: mock_p = AsyncMock() mock_ap.return_value.__aenter__.return_value = mock_p - + mock_browser_type = AsyncMock() mock_p.chromium = mock_browser_type - + mock_context = AsyncMock() mock_browser_type.launch_persistent_context.return_value = mock_context - + async with create_persistent_context( user_data_dir="test_dir", headless=True ) as (p, context): pass - + args, kwargs = mock_browser_type.launch_persistent_context.call_args assert kwargs["headless"] is True @@ -33,12 +35,12 @@ async def test_browser_factory_respects_headless_false(): mock_browser_type = AsyncMock() mock_p.chromium = mock_browser_type mock_browser_type.launch_persistent_context.return_value = AsyncMock() - + async with create_persistent_context( user_data_dir="test_dir", headless=False ) as (p, context): pass - + args, kwargs = mock_browser_type.launch_persistent_context.call_args assert kwargs["headless"] is False diff --git a/tests/integration/test_config_tabs.py b/tests/integration/test_config_tabs.py index 784d9ae..5a51901 100644 --- a/tests/integration/test_config_tabs.py +++ b/tests/integration/test_config_tabs.py @@ -1,14 +1,15 @@ -import yaml -from pathlib import Path -from unittest.mock import MagicMock, patch +from unittest.mock import patch + import pytest +import yaml +from textual.widgets import Checkbox, Input + from src.tui.app import YappyApp from src.tui.screens.config_editor import ConfigEditorScreen -from textual.widgets import Checkbox, Input, Static + @pytest.mark.asyncio async def test_config_editor_saves_headless_toggle(tmp_path, monkeypatch): - from unittest.mock import patch import os import textwrap config_file = tmp_path / "config.yaml" @@ -49,12 +50,11 @@ async def test_config_editor_saves_headless_toggle(tmp_path, monkeypatch): assert saved_config["browser"]["headless"] is True @pytest.mark.asyncio async def test_config_editor_saves_api_key_to_env(tmp_path, monkeypatch): - from unittest.mock import patch import os config_file = tmp_path / "config.yaml" config_file.write_text("browser:\n headless: true\n") env_file = tmp_path / ".env" - + monkeypatch.setattr("src.core.paths.config_file", lambda: config_file) monkeypatch.setattr("src.core.paths.env_file", lambda: env_file) with patch.dict(os.environ, {"GEMINI_API_KEY": "old-key"}): diff --git a/tests/integration/test_onboarding_flow.py b/tests/integration/test_onboarding_flow.py index a674a9c..8345f64 100644 --- a/tests/integration/test_onboarding_flow.py +++ b/tests/integration/test_onboarding_flow.py @@ -1,16 +1,15 @@ import os -from pathlib import Path -from unittest.mock import MagicMock, patch +from unittest.mock import patch + import pytest +from textual.widgets import Input + from src.tui.app import YappyApp -from src.tui.screens.onboarding import OnboardingScreen from src.tui.screens.dashboard import DashboardScreen -from textual.widgets import Input + @pytest.mark.asyncio async def test_onboarding_flow_saves_api_key(tmp_path, monkeypatch): - from unittest.mock import patch - import os # Mock paths to use tmp_path monkeypatch.setattr("src.core.paths.env_file", lambda: tmp_path / ".env") monkeypatch.setattr("src.core.paths.config_file", lambda: tmp_path / "config.yaml") @@ -57,10 +56,10 @@ async def test_onboarding_flow_saves_api_key(tmp_path, monkeypatch): async def test_app_skips_onboarding_if_key_exists(tmp_path, monkeypatch): env_file = tmp_path / ".env" env_file.write_text("GEMINI_API_KEY=existing-key") - + monkeypatch.setattr("src.core.paths.env_file", lambda: env_file) monkeypatch.setattr("src.core.paths.config_file", lambda: tmp_path / "config.yaml") - + app = YappyApp() async with app.run_test() as pilot: assert isinstance(app.screen, DashboardScreen) diff --git a/tests/integration/test_tui_smoke.py b/tests/integration/test_tui_smoke.py index a28cba4..6ed568f 100644 --- a/tests/integration/test_tui_smoke.py +++ b/tests/integration/test_tui_smoke.py @@ -34,8 +34,8 @@ async def test_toggle_mode(self): @pytest.mark.asyncio async def test_navigation_consistency(self): - from src.tui.screens.config_editor import ConfigEditorScreen from src.tui.screens.activity_log import ActivityLogScreen + from src.tui.screens.config_editor import ConfigEditorScreen app = YappyApp(skip_onboarding=True) async with app.run_test() as pilot: @@ -51,7 +51,7 @@ async def test_navigation_consistency(self): await pilot.press("escape") assert isinstance(app.screen, DashboardScreen) - # Test 'q' on sub-screen (should also return to dashboard if configured that way, + # Test 'q' on sub-screen (should also return to dashboard if configured that way, # but we unified 'q' to 'Back' on sub-screens in Task 3) await pilot.press("c") await pilot.press("q") diff --git a/tests/unit/test_ai_config.py b/tests/unit/test_ai_config.py new file mode 100644 index 0000000..ae3e262 --- /dev/null +++ b/tests/unit/test_ai_config.py @@ -0,0 +1,109 @@ +"""Tests for AIConfig feature coverage.""" +from __future__ import annotations + +import pytest +from pydantic import ValidationError + +from src.core.config import AIConfig + + +class TestAIConfigDefaults: + """Test AIConfig default values.""" + + def test_default_model_name(self) -> None: + """Default model should be gemini-3-flash-preview.""" + cfg = AIConfig() + assert cfg.model_name == "gemini-3-flash-preview" + + def test_default_temperature(self) -> None: + """Default temperature should be 0.85.""" + cfg = AIConfig() + assert cfg.temperature == 0.85 + + def test_default_max_output_tokens(self) -> None: + """Default max_output_tokens should be 300.""" + cfg = AIConfig() + assert cfg.max_output_tokens == 300 + + def test_default_personality_prefix_empty(self) -> None: + """Default personality_prefix should be empty string.""" + cfg = AIConfig() + assert cfg.personality_prefix == "" + + def test_default_persona_preset(self) -> None: + """Default persona_preset should be insightful_expert.""" + cfg = AIConfig() + assert cfg.persona_preset == "insightful_expert" + + +class TestAIConfigValidation: + """Test AIConfig validation rules.""" + + def test_temperature_min_valid(self) -> None: + """Temperature of 0.0 should be valid.""" + cfg = AIConfig(temperature=0.0) + assert cfg.temperature == 0.0 + + def test_temperature_max_valid(self) -> None: + """Temperature of 2.0 should be valid.""" + cfg = AIConfig(temperature=2.0) + assert cfg.temperature == 2.0 + + def test_temperature_below_min_invalid(self) -> None: + """Temperature below 0.0 should raise ValidationError.""" + with pytest.raises(ValidationError): + AIConfig(temperature=-0.1) + + def test_temperature_above_max_invalid(self) -> None: + """Temperature above 2.0 should raise ValidationError.""" + with pytest.raises(ValidationError): + AIConfig(temperature=2.1) + + def test_max_tokens_min_valid(self) -> None: + """max_output_tokens of 50 should be valid.""" + cfg = AIConfig(max_output_tokens=50) + assert cfg.max_output_tokens == 50 + + def test_max_tokens_max_valid(self) -> None: + """max_output_tokens of 1000 should be valid.""" + cfg = AIConfig(max_output_tokens=1000) + assert cfg.max_output_tokens == 1000 + + def test_max_tokens_below_min_invalid(self) -> None: + """max_output_tokens below 50 should raise ValidationError.""" + with pytest.raises(ValidationError): + AIConfig(max_output_tokens=49) + + def test_max_tokens_above_max_invalid(self) -> None: + """max_output_tokens above 1000 should raise ValidationError.""" + with pytest.raises(ValidationError): + AIConfig(max_output_tokens=1001) + + +class TestAIConfigCustomValues: + """Test AIConfig accepts custom values.""" + + def test_custom_model_name(self) -> None: + """Should accept custom model name.""" + cfg = AIConfig(model_name="gemini-pro") + assert cfg.model_name == "gemini-pro" + + def test_custom_personality_prefix(self) -> None: + """Should accept custom personality_prefix.""" + cfg = AIConfig(personality_prefix="Be concise and witty.") + assert cfg.personality_prefix == "Be concise and witty." + + def test_custom_persona_preset(self) -> None: + """Should accept custom persona_preset.""" + cfg = AIConfig(persona_preset="supportive_cheerleader") + assert cfg.persona_preset == "supportive_cheerleader" + + +class TestAIConfigImmutable: + """Test AIConfig is immutable (frozen).""" + + def test_config_is_frozen(self) -> None: + """AIConfig should be frozen (immutable).""" + cfg = AIConfig() + with pytest.raises((ValidationError, TypeError)): + cfg.temperature = 0.5 # type: ignore[misc] diff --git a/tests/unit/test_browser_config.py b/tests/unit/test_browser_config.py new file mode 100644 index 0000000..f6c1a05 --- /dev/null +++ b/tests/unit/test_browser_config.py @@ -0,0 +1,61 @@ +"""Tests for BrowserConfig feature coverage.""" +from __future__ import annotations + +import pytest +from pydantic import ValidationError + +from src.core.config import BrowserConfig + + +class TestBrowserConfigDefaults: + """Test BrowserConfig default values.""" + + def test_default_headless(self) -> None: + """Default headless should be False (visible browser).""" + cfg = BrowserConfig() + assert cfg.headless is False + + def test_default_user_data_dir(self) -> None: + """Default user_data_dir should be empty string.""" + cfg = BrowserConfig() + assert cfg.user_data_dir == "" + + def test_default_viewport_width(self) -> None: + """Default viewport_width should be 1920.""" + cfg = BrowserConfig() + assert cfg.viewport_width == 1920 + + def test_default_viewport_height(self) -> None: + """Default viewport_height should be 1080.""" + cfg = BrowserConfig() + assert cfg.viewport_height == 1080 + + +class TestBrowserConfigCustomValues: + """Test BrowserConfig accepts custom values.""" + + def test_custom_headless_true(self) -> None: + """Should accept headless=True.""" + cfg = BrowserConfig(headless=True) + assert cfg.headless is True + + def test_custom_user_data_dir(self) -> None: + """Should accept custom user_data_dir.""" + cfg = BrowserConfig(user_data_dir="/custom/path") + assert cfg.user_data_dir == "/custom/path" + + def test_custom_viewport(self) -> None: + """Should accept custom viewport dimensions.""" + cfg = BrowserConfig(viewport_width=1280, viewport_height=720) + assert cfg.viewport_width == 1280 + assert cfg.viewport_height == 720 + + +class TestBrowserConfigImmutable: + """Test BrowserConfig is immutable (frozen).""" + + def test_config_is_frozen(self) -> None: + """BrowserConfig should be frozen (immutable).""" + cfg = BrowserConfig() + with pytest.raises((ValidationError, TypeError)): + cfg.headless = True # type: ignore[misc] diff --git a/tests/unit/test_comment_generator.py b/tests/unit/test_comment_generator.py index 13d08af..06892fb 100644 --- a/tests/unit/test_comment_generator.py +++ b/tests/unit/test_comment_generator.py @@ -1,10 +1,8 @@ from __future__ import annotations -from datetime import datetime, timezone +from datetime import UTC, datetime from unittest.mock import MagicMock -import pytest - from src.ai.comment_generator import CommentGenerator from src.ai.models import GeneratedComment from src.scraper.models import LinkedInPost @@ -16,7 +14,7 @@ def make_post(text: str = "We just closed our Series A round.") -> LinkedInPost: author_name="Jane Doe", author_profile_url="https://linkedin.com/in/janedoe", post_text=text, - scraped_at=datetime.now(timezone.utc), + scraped_at=datetime.now(UTC), source_target="AI startup fundraising", ) diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index e239e14..f00a517 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -1,15 +1,19 @@ from __future__ import annotations import os -import tempfile from pathlib import Path import pytest import yaml - from pydantic import ValidationError -from src.core.config import AppConfig, BrowserConfig, LimitsConfig, TargetConfig, load_config +from src.core.config import ( + AppConfig, + BrowserConfig, + LimitsConfig, + TargetConfig, + load_config, +) def write_config(data: dict, path: Path) -> None: @@ -45,14 +49,13 @@ def test_does_not_raise_when_api_key_missing( self, tmp_config: Path, monkeypatch: pytest.MonkeyPatch ) -> None: from unittest.mock import patch - import os # Fully clear env to avoid picking up keys from system with patch.dict(os.environ, {}, clear=True): # Mock paths.env_file to point to a non-existent file in tmp_config.parent with patch("src.core.paths.env_file", return_value=tmp_config.parent / ".env.missing"): # Prevent load_dotenv from picking up local .env by staying in empty tmp dir monkeypatch.chdir(tmp_config.parent) - + config = load_config(str(tmp_config)) assert config.gemini_api_key == "" # Verify it actually returns empty even if it was called diff --git a/tests/unit/test_header_bar.py b/tests/unit/test_header_bar.py index 80bae4d..b34138b 100644 --- a/tests/unit/test_header_bar.py +++ b/tests/unit/test_header_bar.py @@ -4,7 +4,7 @@ import pytest from textual.app import App, ComposeResult -from src.tui.widgets.header_bar import HeaderBar, BotMode +from src.tui.widgets.header_bar import BotMode, HeaderBar class HeaderApp(App): diff --git a/tests/unit/test_human_typer.py b/tests/unit/test_human_typer.py index 47c039a..fd9ef2a 100644 --- a/tests/unit/test_human_typer.py +++ b/tests/unit/test_human_typer.py @@ -1,6 +1,6 @@ from __future__ import annotations -from unittest.mock import AsyncMock, MagicMock, call, patch +from unittest.mock import AsyncMock, MagicMock import pytest diff --git a/tests/unit/test_like_comment_logic.py b/tests/unit/test_like_comment_logic.py index 0b00730..902ae8f 100644 --- a/tests/unit/test_like_comment_logic.py +++ b/tests/unit/test_like_comment_logic.py @@ -2,16 +2,19 @@ from __future__ import annotations import sqlite3 -from datetime import datetime, timezone -from unittest.mock import AsyncMock, MagicMock, patch +from datetime import UTC, datetime import pytest -from src.core.config import AppConfig, AIConfig, BrowserConfig, LimitsConfig, TargetConfig +from src.core.config import ( + AIConfig, + AppConfig, + BrowserConfig, + LimitsConfig, + TargetConfig, +) from src.executor.models import PostResult from src.storage.activity_log import ActivityLog -from src.storage.models import ActivityRecord - # --------------------------------------------------------------------------- # ActivityLog: count_today should only count comments, not likes @@ -37,7 +40,7 @@ def test_legacy_db_without_action_type(self, tmp_path): conn.execute( "INSERT INTO activity_log (post_url, comment_text, status, created_at) " "VALUES ('https://linkedin.com/post/old', 'old comment', 'success', ?)", - (datetime.now(timezone.utc).isoformat(),), + (datetime.now(UTC).isoformat(),), ) conn.commit() conn.close() @@ -144,7 +147,7 @@ def test_post_result_tracks_liked(self): success=True, post_url="https://linkedin.com/post/1", comment_text="Nice!", - posted_at=datetime.now(timezone.utc), + posted_at=datetime.now(UTC), error=None, liked=True, ) @@ -156,7 +159,7 @@ def test_post_result_liked_defaults_false(self): success=True, post_url="https://linkedin.com/post/1", comment_text="Nice!", - posted_at=datetime.now(timezone.utc), + posted_at=datetime.now(UTC), error=None, ) assert result.liked is False diff --git a/tests/unit/test_scheduler.py b/tests/unit/test_scheduler.py index c2e9cd0..5035b2a 100644 --- a/tests/unit/test_scheduler.py +++ b/tests/unit/test_scheduler.py @@ -1,9 +1,10 @@ -import platform -from pathlib import Path from unittest.mock import MagicMock, patch + import pytest + from src.core import scheduler + @pytest.fixture def mock_path_home(tmp_path): with patch("pathlib.Path.home", return_value=tmp_path): @@ -17,12 +18,12 @@ def test_register_daily_run_invalid_time(): @patch("subprocess.run") def test_schedule_mac_success(mock_run, mock_system, mock_path_home): mock_run.return_value = MagicMock(returncode=0) - + result = scheduler.register_daily_run("09:30") - + assert "Successfully scheduled" in result assert "macOS LaunchAgent" in result - + # Verify plist creation plist_path = mock_path_home / "Library" / "LaunchAgents" / "com.jienweng.yappy.daily.plist" assert plist_path.exists() @@ -39,12 +40,12 @@ def test_schedule_linux_success(mock_run, mock_system): MagicMock(stdout="", returncode=0), # crontab -l MagicMock(returncode=0), # crontab - ] - + result = scheduler.register_daily_run("23:15") - + assert "Successfully scheduled" in result assert "Linux crontab" in result - + # Verify crontab call args, kwargs = mock_run.call_args_list[1] assert args[0] == ["crontab", "-"] @@ -54,12 +55,12 @@ def test_schedule_linux_success(mock_run, mock_system): def test_list_schedules_mac(mock_system, mock_path_home): # No schedule assert "No active schedules found" in scheduler.list_schedules() - + # With schedule plist_dir = mock_path_home / "Library" / "LaunchAgents" plist_dir.mkdir(parents=True) (plist_dir / "com.jienweng.yappy.daily.plist").write_text("dummy") - + assert "Found active macOS schedule" in scheduler.list_schedules() @patch("platform.system", return_value="Darwin") @@ -68,7 +69,7 @@ def test_clear_schedules_mac(mock_run, mock_system, mock_path_home): plist_path = mock_path_home / "Library" / "LaunchAgents" / "com.jienweng.yappy.daily.plist" plist_path.parent.mkdir(parents=True, exist_ok=True) plist_path.write_text("dummy") - + result = scheduler.clear_schedules() assert "macOS schedules cleared" in result assert not plist_path.exists() diff --git a/tests/unit/test_scraper_robustness.py b/tests/unit/test_scraper_robustness.py index acf61de..1d7a983 100644 --- a/tests/unit/test_scraper_robustness.py +++ b/tests/unit/test_scraper_robustness.py @@ -1,7 +1,9 @@ +from unittest.mock import AsyncMock, MagicMock + import pytest -from unittest.mock import AsyncMock, MagicMock, patch -from src.scraper.linkedin_scraper import LinkedInScraper + from src.core.config import TargetConfig +from src.scraper.linkedin_scraper import LinkedInScraper # --- Mock HTML Snippets --- @@ -47,32 +49,32 @@ def mock_log(): async def test_scraper_finds_old_design_posts(mock_log): page = AsyncMock() scraper = LinkedInScraper( - context=MagicMock(), + context=MagicMock(), activity_log=mock_log, min_reactions=0, min_comments=0 ) - + c1 = AsyncMock() c1.get_attribute = AsyncMock(side_effect=lambda attr: "activity:12345" if attr == "data-urn" else None) - + text_el = AsyncMock() text_el.inner_text = AsyncMock(return_value="This is a very long post text that should definitely pass the media-only check because it has more than eighty characters in total and provides enough context for the AI to generate a meaningful comment.") - + author_el = AsyncMock() author_el.inner_text = AsyncMock(return_value="Old Author") - + async def dynamic_selector(sel): if "description" in sel or "break-words" in sel: return text_el if "author-name" in sel or "actor__name" in sel: return author_el return None c1.query_selector = AsyncMock(side_effect=dynamic_selector) - + page.query_selector_all = AsyncMock(return_value=[c1]) - + target = TargetConfig(type="feed", value="", max_posts=1) posts = await scraper._extract_posts_from_page(page, target) - + assert len(posts) == 1 assert posts[0].author_name == "Old Author" @@ -80,31 +82,31 @@ async def dynamic_selector(sel): async def test_scraper_finds_new_design_posts(mock_log): page = AsyncMock() scraper = LinkedInScraper( - context=MagicMock(), + context=MagicMock(), activity_log=mock_log, min_reactions=0, min_comments=0 ) - + c1 = AsyncMock() c1.get_attribute = AsyncMock(side_effect=lambda attr: "activity:99999" if attr == "data-urn" else None) - + text_el = AsyncMock() text_el.inner_text = AsyncMock(return_value="Here is another very long post for the new design testing. It also has more than eighty characters to ensure that the scraper doesn't skip it as a media-only post. Testing robustness is key!") - + author_el = AsyncMock() author_el.inner_text = AsyncMock(return_value="New Author") - + async def dynamic_selector(sel): if "update-components-text" in sel or "break-words" in sel: return text_el if "actor__name" in sel: return author_el return None c1.query_selector = AsyncMock(side_effect=dynamic_selector) - + page.query_selector_all = AsyncMock(return_value=[c1]) - + target = TargetConfig(type="feed", value="", max_posts=1) posts = await scraper._extract_posts_from_page(page, target) - + assert len(posts) == 1 assert posts[0].author_name == "New Author" diff --git a/tests/unit/test_target_config.py b/tests/unit/test_target_config.py new file mode 100644 index 0000000..d675c1e --- /dev/null +++ b/tests/unit/test_target_config.py @@ -0,0 +1,99 @@ +"""Tests for TargetConfig feature coverage.""" +from __future__ import annotations + +import pytest +from pydantic import ValidationError + +from src.core.config import TargetConfig + + +class TestTargetConfigTypes: + """Test TargetConfig type validation.""" + + def test_type_keyword(self) -> None: + """Should accept type keyword.""" + cfg = TargetConfig(type="keyword", value="python") + assert cfg.type == "keyword" + + def test_type_url(self) -> None: + """Should accept type url.""" + cfg = TargetConfig(type="url", value="https://linkedin.com/feed") + assert cfg.type == "url" + + def test_type_feed(self) -> None: + """Should accept type feed.""" + cfg = TargetConfig(type="feed") + assert cfg.type == "feed" + + def test_type_connections(self) -> None: + """Should accept type connections.""" + cfg = TargetConfig(type="connections") + assert cfg.type == "connections" + + +class TestTargetConfigDefaults: + """Test TargetConfig default values.""" + + def test_default_max_posts(self) -> None: + """Default max_posts should be 5.""" + cfg = TargetConfig(type="feed") + assert cfg.max_posts == 5 + + def test_default_recency_hours(self) -> None: + """Default recency_hours should be 24.""" + cfg = TargetConfig(type="keyword", value="test") + assert cfg.recency_hours == 24 + + def test_default_value_empty_for_feed(self) -> None: + """Default value should be empty for feed type.""" + cfg = TargetConfig(type="feed") + assert cfg.value == "" + + +class TestTargetConfigValidation: + """Test TargetConfig validation rules.""" + + def test_max_posts_min_valid(self) -> None: + """max_posts of 1 should be valid.""" + cfg = TargetConfig(type="feed", max_posts=1) + assert cfg.max_posts == 1 + + def test_max_posts_max_valid(self) -> None: + """max_posts of 50 should be valid.""" + cfg = TargetConfig(type="feed", max_posts=50) + assert cfg.max_posts == 50 + + def test_max_posts_below_min_invalid(self) -> None: + """max_posts below 1 should raise ValidationError.""" + with pytest.raises(ValidationError): + TargetConfig(type="feed", max_posts=0) + + def test_max_posts_above_max_invalid(self) -> None: + """max_posts above 50 should raise ValidationError.""" + with pytest.raises(ValidationError): + TargetConfig(type="feed", max_posts=51) + + def test_recency_hours_min_valid(self) -> None: + """recency_hours of 1 should be valid.""" + cfg = TargetConfig(type="keyword", value="test", recency_hours=1) + assert cfg.recency_hours == 1 + + def test_recency_hours_max_valid(self) -> None: + """recency_hours of 168 should be valid.""" + cfg = TargetConfig(type="keyword", value="test", recency_hours=168) + assert cfg.recency_hours == 168 + + def test_recency_hours_below_min_invalid(self) -> None: + """recency_hours below 1 should raise ValidationError.""" + with pytest.raises(ValidationError): + TargetConfig(type="keyword", value="test", recency_hours=0) + + +class TestTargetConfigImmutable: + """Test TargetConfig is immutable (frozen).""" + + def test_config_is_frozen(self) -> None: + """TargetConfig should be frozen (immutable).""" + cfg = TargetConfig(type="feed") + with pytest.raises((ValidationError, TypeError)): + cfg.max_posts = 10 # type: ignore[misc] diff --git a/tests/unit/test_tui_events.py b/tests/unit/test_tui_events.py index 37748db..695bbff 100644 --- a/tests/unit/test_tui_events.py +++ b/tests/unit/test_tui_events.py @@ -147,9 +147,10 @@ class TestPauseResumeStatusText: def test_paused_status_contains_p_to_resume(self) -> None: """The on_bot_paused handler must tell users to press p, not s.""" # Import here to avoid circular import issues at module level - from src.tui.screens.dashboard import DashboardScreen import inspect + from src.tui.screens.dashboard import DashboardScreen + source = inspect.getsource(DashboardScreen.on_bot_paused) assert "p to resume" in source, ( "on_bot_paused should instruct 'press p to resume'" @@ -172,6 +173,7 @@ class TestMessageRoutingToScreen: def test_app_post_message_does_not_reach_screen(self) -> None: """Textual messages bubble UP, so app.post_message never reaches child screens.""" import inspect + from src.tui.app import YappyApp source = inspect.getsource(YappyApp._run_bot) @@ -185,6 +187,7 @@ def test_app_post_message_does_not_reach_screen(self) -> None: def test_bot_worker_posts_on_screen(self) -> None: """BotWorkerCallbacks must post on screen, not app.""" import inspect + from src.tui.workers.bot_worker import BotWorkerCallbacks source = inspect.getsource(BotWorkerCallbacks) diff --git a/tests/unit/test_updates.py b/tests/unit/test_updates.py index 2bc866b..8e079e4 100644 --- a/tests/unit/test_updates.py +++ b/tests/unit/test_updates.py @@ -1,13 +1,16 @@ -import pytest from unittest.mock import MagicMock, patch + +import pytest + from src.core.updates import check_for_updates + @pytest.mark.asyncio async def test_check_for_updates_no_update(): with patch("httpx.AsyncClient.get") as mock_get: mock_get.return_value = MagicMock(status_code=200) mock_get.return_value.json.return_value = {"info": {"version": "0.1.0"}} - + result = await check_for_updates("0.1.0") assert result is None @@ -16,7 +19,7 @@ async def test_check_for_updates_with_new_version(): with patch("httpx.AsyncClient.get") as mock_get: mock_get.return_value = MagicMock(status_code=200) mock_get.return_value.json.return_value = {"info": {"version": "0.2.0"}} - + result = await check_for_updates("0.1.0") assert result == "0.2.0"