Files
ss-tools/backend/tests/plugins/test_llm_analysis_service_coverage.py

991 lines
44 KiB
Python

# #region Test.LLMAnalysisService.Coverage [C:3] [TYPE Module] [SEMANTICS test,llm,analysis,coverage,edge]
# @BRIEF Additional coverage tests for service.py — _launch_and_login, capture_dashboard_chunks,
# capture_dashboard, LLMClient edge cases, DatasetHealthChecker edge cases.
# @RELATION BINDS_TO -> [LLMAnalysisService]
# @TEST_EDGE: launch_login_direct_form -> Direct form login fallback (no username field found)
# @TEST_EDGE: launch_login_redirect_to_login -> Dashboard navigation redirects to login page
# @TEST_EDGE: chunks_cdp_fallback -> CDP capture fails, falls back to Playwright
# @TEST_EDGE: chunks_no_tabs -> No tabs found, captures full page as single chunk
# @TEST_EDGE: json_completion_rate_limit -> RateLimit error triggers retry delay parsing
# @TEST_EDGE: json_completion_json_mode_fallback -> JSON mode fails, falls back to plain
# @TEST_EDGE: health_chart_data_list_result -> chart data returns list instead of dict
import base64
import json
import os
import ssl
import tempfile
from unittest.mock import AsyncMock, MagicMock, patch, PropertyMock, ANY
import pytest
from PIL import Image
from src.plugins.llm_analysis.models import LLMProviderType
# ═══════════════════════════════════════════════════════════════════
# ScreenshotService — remaining edge cases
# ═══════════════════════════════════════════════════════════════════
class TestFindFirstVisibleLocator:
"""Verify _find_first_visible_locator edge cases."""
@pytest.mark.asyncio
async def test_all_candidates_raise(self):
"""All candidates raise exceptions, returns None."""
from src.plugins.llm_analysis.service import ScreenshotService
svc = ScreenshotService(MagicMock())
mock_loc = MagicMock()
mock_loc.count = AsyncMock(side_effect=Exception("count failed"))
result = await svc._find_first_visible_locator([mock_loc, mock_loc])
assert result is None
@pytest.mark.asyncio
async def test_first_visible_returns_early(self):
"""Returns first visible locator without checking rest."""
from src.plugins.llm_analysis.service import ScreenshotService
svc = ScreenshotService(MagicMock())
mock_visible = MagicMock()
mock_visible.count = AsyncMock(return_value=1)
mock_visible.nth.return_value = mock_visible
mock_visible.is_visible = AsyncMock(return_value=True)
result = await svc._find_first_visible_locator([mock_visible])
assert result is not None
@pytest.mark.asyncio
async def test_none_visible_returns_none(self):
"""No visible candidates returns None."""
from src.plugins.llm_analysis.service import ScreenshotService
svc = ScreenshotService(MagicMock())
mock_invisible = MagicMock()
mock_invisible.count = AsyncMock(return_value=1)
mock_invisible.nth.return_value = mock_invisible
mock_invisible.is_visible = AsyncMock(return_value=False)
result = await svc._find_first_visible_locator([mock_invisible])
assert result is None
class TestIterLoginRoots:
"""Verify _iter_login_roots remaining edge cases."""
def test_duplicate_frames_skipped(self):
"""Frame already in roots is skipped."""
from src.plugins.llm_analysis.service import ScreenshotService
svc = ScreenshotService(MagicMock())
page = MagicMock()
page.frames = [page] # page itself is a frame
roots = svc._iter_login_roots(page)
assert len(roots) >= 1
class TestExtractHiddenLoginFields:
"""Verify _extract_hidden_login_fields edge cases."""
@pytest.mark.asyncio
async def test_locator_exception_handled(self):
"""Exception during locator iteration is caught and skipped."""
from src.plugins.llm_analysis.service import ScreenshotService
svc = ScreenshotService(MagicMock())
mock_page = MagicMock()
mock_locator = MagicMock()
mock_page.locator.return_value = mock_locator
mock_locator.count = AsyncMock(side_effect=Exception("locator error"))
result = await svc._extract_hidden_login_fields(mock_page)
assert result == {}
class TestSubmitLoginViaFormPost:
"""Verify _submit_login_via_form_post remaining edge cases."""
@pytest.mark.asyncio
async def test_request_context_exception(self):
"""Exception getting request context returns False."""
from src.plugins.llm_analysis.service import ScreenshotService
svc = ScreenshotService(MagicMock())
svc.env = MagicMock()
mock_page = MagicMock()
# Make page.context raise
type(mock_page).context = PropertyMock(side_effect=Exception("no context"))
with patch.object(svc, '_extract_hidden_login_fields', AsyncMock(return_value={"csrf_token": "tok"})):
result = await svc._submit_login_via_form_post(mock_page, "https://example.com/login/")
assert result is False
@pytest.mark.asyncio
async def test_empty_origin_fallback(self):
"""When parsed URL has no scheme/netloc, uses login_url as origin."""
from src.plugins.llm_analysis.service import ScreenshotService
svc = ScreenshotService(MagicMock())
svc.env = MagicMock()
svc.env.username = "admin"
svc.env.password = "pass"
mock_page = MagicMock()
mock_request = MagicMock()
mock_page.context.request = mock_request
mock_response = MagicMock()
mock_response.url = ""
mock_response.status = 200
mock_response.headers = {}
mock_response.text = AsyncMock(return_value="<html>ok</html>")
mock_request.post = AsyncMock(return_value=mock_response)
with patch.object(svc, '_extract_hidden_login_fields', AsyncMock(return_value={"csrf_token": "tok"})):
result = await svc._submit_login_via_form_post(mock_page, "http://localhost/login/")
assert result is True
class TestCleanupTempFiles:
"""Verify _cleanup_temp_files remaining edge cases."""
def test_cleanup_exception_handled(self):
"""Exception during delete is caught."""
from src.plugins.llm_analysis.service import ScreenshotService
with patch('os.path.exists', return_value=True), patch('os.remove', side_effect=PermissionError("denied")):
ScreenshotService._cleanup_temp_files(["/tmp/test.png"]) # should not raise
# ═══════════════════════════════════════════════════════════════════
# _launch_and_login — mock internal methods for robust testing
# ═══════════════════════════════════════════════════════════════════
class TestLaunchAndLogin:
"""Verify _launch_and_login via method-level patching."""
def _make_env(self):
env = MagicMock()
env.url = "https://superset.example.com"
env.username = "admin"
env.password = "pass123"
return env
def _make_basic_mocks(self):
"""Create basic Playwright mocks."""
mock_page = MagicMock()
mock_page.frames = []
mock_page.url = "https://superset.example.com/superset/dashboard/42/"
mock_page.goto = AsyncMock()
mock_page.wait_for_load_state = AsyncMock()
mock_page.wait_for_selector = AsyncMock()
mock_page.wait_for_function = AsyncMock()
mock_page.evaluate = AsyncMock()
mock_page.add_init_script = AsyncMock()
mock_page.set_viewport_size = AsyncMock()
mock_page.screenshot = AsyncMock()
mock_page.locator = MagicMock()
mock_page.locator.count = AsyncMock(return_value=0)
mock_context = MagicMock()
mock_context.new_page = AsyncMock(return_value=mock_page)
mock_browser = MagicMock()
mock_browser.new_context = AsyncMock(return_value=mock_context)
mock_playwright = MagicMock()
mock_playwright.chromium.launch = AsyncMock(return_value=mock_browser)
return mock_page, mock_context, mock_browser, mock_playwright
@pytest.mark.asyncio
async def test_launch_login_success(self):
"""Happy path via patched internal methods."""
from src.plugins.llm_analysis.service import ScreenshotService
svc = ScreenshotService(self._make_env())
mock_page, _, _, mock_playwright = self._make_basic_mocks()
# Patch internal methods
username_loc = MagicMock()
username_loc.fill = AsyncMock()
password_loc = MagicMock()
password_loc.fill = AsyncMock()
submit_loc = MagicMock()
submit_loc.click = AsyncMock()
with patch.object(svc, '_find_login_field_locator', AsyncMock(side_effect=[username_loc, password_loc])):
with patch.object(svc, '_find_submit_locator', AsyncMock(return_value=submit_loc)):
browser, context, page = await svc._launch_and_login(mock_playwright, "42")
assert page is not None
username_loc.fill.assert_called_once_with("admin")
password_loc.fill.assert_called_once_with("pass123")
submit_loc.click.assert_called_once()
@pytest.mark.asyncio
async def test_launch_login_direct_form(self):
"""No username field → direct form login fallback."""
from src.plugins.llm_analysis.service import ScreenshotService
svc = ScreenshotService(self._make_env())
mock_page, _, _, mock_playwright = self._make_basic_mocks()
with patch.object(svc, '_find_login_field_locator', AsyncMock(return_value=None)):
with patch.object(svc, '_submit_login_via_form_post', AsyncMock(return_value=True)):
browser, context, page = await svc._launch_and_login(mock_playwright, "42")
assert page is not None
@pytest.mark.asyncio
async def test_launch_login_direct_form_fails(self):
"""No username field and form login fails → RuntimeError."""
from src.plugins.llm_analysis.service import ScreenshotService
svc = ScreenshotService(self._make_env())
mock_page, _, _, mock_playwright = self._make_basic_mocks()
with patch.object(svc, '_find_login_field_locator', AsyncMock(return_value=None)):
with patch.object(svc, '_submit_login_via_form_post', AsyncMock(return_value=False)):
with pytest.raises(RuntimeError, match="Could not find username input field"):
await svc._launch_and_login(mock_playwright, "42")
@pytest.mark.asyncio
async def test_username_found_password_not_found(self):
"""Username field found but no password → RuntimeError."""
from src.plugins.llm_analysis.service import ScreenshotService
svc = ScreenshotService(self._make_env())
mock_page, _, _, mock_playwright = self._make_basic_mocks()
username_loc = MagicMock()
username_loc.fill = AsyncMock()
with patch.object(svc, '_find_login_field_locator', AsyncMock(side_effect=[username_loc, None])):
with pytest.raises(RuntimeError, match="Could not find password input field"):
await svc._launch_and_login(mock_playwright, "42")
@pytest.mark.asyncio
async def test_username_found_submit_not_found(self):
"""Username+password found but no submit → RuntimeError."""
from src.plugins.llm_analysis.service import ScreenshotService
svc = ScreenshotService(self._make_env())
mock_page, _, _, mock_playwright = self._make_basic_mocks()
username_loc = MagicMock()
username_loc.fill = AsyncMock()
password_loc = MagicMock()
password_loc.fill = AsyncMock()
with patch.object(svc, '_find_login_field_locator', AsyncMock(side_effect=[username_loc, password_loc])):
with patch.object(svc, '_find_submit_locator', AsyncMock(return_value=None)):
with pytest.raises(RuntimeError, match="Could not find submit button"):
await svc._launch_and_login(mock_playwright, "42")
@pytest.mark.asyncio
async def test_login_still_on_login_page(self):
"""After submit, still on /login page → RuntimeError."""
from src.plugins.llm_analysis.service import ScreenshotService
svc = ScreenshotService(self._make_env())
mock_page, _, _, mock_playwright = self._make_basic_mocks()
mock_page.url = "https://superset.example.com/login/"
username_loc = MagicMock()
username_loc.fill = AsyncMock()
password_loc = MagicMock()
password_loc.fill = AsyncMock()
submit_loc = MagicMock()
submit_loc.click = AsyncMock()
with patch.object(svc, '_find_login_field_locator', AsyncMock(side_effect=[username_loc, password_loc])):
with patch.object(svc, '_find_submit_locator', AsyncMock(return_value=submit_loc)):
with patch.object(svc, '_goto_resilient', new=AsyncMock()):
with pytest.raises(RuntimeError, match="Login failed"):
await svc._launch_and_login(mock_playwright, "42")
@pytest.mark.asyncio
async def test_dashboard_nav_redirects_to_login(self):
"""Dashboard navigation ends up on /login → RuntimeError."""
from src.plugins.llm_analysis.service import ScreenshotService
svc = ScreenshotService(self._make_env())
mock_page, _, _, mock_playwright = self._make_basic_mocks()
# Make page.url return /login/ after the second navigation
url_values = [
"https://superset.example.com/superset/dashboard/42/", # during login
"https://superset.example.com/login/", # after dashboard goto
]
type(mock_page).url = PropertyMock(side_effect=lambda: url_values.pop(0))
username_loc = MagicMock()
username_loc.fill = AsyncMock()
password_loc = MagicMock()
password_loc.fill = AsyncMock()
submit_loc = MagicMock()
submit_loc.click = AsyncMock()
with patch.object(svc, '_find_login_field_locator', AsyncMock(side_effect=[username_loc, password_loc])):
with patch.object(svc, '_find_submit_locator', AsyncMock(return_value=submit_loc)):
with patch.object(svc, '_goto_resilient', new=AsyncMock()):
with pytest.raises(RuntimeError, match="Dashboard navigation redirected to login page"):
await svc._launch_and_login(mock_playwright, "42")
@pytest.mark.asyncio
async def test_launch_with_parsed_context(self):
"""Parsed context with native_filters and activeTabs."""
from src.plugins.llm_analysis.service import ScreenshotService
svc = ScreenshotService(self._make_env())
mock_page, _, _, mock_playwright = self._make_basic_mocks()
username_loc = MagicMock()
username_loc.fill = AsyncMock()
password_loc = MagicMock()
password_loc.fill = AsyncMock()
submit_loc = MagicMock()
submit_loc.click = AsyncMock()
with patch.object(svc, '_find_login_field_locator', AsyncMock(side_effect=[username_loc, password_loc])):
with patch.object(svc, '_find_submit_locator', AsyncMock(return_value=submit_loc)):
parsed_context = {"native_filters": "!((ekf:v))", "activeTabs": "tab-1"}
browser, context, page = await svc._launch_and_login(
mock_playwright, "42", parsed_context=parsed_context,
)
assert page is not None
@pytest.mark.asyncio
async def test_login_exception_wrapped(self):
"""Unexpected login exception is wrapped in RuntimeError."""
from src.plugins.llm_analysis.service import ScreenshotService
svc = ScreenshotService(self._make_env())
mock_page, _, _, mock_playwright = self._make_basic_mocks()
with patch.object(svc, '_find_login_field_locator', AsyncMock(side_effect=ValueError("unexpected"))):
with pytest.raises(RuntimeError, match="Login failed"):
await svc._launch_and_login(mock_playwright, "42")
@pytest.mark.asyncio
async def test_https_upgrade_path(self):
"""HTTP dashboard_url upgraded to HTTPS when base is HTTPS."""
from src.plugins.llm_analysis.service import ScreenshotService
env = MagicMock()
env.url = "https://superset.example.com"
env.username = "admin"
env.password = "pass"
svc = ScreenshotService(env)
mock_page, _, _, mock_playwright = self._make_basic_mocks()
username_loc = MagicMock()
username_loc.fill = AsyncMock()
password_loc = MagicMock()
password_loc.fill = AsyncMock()
submit_loc = MagicMock()
submit_loc.click = AsyncMock()
with patch.object(svc, '_find_login_field_locator', AsyncMock(side_effect=[username_loc, password_loc])):
with patch.object(svc, '_find_submit_locator', AsyncMock(return_value=submit_loc)):
browser, context, page = await svc._launch_and_login(mock_playwright, "42")
assert page is not None
# ═══════════════════════════════════════════════════════════════════
# capture_dashboard_chunks — simpler approach via _launch_and_login mock
# ═══════════════════════════════════════════════════════════════════
class TestCaptureDashboardChunks:
"""Verify capture_dashboard_chunks via method-level patching."""
def _make_env(self):
env = MagicMock()
env.url = "https://superset.example.com"
env.username = "admin"
env.password = "pass"
return env
@pytest.mark.asyncio
async def test_capture_with_tabs(self):
"""Happy path: tabs found, each tab captured via CDP."""
from src.plugins.llm_analysis.service import ScreenshotService
svc = ScreenshotService(self._make_env())
# Mock _launch_and_login to return browser, context, page
mock_page = MagicMock()
mock_page.set_viewport_size = AsyncMock()
mock_page.screenshot = AsyncMock()
mock_page.url = "https://example.com/dashboard/42/"
mock_page.frames = []
mock_context = MagicMock()
mock_cdp = MagicMock()
mock_cdp.send = AsyncMock(return_value={"data": base64.b64encode(b"img").decode()})
mock_context.new_cdp_session = AsyncMock(return_value=mock_cdp)
mock_browser = MagicMock()
mock_browser.close = AsyncMock()
# Tab with unique class so it's not "ant-tabs-tab-active"
tab_mock = MagicMock()
tab_mock.inner_text = AsyncMock(return_value="Tab 1")
tab_mock.is_visible = AsyncMock(return_value=True)
tab_mock.get_attribute = AsyncMock(return_value="ant-tabs-tab")
tab_mock.click = AsyncMock()
# Locator returns tabs list
mock_page.locator = MagicMock()
mock_page.locator.return_value.all = AsyncMock(return_value=[tab_mock])
# Mock wait_for_charts_stabilized and wait_for_resize_rendered
with patch.object(svc, '_launch_and_login',
AsyncMock(return_value=(mock_browser, mock_context, mock_page))):
with patch.object(svc, '_wait_for_charts_stabilized', AsyncMock()):
with patch.object(svc, '_wait_for_resize_rendered', AsyncMock()):
with tempfile.TemporaryDirectory() as tmp:
results = await svc.capture_dashboard_chunks("42", tmp)
assert len(results) >= 1
assert results[0]["tab_name"] == "Tab 1"
@pytest.mark.asyncio
async def test_capture_no_tabs(self):
"""No tabs found → captures full page as single chunk."""
from src.plugins.llm_analysis.service import ScreenshotService
svc = ScreenshotService(self._make_env())
mock_page = MagicMock()
mock_page.set_viewport_size = AsyncMock()
mock_page.screenshot = AsyncMock()
mock_page.url = "https://example.com/dashboard/42/"
mock_page.frames = []
mock_context = MagicMock()
mock_cdp = MagicMock()
mock_cdp.send = AsyncMock(return_value={"data": base64.b64encode(b"img").decode()})
mock_context.new_cdp_session = AsyncMock(return_value=mock_cdp)
mock_browser = MagicMock()
mock_browser.close = AsyncMock()
# No tabs
mock_page.locator = MagicMock()
mock_page.locator.return_value.all = AsyncMock(return_value=[])
with patch.object(svc, '_launch_and_login',
AsyncMock(return_value=(mock_browser, mock_context, mock_page))):
with patch.object(svc, '_wait_for_charts_stabilized', AsyncMock()):
with patch.object(svc, '_wait_for_resize_rendered', AsyncMock()):
with tempfile.TemporaryDirectory() as tmp:
results = await svc.capture_dashboard_chunks("42", tmp)
assert len(results) == 1
assert results[0]["tab_name"] == "full"
@pytest.mark.asyncio
async def test_capture_cdp_fallback(self):
"""CDP fails → falls back to Playwright screenshot."""
from src.plugins.llm_analysis.service import ScreenshotService
svc = ScreenshotService(self._make_env())
mock_page = MagicMock()
mock_page.set_viewport_size = AsyncMock()
mock_page.screenshot = AsyncMock()
mock_page.url = "https://example.com/dashboard/42/"
mock_page.frames = []
mock_context = MagicMock()
mock_context.new_cdp_session = AsyncMock(side_effect=Exception("CDP unavailable"))
mock_browser = MagicMock()
mock_browser.close = AsyncMock()
tab_mock = MagicMock()
tab_mock.inner_text = AsyncMock(return_value="Tab A")
tab_mock.is_visible = AsyncMock(return_value=True)
tab_mock.get_attribute = AsyncMock(return_value="ant-tabs-tab-active")
tab_mock.click = AsyncMock()
mock_page.locator = MagicMock()
mock_page.locator.return_value.all = AsyncMock(return_value=[tab_mock])
with patch.object(svc, '_launch_and_login',
AsyncMock(return_value=(mock_browser, mock_context, mock_page))):
with patch.object(svc, '_wait_for_charts_stabilized', AsyncMock()):
with patch.object(svc, '_wait_for_resize_rendered', AsyncMock()):
with tempfile.TemporaryDirectory() as tmp:
results = await svc.capture_dashboard_chunks("42", tmp)
assert len(results) >= 1
@pytest.mark.asyncio
async def test_tab_processing_exception_skipped(self):
"""Exception during tab processing is logged and skipped."""
from src.plugins.llm_analysis.service import ScreenshotService
svc = ScreenshotService(self._make_env())
mock_page = MagicMock()
mock_page.set_viewport_size = AsyncMock()
mock_page.screenshot = AsyncMock()
mock_page.url = "https://example.com/dashboard/42/"
mock_page.frames = []
mock_context = MagicMock()
mock_browser = MagicMock()
mock_browser.close = AsyncMock()
# Tab that raises on inner_text
bad_tab = MagicMock()
bad_tab.inner_text = AsyncMock(side_effect=Exception("tab error"))
bad_tab.is_visible = AsyncMock(return_value=True)
mock_page.locator = MagicMock()
mock_page.locator.return_value.all = AsyncMock(return_value=[bad_tab])
with patch.object(svc, '_launch_and_login',
AsyncMock(return_value=(mock_browser, mock_context, mock_page))):
with patch.object(svc, '_wait_for_charts_stabilized', AsyncMock()):
with tempfile.TemporaryDirectory() as tmp:
results = await svc.capture_dashboard_chunks("42", tmp)
# Tabs found but processing failed, then no-tab fallback triggers
assert len(results) == 1
assert results[0]["tab_name"] == "full"
@pytest.mark.asyncio
async def test_duplicate_tabs_skipped(self):
"""Same tab_id processed only once. Prevent recursion by returning no tabs after first lookup."""
from src.plugins.llm_analysis.service import ScreenshotService
svc = ScreenshotService(self._make_env())
mock_page = MagicMock()
mock_page.set_viewport_size = AsyncMock()
mock_page.screenshot = AsyncMock()
mock_page.url = "https://example.com/dashboard/42/"
mock_page.frames = []
mock_context = MagicMock()
mock_cdp = MagicMock()
mock_cdp.send = AsyncMock(return_value={"data": base64.b64encode(b"img").decode()})
mock_context.new_cdp_session = AsyncMock(return_value=mock_cdp)
mock_browser = MagicMock()
mock_browser.close = AsyncMock()
tab_mock = MagicMock()
tab_mock.inner_text = AsyncMock(return_value="SameTab")
tab_mock.is_visible = AsyncMock(return_value=True)
tab_mock.get_attribute = AsyncMock(return_value="")
tab_mock.click = AsyncMock()
# Return tabs only for the first level call, empty for recursion
call_count = [0]
async def all_side(*args, **kwargs):
call_count[0] += 1
if call_count[0] == 1:
return [tab_mock, tab_mock]
return []
mock_page.locator = MagicMock()
mock_page.locator.return_value.all = AsyncMock(side_effect=all_side)
mock_page.locator.return_value.count = AsyncMock(return_value=0)
with patch.object(svc, '_launch_and_login',
AsyncMock(return_value=(mock_browser, mock_context, mock_page))):
with patch.object(svc, '_wait_for_charts_stabilized', AsyncMock()):
with patch.object(svc, '_wait_for_resize_rendered', AsyncMock()):
with tempfile.TemporaryDirectory() as tmp:
results = await svc.capture_dashboard_chunks("42", tmp)
# Two tabs at depth 0 (different indices → different tab_ids) → 2 results
# No recursion since all_side returns empty after first call
assert len(results) == 2
# ═══════════════════════════════════════════════════════════════════
# capture_dashboard — remaining paths
# ═══════════════════════════════════════════════════════════════════
class TestCaptureDashboard:
"""Verify capture_dashboard remaining branches."""
@pytest.mark.asyncio
async def test_capture_dashboard_full_flow(self):
"""Happy path: chunks captured, converted, archived."""
from src.plugins.llm_analysis.service import ScreenshotService
with tempfile.TemporaryDirectory() as tmp:
svc = ScreenshotService(MagicMock())
png_path = os.path.join(tmp, "test.png")
Image.new("RGB", (100, 100)).save(png_path, "PNG")
with patch.object(svc, 'capture_dashboard_chunks', AsyncMock(return_value=[
{"tab_name": "Tab1", "path": png_path},
])):
with patch.object(ScreenshotService, '_convert_screenshots_for_llm', return_value=[
os.path.join(tmp, "test_llm.jpg"),
]):
with patch.object(ScreenshotService, '_archive_screenshots_as_webp', return_value=[
{"original": png_path, "webp_path": os.path.join(tmp, "test.webp")},
]):
jpeg_paths, archive_results = await svc.capture_dashboard("42", os.path.join(tmp, "output.png"))
assert len(jpeg_paths) == 1
assert len(archive_results) == 1
@pytest.mark.asyncio
async def test_capture_no_screenshots_returns_empty(self):
"""No screenshots captured → returns empty lists."""
from src.plugins.llm_analysis.service import ScreenshotService
svc = ScreenshotService(MagicMock())
with patch.object(svc, 'capture_dashboard_chunks', AsyncMock(return_value=[])):
with tempfile.TemporaryDirectory() as tmp:
jpeg_paths, archive_results = await svc.capture_dashboard("42", os.path.join(tmp, "out.png"))
assert jpeg_paths == []
assert archive_results == []
# ═══════════════════════════════════════════════════════════════════
# LLMClient — remaining edge cases
# ═══════════════════════════════════════════════════════════════════
class TestLLMClientEdgeCases:
"""Verify LLMClient remaining branches."""
def test_ssl_verify_ca_dir_nonexistent(self):
"""When /etc/ssl/certs doesn't exist, still returns SSLContext."""
from src.plugins.llm_analysis.service import LLMClient
with patch.dict(os.environ, {}, clear=True):
with patch('os.path.isdir', return_value=False):
result = LLMClient._get_ssl_verify()
assert isinstance(result, ssl.SSLContext)
def test_optimize_images_fallback_to_raw(self):
"""When _reduce_image_quality fails, falls back to raw base64."""
from src.plugins.llm_analysis.service import LLMClient
with tempfile.TemporaryDirectory() as tmp:
png_path = os.path.join(tmp, "test.png")
Image.new("RGB", (100, 100)).save(png_path, "PNG")
client = MagicMock()
with patch.object(LLMClient, '_reduce_image_quality', side_effect=Exception("corrupt")):
result = LLMClient._optimize_images(client, [png_path], 1024, 60)
assert len(result) == 1
assert isinstance(result[0], str)
def test_deduplicate_preserves_order(self):
"""_deduplicate_issues preserves original order."""
from src.plugins.llm_analysis.service import LLMClient
client = MagicMock()
issues = [
{"severity": "HIGH", "message": "A", "location": "loc1"},
{"severity": "LOW", "message": "B", "location": "loc2"},
{"severity": "HIGH", "message": "A", "location": "loc1"},
{"severity": "MED", "message": "C", "location": "loc3"},
]
result = LLMClient._deduplicate_issues(client, issues)
assert len(result) == 3
assert result[0]["message"] == "A"
assert result[1]["message"] == "B"
assert result[2]["message"] == "C"
def test_estimate_payload_size_edge_cases(self):
"""_estimate_payload_size with various inputs."""
from src.plugins.llm_analysis.service import LLMClient
# Zero images, zero text
result = LLMClient._estimate_payload_size([], 0, 128000)
assert result["estimated_tokens"] == 0
assert result["exceeds_limit"] is False
# Exactly at 80% limit
result = LLMClient._estimate_payload_size([], 409600, 128000)
assert abs(result["pct_of_limit"] - 80.0) < 0.1
@pytest.mark.asyncio
async def test_get_json_completion_rate_limit(self):
"""Rate limit error handled in get_json_completion."""
from src.plugins.llm_analysis.service import LLMClient
from openai import RateLimitError
with patch('src.plugins.llm_analysis.service.httpx.AsyncClient'):
with patch('src.plugins.llm_analysis.service.AsyncOpenAI') as MockOpenAI:
mock_client_instance = MagicMock()
MockOpenAI.return_value = mock_client_instance
rate_err = RateLimitError(
"rate_limit",
response=MagicMock(),
body={},
)
mock_success = MagicMock()
mock_success.choices = [MagicMock()]
mock_success.choices[0].message.content = '{"status": "PASS"}'
mock_client_instance.chat.completions.create = AsyncMock(side_effect=[
rate_err,
mock_success,
])
real = LLMClient(
LLMProviderType.OPENAI, "sk-test", "https://api.openai.com", "gpt-4o",
)
with patch.object(real, '_supports_json_response_format', return_value=False):
result = await real.get_json_completion([{"role": "user", "content": "test"}])
assert result["status"] == "PASS"
@pytest.mark.asyncio
async def test_get_json_completion_rate_limit_with_retry_delay_in_body(self):
"""Rate limit error with google.rpc.RetryInfo in body."""
from src.plugins.llm_analysis.service import LLMClient
from openai import RateLimitError
with patch('src.plugins.llm_analysis.service.httpx.AsyncClient'):
with patch('src.plugins.llm_analysis.service.AsyncOpenAI') as MockOpenAI:
mock_client_instance = MagicMock()
MockOpenAI.return_value = mock_client_instance
rate_err = RateLimitError(
"rate_limit",
response=MagicMock(),
body={"error": {"details": [{"@type": "type.googleapis.com/google.rpc.RetryInfo", "retryDelay": "10s"}]}},
)
mock_success = MagicMock()
mock_success.choices = [MagicMock()]
mock_success.choices[0].message.content = '{"status": "PASS"}'
mock_client_instance.chat.completions.create = AsyncMock(side_effect=[
rate_err,
mock_success,
])
real = LLMClient(
LLMProviderType.OPENAI, "sk-test", "https://api.openai.com", "gpt-4o",
)
with patch.object(real, '_supports_json_response_format', return_value=False):
result = await real.get_json_completion([{"role": "user", "content": "test"}])
assert result["status"] == "PASS"
@pytest.mark.asyncio
async def test_get_json_completion_json_mode_fallback(self):
"""JSON mode fails, falls back to plain text."""
from src.plugins.llm_analysis.service import LLMClient
with patch('src.plugins.llm_analysis.service.httpx.AsyncClient'):
with patch('src.plugins.llm_analysis.service.AsyncOpenAI') as MockOpenAI:
mock_client_instance = MagicMock()
MockOpenAI.return_value = mock_client_instance
mock_response = MagicMock()
mock_response.choices = [MagicMock()]
mock_response.choices[0].message.content = '{"status": "PASS"}'
mock_client_instance.chat.completions.create = AsyncMock(side_effect=[
Exception("400: JSON mode is not enabled"),
mock_response,
])
real = LLMClient(
LLMProviderType.OPENAI, "sk-test", "https://api.openai.com", "gpt-4o",
)
result = await real.get_json_completion([{"role": "user", "content": "test"}])
assert result["status"] == "PASS"
class TestCallLlmForImages:
"""Verify _call_llm_for_images."""
@pytest.mark.asyncio
async def test_sends_images_in_content(self):
"""Messages constructed with image_url content blocks."""
from src.plugins.llm_analysis.service import LLMClient
client = MagicMock()
client.get_json_completion = AsyncMock(return_value={"status": "PASS"})
result = await LLMClient._call_llm_for_images(client, ["base64img"], "Analyze this")
assert result["status"] == "PASS"
@pytest.mark.asyncio
async def test_multiple_images(self):
"""Multiple images each get their own content block."""
from src.plugins.llm_analysis.service import LLMClient
client = MagicMock()
async def capture_messages(messages):
content = messages[0]["content"]
assert len(content) == 4 # text + 3 images
assert content[0]["type"] == "text"
assert content[1]["type"] == "image_url"
return {"status": "PASS"}
client.get_json_completion = AsyncMock(side_effect=capture_messages)
result = await LLMClient._call_llm_for_images(client, ["a", "b", "c"], "Analyze")
assert result["status"] == "PASS"
class TestAnalyzeDashboardMultimodalChunking:
"""Verify analyze_dashboard_multimodal chunking edge cases."""
@pytest.mark.asyncio
async def test_chunk_failure_returns_unknown_for_that_chunk(self):
"""When a chunk call fails, that chunk's status is UNKNOWN."""
from src.plugins.llm_analysis.service import LLMClient
with tempfile.TemporaryDirectory() as tmp:
paths = []
for i in range(3):
p = os.path.join(tmp, f"t_{i}.png")
Image.new("RGB", (10, 10)).save(p, "PNG")
paths.append(p)
client = MagicMock()
client._optimize_images.return_value = ["a", "b", "c"]
client._estimate_payload_size.return_value = {"exceeds_limit": False, "pct_of_limit": 10}
client._call_llm_for_images = AsyncMock(side_effect=[
{"status": "PASS", "summary": "OK", "issues": []},
RuntimeError("Chunk 2 failed"),
{"status": "WARN", "summary": "Issues", "issues": []},
])
client._deduplicate_issues.return_value = []
# Bind the real method so self is passed correctly
import types
client._merge_chunk_results = types.MethodType(LLMClient._merge_chunk_results, client)
result = await LLMClient.analyze_dashboard_multimodal(
client, paths, ["log"], max_images=1,
)
assert "status" in result
assert result["chunk_count"] == 3
class TestAnalyzeDashboardTextBatchEdge:
"""Verify analyze_dashboard_text_batch edge cases."""
@pytest.mark.asyncio
async def test_prompt_renders_total_dashboards(self):
"""{total_dashboards} placeholder replaced in prompt."""
from src.plugins.llm_analysis.service import LLMClient
client = MagicMock()
client.get_json_completion = AsyncMock(return_value={"dashboards": []})
result = await LLMClient.analyze_dashboard_text_batch(
client,
[{"dashboard_id": "1", "topology": "", "dataset_health": "", "log_text": ""}],
"Analyzing {total_dashboards} dashboards",
)
assert "dashboards" in result
# ═══════════════════════════════════════════════════════════════════
# DatasetHealthChecker — remaining edge cases
# ═══════════════════════════════════════════════════════════════════
class TestDatasetHealthCheckerEdge:
"""Verify DatasetHealthChecker remaining branches."""
@pytest.mark.asyncio
async def test_check_dashboard_datasets_no_datasource_ids(self):
"""Charts without datasource_id produce empty dataset results."""
from src.plugins.llm_analysis.service import DatasetHealthChecker
mock_client = MagicMock()
checker = DatasetHealthChecker(mock_client)
chart_list = [
{"slice_id": 1, "slice_name": "Chart 1"},
{"slice_id": 2, "slice_name": "Chart 2", "datasource_id": None},
]
result = await checker.check_dashboard_datasets(chart_list, execute_chart_data=False)
assert result["datasets"] == []
@pytest.mark.asyncio
async def test_check_chart_data_list_result(self):
"""When chart data response is a list (not dict), handles correctly."""
from src.plugins.llm_analysis.service import DatasetHealthChecker
mock_client = MagicMock()
mock_client.network.request.return_value = [{"val": 1}, {"val": 2}]
checker = DatasetHealthChecker(mock_client)
result = await checker.check_chart_data(1, {"viz_type": "table"})
assert result["executed"] is True
assert result["row_count"] == 2
@pytest.mark.asyncio
async def test_check_dataset_health_flat_response(self):
"""When response has no 'result' wrapper, uses response directly."""
from src.plugins.llm_analysis.service import DatasetHealthChecker
mock_client = MagicMock()
mock_client.get_dataset.return_value = {
"table_name": "flat_table",
"database": {"database_name": "flat_db", "backend": "mysql"},
}
checker = DatasetHealthChecker(mock_client)
result = await checker.check_dataset_health(1)
assert result["dataset_name"] == "flat_table"
assert result["metadata_accessible"] is True
@pytest.mark.asyncio
async def test_check_dataset_health_non_dict_response(self):
"""When response is not a dict, uses empty dict as fallback (metadata_accessible remains True)."""
from src.plugins.llm_analysis.service import DatasetHealthChecker
mock_client = MagicMock()
mock_client.get_dataset.return_value = "not a dict"
checker = DatasetHealthChecker(mock_client)
result = await checker.check_dataset_health(1)
# Non-dict response does not raise; empty result dict with defaults
assert result["metadata_accessible"] is True
@pytest.mark.asyncio
async def test_check_dashboard_datasets_empty_chart_list(self):
"""Empty chart list produces empty results."""
from src.plugins.llm_analysis.service import DatasetHealthChecker
mock_client = MagicMock()
checker = DatasetHealthChecker(mock_client)
result = await checker.check_dashboard_datasets([], execute_chart_data=True)
assert result["datasets"] == []
assert result["chart_data"] == []
@pytest.mark.asyncio
async def test_call_sync_sync_method(self):
"""_call_sync wraps sync method in to_thread."""
from src.plugins.llm_analysis.service import DatasetHealthChecker
def sync_method(arg):
return f"sync_{arg}"
result = await DatasetHealthChecker._call_sync(sync_method, "test")
assert result == "sync_test"
@pytest.mark.asyncio
async def test_check_dashboard_datasets_with_affected_charts(self):
"""Datasets map affected charts correctly."""
from src.plugins.llm_analysis.service import DatasetHealthChecker
mock_client = MagicMock()
mock_client.get_dataset.return_value = {
"result": {"table_name": "t", "database": {"database_name": "db", "backend": "pg"}},
}
mock_client.network.request.return_value = {"result": []}
checker = DatasetHealthChecker(mock_client)
chart_list = [
{"slice_id": 1, "slice_name": "C1", "datasource_id": 101},
{"slice_id": 2, "slice_name": "C2", "datasource_id": 101},
{"slice_id": 3, "slice_name": "C3", "datasource_id": 102},
]
result = await checker.check_dashboard_datasets(chart_list, execute_chart_data=False)
assert len(result["datasets"]) == 2
# Check affected_charts mapping
ds_101 = [d for d in result["datasets"] if d["dataset_id"] == 101][0]
assert len(ds_101["affected_charts"]) == 2
# #endregion Test.LLMAnalysisService.Coverage