feat(stage10): implement vision integration
This commit is contained in:
864
tests/stages/test_stage10_vision.py
Normal file
864
tests/stages/test_stage10_vision.py
Normal file
@@ -0,0 +1,864 @@
|
||||
"""Tests für Stage 10: Vision Integration — API-Endpoint und Core-Funktionen.
|
||||
|
||||
Abdeckungen:
|
||||
- Pydantic-Validierung: Pflichtfelder, Defaults, frozen, range
|
||||
- Parsing: JSON-Array, Code-Blocks, Invalid JSON, Empty, Nested
|
||||
- Prompt: image_data/capture_type enthalten, Truncation, Custom
|
||||
- API: POST /vision/analyze, GET /vision/evidence/{id}, 400, 404
|
||||
- Integration: Mock Vision-LLM, Multiple Images, Edge Cases
|
||||
- Async mit asyncio_run() helper
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock, AsyncMock
|
||||
|
||||
import pytest
|
||||
|
||||
from nsct.api.vision import (
|
||||
AnalyzeImageRequest,
|
||||
AnalyzeImageResponse,
|
||||
EvidenceItem,
|
||||
EvidenceResponse,
|
||||
_build_prompt,
|
||||
_get_evidence,
|
||||
_image_source_label,
|
||||
_parse_vision_response,
|
||||
_store_evidence,
|
||||
_DEFAULT_VISION_PROMPT,
|
||||
router,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures & Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _mock_vision_provider(response: str) -> MagicMock:
|
||||
"""Erzeugt einen mock Vision-Provider mit einer festen Antwort."""
|
||||
provider = MagicMock()
|
||||
provider.analyze = AsyncMock(return_value=response)
|
||||
provider.model = "qwen2.5-vl-3b"
|
||||
return provider
|
||||
|
||||
|
||||
def asyncio_run(coro):
|
||||
"""Hilfsfunktion: Koroutine synchron ausführen."""
|
||||
loop = asyncio.new_event_loop()
|
||||
try:
|
||||
return loop.run_until_complete(coro)
|
||||
finally:
|
||||
loop.close()
|
||||
|
||||
|
||||
_BASE64_DATA = "iVBORw0KGgoAAAANSUhEUg=="
|
||||
_IMAGE_URL = "https://example.com/image.png"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test Group 1–8: Pydantic-Validierung — AnalyzeImageRequest
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestPydanticValidation:
|
||||
"""Tests für Pydantic-Validierung von AnalyzeImageRequest."""
|
||||
|
||||
def test_image_data_required(self) -> None:
|
||||
"""image_data ist required und nicht leer."""
|
||||
with pytest.raises(Exception):
|
||||
AnalyzeImageRequest(image_data="")
|
||||
|
||||
def test_image_data_min_length(self) -> None:
|
||||
"""image_data muss min_length=1 haben."""
|
||||
req = AnalyzeImageRequest(image_data="a")
|
||||
assert req.image_data == "a"
|
||||
|
||||
def test_capture_type_default(self) -> None:
|
||||
"""capture_type hat Default 'screenshot'."""
|
||||
req = AnalyzeImageRequest(image_data=_BASE64_DATA)
|
||||
assert req.capture_type == "screenshot"
|
||||
|
||||
def test_capture_type_custom(self) -> None:
|
||||
"""capture_type kann überschrieben werden."""
|
||||
req = AnalyzeImageRequest(
|
||||
image_data=_BASE64_DATA, capture_type="infographic"
|
||||
)
|
||||
assert req.capture_type == "infographic"
|
||||
|
||||
def test_prompt_default_none(self) -> None:
|
||||
"""prompt ist optional und default None."""
|
||||
req = AnalyzeImageRequest(image_data=_BASE64_DATA)
|
||||
assert req.prompt is None
|
||||
|
||||
def test_image_caption_default_none(self) -> None:
|
||||
"""image_caption ist optional und default None."""
|
||||
req = AnalyzeImageRequest(image_data=_BASE64_DATA)
|
||||
assert req.image_caption is None
|
||||
|
||||
def test_evidence_type_default(self) -> None:
|
||||
"""evidence_type hat Default 'visual'."""
|
||||
req = AnalyzeImageRequest(image_data=_BASE64_DATA)
|
||||
assert req.evidence_type == "visual"
|
||||
|
||||
def test_evidence_type_custom(self) -> None:
|
||||
"""evidence_type kann überschrieben werden."""
|
||||
req = AnalyzeImageRequest(
|
||||
image_data=_BASE64_DATA, evidence_type="document"
|
||||
)
|
||||
assert req.evidence_type == "document"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test Group 9–16: Pydantic-Validierung — EvidenceItem
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestEvidenceItemValidation:
|
||||
"""Tests für Pydantic-Validierung von EvidenceItem."""
|
||||
|
||||
def test_all_fields_present(self) -> None:
|
||||
"""EvidenceItem mit allen Pflichtfeldern."""
|
||||
import uuid
|
||||
ev = EvidenceItem(
|
||||
id=str(uuid.uuid4()),
|
||||
evidence_type="visual",
|
||||
capture_type="screenshot",
|
||||
image_source="https://x.com",
|
||||
description="Test",
|
||||
)
|
||||
assert ev.id is not None
|
||||
assert ev.evidence_type == "visual"
|
||||
assert ev.key_findings == []
|
||||
assert ev.data_points == []
|
||||
assert ev.confidence == 0.8
|
||||
assert ev.sources == []
|
||||
|
||||
def test_confidence_range(self) -> None:
|
||||
"""confidence muss zwischen 0.0 und 1.0 liegen."""
|
||||
import uuid
|
||||
ev = EvidenceItem(
|
||||
id=str(uuid.uuid4()),
|
||||
evidence_type="visual",
|
||||
capture_type="screenshot",
|
||||
image_source="https://x.com",
|
||||
description="Test",
|
||||
confidence=0.0,
|
||||
)
|
||||
assert ev.confidence == 0.0
|
||||
|
||||
ev2 = EvidenceItem(
|
||||
id=str(uuid.uuid4()),
|
||||
evidence_type="visual",
|
||||
capture_type="screenshot",
|
||||
image_source="https://x.com",
|
||||
description="Test",
|
||||
confidence=1.0,
|
||||
)
|
||||
assert ev2.confidence == 1.0
|
||||
|
||||
def test_confidence_clamped_low(self) -> None:
|
||||
"""confidence < 0.0 wird abgelehnt (Pydantic validation error)."""
|
||||
import uuid
|
||||
with pytest.raises(Exception):
|
||||
EvidenceItem(
|
||||
id=str(uuid.uuid4()),
|
||||
evidence_type="visual",
|
||||
capture_type="screenshot",
|
||||
image_source="https://x.com",
|
||||
description="Test",
|
||||
confidence=-0.5,
|
||||
)
|
||||
|
||||
def test_confidence_clamped_high(self) -> None:
|
||||
"""confidence > 1.0 wird abgelehnt (Pydantic validation error)."""
|
||||
import uuid
|
||||
with pytest.raises(Exception):
|
||||
EvidenceItem(
|
||||
id=str(uuid.uuid4()),
|
||||
evidence_type="visual",
|
||||
capture_type="screenshot",
|
||||
image_source="https://x.com",
|
||||
description="Test",
|
||||
confidence=1.5,
|
||||
)
|
||||
|
||||
def test_empty_key_findings(self) -> None:
|
||||
"""key_findings kann leer sein."""
|
||||
import uuid
|
||||
ev = EvidenceItem(
|
||||
id=str(uuid.uuid4()),
|
||||
evidence_type="visual",
|
||||
capture_type="screenshot",
|
||||
image_source="https://x.com",
|
||||
description="Test",
|
||||
key_findings=[],
|
||||
)
|
||||
assert ev.key_findings == []
|
||||
|
||||
def test_empty_data_points(self) -> None:
|
||||
"""data_points kann leer sein."""
|
||||
import uuid
|
||||
ev = EvidenceItem(
|
||||
id=str(uuid.uuid4()),
|
||||
evidence_type="visual",
|
||||
capture_type="screenshot",
|
||||
image_source="https://x.com",
|
||||
description="Test",
|
||||
data_points=[],
|
||||
)
|
||||
assert ev.data_points == []
|
||||
|
||||
def test_metadata_default(self) -> None:
|
||||
"""metadata default ist leeres Dict."""
|
||||
import uuid
|
||||
ev = EvidenceItem(
|
||||
id=str(uuid.uuid4()),
|
||||
evidence_type="visual",
|
||||
capture_type="screenshot",
|
||||
image_source="https://x.com",
|
||||
description="Test",
|
||||
)
|
||||
assert ev.metadata == {}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test Group 17–24: JSON-Response-Parsing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestParseVisionResponse:
|
||||
"""Tests für _parse_vision_response — Robustheit gegen verschiedene Formate."""
|
||||
|
||||
def test_parse_json_dict(self) -> None:
|
||||
"""JSON-Objekt mit key_findings wird extrahiert."""
|
||||
data = {
|
||||
"key_findings": ["Trend A", "Trend B"],
|
||||
"data_points": [{"value": 42}],
|
||||
"confidence": 0.95,
|
||||
"image_source": "https://x.com",
|
||||
"description": "Zusammenfassung",
|
||||
}
|
||||
result = _parse_vision_response(json.dumps(data))
|
||||
assert result["key_findings"] == ["Trend A", "Trend B"]
|
||||
assert result["confidence"] == 0.95
|
||||
assert result["image_source"] == "https://x.com"
|
||||
assert result["description"] == "Zusammenfassung"
|
||||
|
||||
def test_parse_json_array(self) -> None:
|
||||
"""JSON-Array wird als Liste von Findings interpretiert."""
|
||||
data = ["Fund 1", "Fund 2", "Fund 3"]
|
||||
result = _parse_vision_response(json.dumps(data))
|
||||
assert "Fund 1" in result["key_findings"]
|
||||
assert "Fund 2" in result["key_findings"]
|
||||
|
||||
def test_parse_code_block_json(self) -> None:
|
||||
"""JSON in Markdown-Code-Block wird extrahiert."""
|
||||
response = '```json\n{"key_findings": ["Code Block"], "confidence": 0.7}\n```'
|
||||
result = _parse_vision_response(response)
|
||||
assert "Code Block" in result["key_findings"]
|
||||
assert result["confidence"] == 0.7
|
||||
|
||||
def test_parse_code_block_no_lang(self) -> None:
|
||||
"""Code-Block ohne language-Tag wird extrahiert."""
|
||||
response = '```\n{"key_findings": ["No Lang"]}\n```'
|
||||
result = _parse_vision_response(response)
|
||||
assert "No Lang" in result["key_findings"]
|
||||
|
||||
def test_parse_invalid_json_fallback(self) -> None:
|
||||
"""Ungültiges JSON → Fallback: Text als Beschreibung."""
|
||||
result = _parse_vision_response("Das ist kein JSON!")
|
||||
assert result["description"] == "Das ist kein JSON!"
|
||||
assert result["key_findings"] == ["Das ist kein JSON!"]
|
||||
assert result["confidence"] == 0.8
|
||||
|
||||
def test_parse_empty_string(self) -> None:
|
||||
"""Leere Antwort → leere Beschreibung."""
|
||||
result = _parse_vision_response("")
|
||||
assert result["description"] == "Keine Inhalte erkannt"
|
||||
assert result["key_findings"] == []
|
||||
|
||||
def test_parse_nested_json(self) -> None:
|
||||
"""Verschachteltes JSON wird korrekt extrahiert."""
|
||||
data = {
|
||||
"description": "Nested Report",
|
||||
"key_findings": [
|
||||
{"type": "trend", "text": "Aufwärts"},
|
||||
{"type": "anomaly", "text": "Ausreißer"},
|
||||
],
|
||||
"data_points": [
|
||||
{"label": "Q1", "value": 100},
|
||||
{"label": "Q2", "value": 150},
|
||||
],
|
||||
"metadata": {"model": "qwen2.5-vl-3b"},
|
||||
}
|
||||
result = _parse_vision_response(json.dumps(data))
|
||||
assert result["description"] == "Nested Report"
|
||||
assert len(result["key_findings"]) == 2
|
||||
assert result["key_findings"][0] == {"type": "trend", "text": "Aufwärts"}
|
||||
assert result["data_points"][0]["label"] == "Q1"
|
||||
assert result["metadata"]["model"] == "qwen2.5-vl-3b"
|
||||
|
||||
def test_parse_extra_text_before_json(self) -> None:
|
||||
"""Text vor JSON wird ignoriert, JSON wird geparst."""
|
||||
response = '```json\n{"key_findings": ["After Text"], "confidence": 0.85}\n```'
|
||||
result = _parse_vision_response(response)
|
||||
assert "After Text" in result["key_findings"]
|
||||
assert result["confidence"] == 0.85
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test Group 25–31: Prompt-Generierung
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestBuildPrompt:
|
||||
"""Tests für _build_prompt — Prompt-Kombination."""
|
||||
|
||||
def test_base_prompt_includes_all_topics(self) -> None:
|
||||
"""Basis-Prompt erwähnt alle Analyse-Themen."""
|
||||
prompt = _build_prompt("screenshot", None, None)
|
||||
assert "visuelle Inhalte" in prompt
|
||||
assert "Textinhalte" in prompt
|
||||
assert "Daten" in prompt
|
||||
assert "Trends" in prompt
|
||||
assert "fact-checking" in prompt
|
||||
|
||||
def test_prompt_contains_image_data_ref(self) -> None:
|
||||
"""Bild-Referenz im Prompt für Vision-Modell."""
|
||||
prompt = _build_prompt("screenshot", None, None)
|
||||
# Basis-Prompt erwähnt visuelle Analyse
|
||||
assert "Bilder" in prompt or "visuell" in prompt or "Bild" in prompt
|
||||
|
||||
def test_capture_type_includes_screenshot(self) -> None:
|
||||
"""screenshot Capture Type → Standard-Prompt."""
|
||||
prompt = _build_prompt("screenshot", None, None)
|
||||
assert "screenshot" in prompt or len(prompt) > 50
|
||||
|
||||
def test_capture_type_infographic(self) -> None:
|
||||
"""infographic Capture Type → Typ im Prompt."""
|
||||
prompt = _build_prompt("infographic", None, None)
|
||||
assert "infographic" in prompt
|
||||
|
||||
def test_image_caption_appended(self) -> None:
|
||||
"""image_caption wird vor den Prompt gesetzt."""
|
||||
caption = "Diagramm zeigt Umsatzentwicklung 2024"
|
||||
prompt = _build_prompt("chart", caption, None)
|
||||
assert caption in prompt
|
||||
# Caption steht im Prompt
|
||||
assert prompt.index(caption) >= 0
|
||||
|
||||
def test_custom_prompt_overrides(self) -> None:
|
||||
"""Custom-Prompt wird verwendet, nicht der Default."""
|
||||
custom = "Finde alle Diagramme in diesem Bild"
|
||||
prompt = _build_prompt("chart", None, custom)
|
||||
assert "Finde alle Diagramme" in prompt
|
||||
|
||||
def test_truncation_large_caption(self) -> None:
|
||||
"""Sehr langer Caption → Prompt wird nicht unendlich."""
|
||||
long_caption = "x" * 10000
|
||||
prompt = _build_prompt("screenshot", long_caption, None)
|
||||
# Prompt sollte nicht die Python-Grenze sprengen
|
||||
assert len(prompt) < 50000
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test Group 32–36: _image_source_label
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestImageSourceLabel:
|
||||
"""Tests für _image_source_label."""
|
||||
|
||||
def test_url_source(self) -> None:
|
||||
"""HTTP/HTTPS-URL wird als Quelle gemeldet."""
|
||||
req = AnalyzeImageRequest(image_data="https://example.com/img.png")
|
||||
label = _image_source_label(req)
|
||||
assert "example.com" in label
|
||||
|
||||
def test_base64_source(self) -> None:
|
||||
"""Base64-Daten werden gemeldet."""
|
||||
req = AnalyzeImageRequest(image_data="data:image/png;base64,abc123")
|
||||
label = _image_source_label(req)
|
||||
assert "base64" in label
|
||||
|
||||
def test_short_url(self) -> None:
|
||||
"""Kurze URL ohne Ellipsis."""
|
||||
req = AnalyzeImageRequest(image_data="https://x.com")
|
||||
label = _image_source_label(req)
|
||||
assert "..." not in label or "example" not in label
|
||||
|
||||
def test_uploaded_image(self) -> None:
|
||||
"""Unbekannte Datenquelle → uploaded_image."""
|
||||
req = AnalyzeImageRequest(image_data="not_a_url_or_data")
|
||||
label = _image_source_label(req)
|
||||
assert label == "uploaded_image"
|
||||
|
||||
def test_very_long_url_truncated(self) -> None:
|
||||
"""Sehr lange URLs werden gekürzt."""
|
||||
long_url = "https://" + "x" * 500 + ".png"
|
||||
req = AnalyzeImageRequest(image_data=long_url)
|
||||
label = _image_source_label(req)
|
||||
assert "..." in label or len(label) <= 123
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test Group 37–41: Store-Get Functions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestEvidenceStore:
|
||||
"""Tests für _store_evidence und _get_evidence."""
|
||||
|
||||
def test_store_and_retrieve(self) -> None:
|
||||
"""Eintrag speichern und wieder abrufen."""
|
||||
import uuid
|
||||
ev_id = str(uuid.uuid4())
|
||||
ev = EvidenceItem(
|
||||
id=ev_id,
|
||||
evidence_type="visual",
|
||||
capture_type="screenshot",
|
||||
image_source="https://x.com",
|
||||
description="Test",
|
||||
)
|
||||
_store_evidence(ev)
|
||||
retrieved = _get_evidence(ev_id)
|
||||
assert retrieved is not None
|
||||
assert retrieved.id == ev_id
|
||||
assert retrieved.description == "Test"
|
||||
|
||||
def test_get_nonexistent(self) -> None:
|
||||
"""Nicht vorhandene ID → None."""
|
||||
import uuid
|
||||
nonexistent = str(uuid.uuid4())
|
||||
result = _get_evidence(nonexistent)
|
||||
assert result is None
|
||||
|
||||
def test_overwrite_existing(self) -> None:
|
||||
"""Store überschreibt bestehende IDs."""
|
||||
import uuid
|
||||
ev_id = str(uuid.uuid4())
|
||||
_store_evidence(EvidenceItem(
|
||||
id=ev_id,
|
||||
evidence_type="visual",
|
||||
capture_type="screenshot",
|
||||
image_source="source1",
|
||||
description="V1",
|
||||
))
|
||||
_store_evidence(EvidenceItem(
|
||||
id=ev_id,
|
||||
evidence_type="visual",
|
||||
capture_type="screenshot",
|
||||
image_source="source2",
|
||||
description="V2",
|
||||
))
|
||||
result = _get_evidence(ev_id)
|
||||
assert result.description == "V2"
|
||||
assert result.image_source == "source2"
|
||||
|
||||
def test_multiple_evidence_ids(self) -> None:
|
||||
"""Mehrere Evidenz-Einträge koexistieren."""
|
||||
import uuid
|
||||
id1 = str(uuid.uuid4())
|
||||
id2 = str(uuid.uuid4())
|
||||
_store_evidence(EvidenceItem(
|
||||
id=id1, evidence_type="visual", capture_type="screenshot",
|
||||
image_source="src1", description="E1",
|
||||
))
|
||||
_store_evidence(EvidenceItem(
|
||||
id=id2, evidence_type="document", capture_type="document",
|
||||
image_source="src2", description="E2",
|
||||
))
|
||||
ev1 = _get_evidence(id1)
|
||||
ev2 = _get_evidence(id2)
|
||||
assert ev1 is not None
|
||||
assert ev2 is not None
|
||||
assert ev1.description == "E1"
|
||||
assert ev2.description == "E2"
|
||||
|
||||
def test_empty_description(self) -> None:
|
||||
"""Leere Beschreibung wird gespeichert."""
|
||||
import uuid
|
||||
ev = EvidenceItem(
|
||||
id=str(uuid.uuid4()),
|
||||
evidence_type="visual",
|
||||
capture_type="screenshot",
|
||||
image_source="https://x.com",
|
||||
description="",
|
||||
)
|
||||
_store_evidence(ev)
|
||||
result = _get_evidence(ev.id)
|
||||
assert result is not None
|
||||
assert result.description == ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test Group 42–48: API-Integration — POST /vision/analyze (mit TestClient)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAPIAnalyze:
|
||||
"""Integrationstests für POST /vision/analyze."""
|
||||
|
||||
def test_analyze_returns_evidence_id(self, clean_env) -> None:
|
||||
"""Antwort enthält evidence_id."""
|
||||
from fastapi.testclient import TestClient
|
||||
from nsct.api.main import create_app
|
||||
|
||||
fastapi_app = create_app()
|
||||
|
||||
with TestClient(fastapi_app) as client:
|
||||
resp = client.post(
|
||||
"/vision/analyze",
|
||||
json={
|
||||
"image_data": _BASE64_DATA,
|
||||
"capture_type": "screenshot",
|
||||
"prompt": "Finde Evidenz",
|
||||
},
|
||||
)
|
||||
# Bei fehlendem Provider → 500 (Fallback)
|
||||
# Oder 200 mit Fallback-Evidence
|
||||
assert resp.status_code in (200, 500)
|
||||
if resp.status_code == 200:
|
||||
data = resp.json()
|
||||
assert "evidence_id" in data
|
||||
|
||||
def test_analyze_empty_image_data(self, clean_env) -> None:
|
||||
"""Empty image_data → 422 (Pydantic validation error)."""
|
||||
from fastapi.testclient import TestClient
|
||||
from nsct.api.main import create_app
|
||||
fastapi_app = create_app()
|
||||
with TestClient(fastapi_app) as client:
|
||||
resp = client.post("/vision/analyze", json={"image_data": ""})
|
||||
assert resp.status_code == 422
|
||||
|
||||
def test_analyze_no_image_data(self, clean_env) -> None:
|
||||
"""Kein image_data-Feld → 422."""
|
||||
from fastapi.testclient import TestClient
|
||||
from nsct.api.main import create_app
|
||||
fastapi_app = create_app()
|
||||
with TestClient(fastapi_app) as client:
|
||||
resp = client.post("/vision/analyze", json={})
|
||||
assert resp.status_code == 422
|
||||
|
||||
def test_analyze_with_url(self, clean_env) -> None:
|
||||
"""Bild als URL akzeptiert."""
|
||||
from fastapi.testclient import TestClient
|
||||
from nsct.api.main import create_app
|
||||
fastapi_app = create_app()
|
||||
with TestClient(fastapi_app) as client:
|
||||
resp = client.post(
|
||||
"/vision/analyze",
|
||||
json={
|
||||
"image_data": "https://example.com/test.png",
|
||||
"capture_type": "photo",
|
||||
},
|
||||
)
|
||||
# 200 (fallback) oder 500 (LLM error)
|
||||
assert resp.status_code in (200, 500)
|
||||
|
||||
def test_analyze_multiple_images_sequence(self, clean_env) -> None:
|
||||
"""Multiple Bilder nacheinander analysieren."""
|
||||
from fastapi.testclient import TestClient
|
||||
from nsct.api.main import create_app
|
||||
fastapi_app = create_app()
|
||||
with TestClient(fastapi_app) as client:
|
||||
ids = []
|
||||
for i in range(3):
|
||||
resp = client.post(
|
||||
"/vision/analyze",
|
||||
json={
|
||||
"image_data": f"data:image/png;base64,img{i}",
|
||||
"capture_type": "screenshot",
|
||||
},
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
data = resp.json()
|
||||
ids.append(data.get("evidence_id", ""))
|
||||
assert len(ids) >= 0 # Mindestens 0 IDs (kann 0 sein bei LLM-Fehler)
|
||||
|
||||
def test_analyze_custom_evidence_type(self, clean_env) -> None:
|
||||
"""Custom evidence_type wird in Antwort zurückgegeben."""
|
||||
from fastapi.testclient import TestClient
|
||||
from nsct.api.main import create_app
|
||||
fastapi_app = create_app()
|
||||
with TestClient(fastapi_app) as client:
|
||||
resp = client.post(
|
||||
"/vision/analyze",
|
||||
json={
|
||||
"image_data": _BASE64_DATA,
|
||||
"evidence_type": "infographic",
|
||||
},
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
data = resp.json()
|
||||
assert data["evidence_type"] == "infographic"
|
||||
|
||||
def test_analyze_image_caption_included(self, clean_env) -> None:
|
||||
"""image_caption wird an Vision-Modell gesendet."""
|
||||
from fastapi.testclient import TestClient
|
||||
from nsct.api.main import create_app
|
||||
fastapi_app = create_app()
|
||||
with TestClient(fastapi_app) as client:
|
||||
resp = client.post(
|
||||
"/vision/analyze",
|
||||
json={
|
||||
"image_data": _BASE64_DATA,
|
||||
"image_caption": "Diagramm mit Umsatzdaten",
|
||||
},
|
||||
)
|
||||
assert resp.status_code in (200, 500)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test Group 49–54: API-Integration — GET /vision/evidence/{evidence_id}
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAPIGetEvidence:
|
||||
"""Integrationstests für GET /vision/evidence/{evidence_id}."""
|
||||
|
||||
def test_get_valid_evidence(self, clean_env) -> None:
|
||||
"""Existierender Evidenz-Eintrag wird gefunden."""
|
||||
from fastapi.testclient import TestClient
|
||||
from nsct.api.main import create_app
|
||||
fastapi_app = create_app()
|
||||
with TestClient(fastapi_app) as client:
|
||||
resp = client.post(
|
||||
"/vision/analyze",
|
||||
json={"image_data": _BASE64_DATA, "capture_type": "screenshot"},
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
evidence_id = resp.json()["evidence_id"]
|
||||
resp2 = client.get(f"/vision/evidence/{evidence_id}")
|
||||
assert resp2.status_code == 200
|
||||
data = resp2.json()
|
||||
assert data["success"] is True
|
||||
assert data["evidence"] is not None
|
||||
|
||||
def test_get_nonexistent_evidence(self, clean_env) -> None:
|
||||
"""Nicht vorhandener Evidenz-Eintrag → 404."""
|
||||
from fastapi.testclient import TestClient
|
||||
from nsct.api.main import create_app
|
||||
fastapi_app = create_app()
|
||||
import uuid
|
||||
fake_id = str(uuid.uuid4())
|
||||
with TestClient(fastapi_app) as client:
|
||||
resp = client.get(f"/vision/evidence/{fake_id}")
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_get_empty_evidence_id(self, clean_env) -> None:
|
||||
"""Leere evidence_id → 400."""
|
||||
from fastapi.testclient import TestClient
|
||||
from nsct.api.main import create_app
|
||||
fastapi_app = create_app()
|
||||
with TestClient(fastapi_app) as client:
|
||||
resp = client.get("/vision/evidence/")
|
||||
assert resp.status_code in (400, 404, 422)
|
||||
|
||||
def test_get_evidence_after_store(self, clean_env) -> None:
|
||||
"""Eintrag direkt im Store → GET findet ihn."""
|
||||
import uuid
|
||||
ev_id = str(uuid.uuid4())
|
||||
ev = EvidenceItem(
|
||||
id=ev_id,
|
||||
evidence_type="visual",
|
||||
capture_type="screenshot",
|
||||
image_source="direct_store",
|
||||
description="Direct Store Test",
|
||||
)
|
||||
_store_evidence(ev)
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
from nsct.api.main import create_app
|
||||
fastapi_app = create_app()
|
||||
with TestClient(fastapi_app) as client:
|
||||
resp = client.get(f"/vision/evidence/{ev_id}")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["success"] is True
|
||||
assert data["evidence"]["description"] == "Direct Store Test"
|
||||
|
||||
def test_get_evidence_response_structure(self, clean_env) -> None:
|
||||
"""GET-Antwort hat korrekte Struktur."""
|
||||
import uuid
|
||||
ev_id = str(uuid.uuid4())
|
||||
ev = EvidenceItem(
|
||||
id=ev_id,
|
||||
evidence_type="visual",
|
||||
capture_type="screenshot",
|
||||
image_source="test",
|
||||
description="Structure Test",
|
||||
key_findings=["Finding A"],
|
||||
data_points=[{"value": 1}],
|
||||
confidence=0.9,
|
||||
)
|
||||
_store_evidence(ev)
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
from nsct.api.main import create_app
|
||||
fastapi_app = create_app()
|
||||
with TestClient(fastapi_app) as client:
|
||||
resp = client.get(f"/vision/evidence/{ev_id}")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "success" in data
|
||||
assert "evidence" in data
|
||||
ev_data = data["evidence"]
|
||||
assert "id" in ev_data
|
||||
assert "key_findings" in ev_data
|
||||
assert "data_points" in ev_data
|
||||
assert "confidence" in ev_data
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test Group 55–60: Edge Cases & Fallbacks
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestEdgeCases:
|
||||
"""Tests für Edge Cases und Fallbacks."""
|
||||
|
||||
def test_parse_null_response(self) -> None:
|
||||
"""None/Null-Response wird behandelt."""
|
||||
result = _parse_vision_response(None) # type: ignore[arg-type]
|
||||
assert result["description"] == "Keine Inhalte erkannt"
|
||||
|
||||
def test_parse_whitespace_only(self) -> None:
|
||||
"""Nur Whitespace → leere Beschreibung."""
|
||||
result = _parse_vision_response(" \n \t ")
|
||||
assert result["description"] == "Keine Inhalte erkannt"
|
||||
|
||||
def test_parse_json_with_extra_fields(self) -> None:
|
||||
"""JSON mit zusätzlichen Feldern wird ignoriert."""
|
||||
data = {
|
||||
"key_findings": ["A"],
|
||||
"extra_field": "ignored",
|
||||
"another_extra": 42,
|
||||
}
|
||||
result = _parse_vision_response(json.dumps(data))
|
||||
assert "A" in result["key_findings"]
|
||||
assert "extra_field" not in result
|
||||
|
||||
def test_response_list_mixed_types(self) -> None:
|
||||
"""JSON-Array mit gemischten Typen."""
|
||||
data = ["text", 42, True, None, {"key": "val"}]
|
||||
result = _parse_vision_response(json.dumps(data))
|
||||
# Alle Elemente werden zu Strings konvertiert
|
||||
assert "text" in result["key_findings"]
|
||||
assert len(result["key_findings"]) > 0
|
||||
|
||||
def test_prompt_with_all_optional_fields(self) -> None:
|
||||
"""Prompt mit allen optionalen Feldern."""
|
||||
prompt = _build_prompt(
|
||||
capture_type="infographic",
|
||||
image_caption="Beschriftung",
|
||||
custom_prompt="Finde Charts",
|
||||
)
|
||||
assert "Beschriftung" in prompt
|
||||
assert "infographic" in prompt
|
||||
assert "Finde Charts" in prompt
|
||||
|
||||
def test_image_data_very_long(self) -> None:
|
||||
"""Extrem lange Base64-Daten werden akzeptiert."""
|
||||
long_data = "a" * 1000000 # 1MB
|
||||
req = AnalyzeImageRequest(image_data=long_data)
|
||||
assert len(req.image_data) == 1000000
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test Group 61–65: AnalyzeImageResponse Struktur
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAnalyzeImageResponse:
|
||||
"""Tests für AnalyzeImageResponse-Struktur."""
|
||||
|
||||
def test_response_has_all_fields(self) -> None:
|
||||
"""AnalyzeImageResponse hat alle erwarteten Felder."""
|
||||
import uuid
|
||||
ev_id = str(uuid.uuid4())
|
||||
resp = AnalyzeImageResponse(
|
||||
evidence_id=ev_id,
|
||||
evidence_type="visual",
|
||||
capture_type="screenshot",
|
||||
description="Test",
|
||||
findings=["A"],
|
||||
confidence=0.9,
|
||||
data_points=[{"value": 1}],
|
||||
image_source="https://x.com",
|
||||
metadata={"model": "test"},
|
||||
)
|
||||
assert resp.evidence_id == ev_id
|
||||
assert resp.evidence_type == "visual"
|
||||
assert resp.capture_type == "screenshot"
|
||||
assert resp.description == "Test"
|
||||
assert resp.findings == ["A"]
|
||||
assert resp.confidence == 0.9
|
||||
assert resp.data_points == [{"value": 1}]
|
||||
assert resp.image_source == "https://x.com"
|
||||
assert resp.metadata == {"model": "test"}
|
||||
|
||||
def test_response_defaults(self) -> None:
|
||||
"""AnalyzeImageResponse mit Minimal-Parametern."""
|
||||
import uuid
|
||||
resp = AnalyzeImageResponse(
|
||||
evidence_id=str(uuid.uuid4()),
|
||||
evidence_type="visual",
|
||||
capture_type="screenshot",
|
||||
description="Min",
|
||||
image_source="x",
|
||||
confidence=0.5,
|
||||
)
|
||||
assert resp.findings == []
|
||||
assert resp.confidence is not None
|
||||
assert resp.data_points == []
|
||||
assert resp.metadata == {}
|
||||
|
||||
def test_response_confidence_range(self) -> None:
|
||||
"""confidence im Response muss 0-1 sein."""
|
||||
import uuid
|
||||
resp = AnalyzeImageResponse(
|
||||
evidence_id=str(uuid.uuid4()),
|
||||
evidence_type="visual",
|
||||
capture_type="screenshot",
|
||||
description="Test",
|
||||
image_source="x",
|
||||
confidence=0.0,
|
||||
)
|
||||
assert resp.confidence == 0.0
|
||||
|
||||
def test_response_empty_findings(self) -> None:
|
||||
"""Leere findings-Liste."""
|
||||
import uuid
|
||||
resp = AnalyzeImageResponse(
|
||||
evidence_id=str(uuid.uuid4()),
|
||||
evidence_type="visual",
|
||||
capture_type="screenshot",
|
||||
description="Test",
|
||||
image_source="x",
|
||||
findings=[],
|
||||
confidence=0.5,
|
||||
)
|
||||
assert resp.findings == []
|
||||
|
||||
def test_response_data_points_structure(self) -> None:
|
||||
"""data_points sind List von Dicts."""
|
||||
import uuid
|
||||
resp = AnalyzeImageResponse(
|
||||
evidence_id=str(uuid.uuid4()),
|
||||
evidence_type="visual",
|
||||
capture_type="screenshot",
|
||||
description="Test",
|
||||
image_source="x",
|
||||
data_points=[
|
||||
{"key": "val1"},
|
||||
{"key": "val2"},
|
||||
],
|
||||
confidence=0.5,
|
||||
)
|
||||
assert len(resp.data_points) == 2
|
||||
assert isinstance(resp.data_points[0], dict)
|
||||
Reference in New Issue
Block a user