Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 10 additions & 8 deletions tests/integration/test_browser_launch.py
Original file line number Diff line number Diff line change
@@ -1,27 +1,29 @@
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():
# Patch WHERE it is used
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

Expand All @@ -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
14 changes: 7 additions & 7 deletions tests/integration/test_config_tabs.py
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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"}):
Expand Down
15 changes: 7 additions & 8 deletions tests/integration/test_onboarding_flow.py
Original file line number Diff line number Diff line change
@@ -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")
Expand Down Expand Up @@ -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)
4 changes: 2 additions & 2 deletions tests/integration/test_tui_smoke.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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")
Expand Down
109 changes: 109 additions & 0 deletions tests/unit/test_ai_config.py
Original file line number Diff line number Diff line change
@@ -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]
61 changes: 61 additions & 0 deletions tests/unit/test_browser_config.py
Original file line number Diff line number Diff line change
@@ -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]
6 changes: 2 additions & 4 deletions tests/unit/test_comment_generator.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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",
)

Expand Down
13 changes: 8 additions & 5 deletions tests/unit/test_config.py
Original file line number Diff line number Diff line change
@@ -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:
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion tests/unit/test_header_bar.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
2 changes: 1 addition & 1 deletion tests/unit/test_human_typer.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from __future__ import annotations

from unittest.mock import AsyncMock, MagicMock, call, patch
from unittest.mock import AsyncMock, MagicMock

import pytest

Expand Down
Loading
Loading