test(07-02): promote 6 Wave-2 xfail tests to passing — D-01/D-02/D-07/D-12/D-13

- test_get_provider_typed: registry get_provider() returns correct class + ValueError on bogus id
- test_client_singleton: AsyncOpenAI instantiated once per provider instance (D-07)
- test_generic_openai_json_mode: response_format present/absent based on supports_json_mode (D-01)
- test_context_chars_truncation: _truncate() shortens long text (D-12)
- test_smart_truncation: 60% head + 40% tail truncation strategy (D-13)
- test_gemini_fallback_to_parse_classification: supports_json_mode=False skips
  response_format AND routes through parse_classification() from ai.utils (D-02 contract)
- test_anthropic_structured_output remains xfail (Plan 07-03)
This commit is contained in:
curo1305
2026-06-04 19:01:38 +02:00
parent 13eef371bc
commit 209b156af1
+184 -33
View File
@@ -1,46 +1,197 @@
""" """
Wave 0 xfail stubs for Phase 7 AI provider tests. Tests for Phase 7 AI provider refactor.
Each function is a placeholder for a test that will be promoted to green Wave 2 (Plan 07-02) promotes: test_get_provider_typed, test_client_singleton,
in a later plan wave (per 07-VALIDATION.md per-task-verification map). test_generic_openai_json_mode, test_context_chars_truncation, test_smart_truncation,
test_gemini_fallback_to_parse_classification.
Stub policy (STATE.md decision: xfail(strict=False) for Wave 0): Remaining stubs (promoted in Plan 07-03): test_anthropic_structured_output.
- Body is a single pytest.xfail() call — no assertion code.
- strict=False so unexpected passes (xpass) never break CI.
""" """
import pytest import pytest
from unittest.mock import AsyncMock, MagicMock, patch
from ai.generic_openai_provider import GenericOpenAIProvider
from ai.openai_provider import OpenAIProvider
from ai.provider_config import ProviderConfig, PROVIDER_DEFAULTS
from ai.utils import parse_classification
@pytest.mark.xfail(strict=False, reason="Wave 0 stub — promoted in Plan 07-02") # ---------------------------------------------------------------------------
# Task 3: Registry-based get_provider(config: ProviderConfig) — D-06
# ---------------------------------------------------------------------------
def test_get_provider_typed():
"""get_provider() accepts a ProviderConfig and returns the correct provider class."""
from ai import get_provider
# Groq → GenericOpenAIProvider with supports_json_mode=True
config = ProviderConfig(provider_id="groq")
result = get_provider(config)
assert isinstance(result, GenericOpenAIProvider)
assert result.supports_json_mode is True
assert result._context_chars == PROVIDER_DEFAULTS["groq"]["context_chars"]
# Gemini → GenericOpenAIProvider with supports_json_mode=False (D-02)
config_gemini = ProviderConfig(provider_id="gemini")
result_gemini = get_provider(config_gemini)
assert isinstance(result_gemini, GenericOpenAIProvider)
assert result_gemini.supports_json_mode is False
# Unknown provider → ValueError
config_bogus = ProviderConfig(provider_id="bogus")
with pytest.raises(ValueError, match="Unknown AI provider"):
get_provider(config_bogus)
# ---------------------------------------------------------------------------
# Task 4: Singleton client lifecycle — D-07
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_client_singleton():
"""AsyncOpenAI is instantiated exactly once per OpenAIProvider instance (D-07)."""
with patch("ai.openai_provider.AsyncOpenAI") as mock_cls:
# mock_cls() returns a mock instance — configure chat.completions.create
mock_instance = MagicMock()
mock_instance.chat = MagicMock()
mock_instance.chat.completions = MagicMock()
mock_instance.chat.completions.create = AsyncMock(
return_value=MagicMock(
choices=[MagicMock(message=MagicMock(content='{"assigned_topics":[],"new_topic_suggestions":[]}'))]
)
)
mock_cls.return_value = mock_instance
provider = OpenAIProvider(api_key="test-key", model="gpt-4o", base_url=None, context_chars=1000)
# Call classify twice on the same instance
await provider.classify("doc text", [], "sys")
await provider.classify("doc text", [], "sys")
# AsyncOpenAI class should have been called exactly once (in __init__)
assert mock_cls.call_count == 1
# ---------------------------------------------------------------------------
# Task 4: JSON-mode conditional — D-01
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_generic_openai_json_mode(): async def test_generic_openai_json_mode():
pytest.xfail("not implemented yet — Plan 07-02") """GenericOpenAIProvider passes response_format only when supports_json_mode=True."""
synthetic_response = MagicMock(
choices=[MagicMock(message=MagicMock(
content='{"assigned_topics":["finance"],"new_topic_suggestions":[]}'
))]
)
# supports_json_mode=True → response_format present in call kwargs
with patch("ai.openai_provider.AsyncOpenAI") as mock_cls:
mock_instance = MagicMock()
mock_instance.chat.completions.create = AsyncMock(return_value=synthetic_response)
mock_cls.return_value = mock_instance
provider_json = GenericOpenAIProvider(
api_key="key", model="gpt-4o", base_url=None,
context_chars=1000, supports_json_mode=True
)
await provider_json.classify("text", [], "sys")
call_kwargs = mock_instance.chat.completions.create.call_args.kwargs
assert "response_format" in call_kwargs
assert call_kwargs["response_format"] == {"type": "json_object"}
# supports_json_mode=False → response_format absent from call kwargs (Gemini preset path)
with patch("ai.openai_provider.AsyncOpenAI") as mock_cls2:
mock_instance2 = MagicMock()
mock_instance2.chat.completions.create = AsyncMock(return_value=synthetic_response)
mock_cls2.return_value = mock_instance2
provider_no_json = GenericOpenAIProvider(
api_key="key", model="gemini-2.0-flash",
base_url="https://generativelanguage.googleapis.com/v1beta/openai/",
context_chars=1000, supports_json_mode=False
)
await provider_no_json.classify("text", [], "sys")
call_kwargs2 = mock_instance2.chat.completions.create.call_args.kwargs
assert "response_format" not in call_kwargs2
# ---------------------------------------------------------------------------
# Task 4: Smart truncation — D-12/D-13
# ---------------------------------------------------------------------------
def test_context_chars_truncation():
"""Provider with context_chars=100 truncates a 500-char input."""
provider = OpenAIProvider(api_key="", model="gpt-4o", base_url=None, context_chars=100)
long_text = "a" * 500
result = provider._truncate(long_text)
assert len(result) < 500
assert "[...truncated...]" in result
def test_smart_truncation():
"""_truncate uses 60% head + 40% tail of context_chars."""
provider = OpenAIProvider(api_key="", model="gpt-4o", base_url=None, context_chars=1000)
# Build a distinguishable input where head and tail chars differ
input_text = "H" * 5000 + "T" * 5000 # 10000 chars total
result = provider._truncate(input_text)
# head = int(1000 * 0.6) = 600, tail = 1000 - 600 = 400
assert result.startswith("H" * 600)
assert result.endswith("T" * 400)
assert "[...truncated...]" in result
# ---------------------------------------------------------------------------
# Task 4: Gemini fallback to parse_classification (D-02 contract enforcement)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_gemini_fallback_to_parse_classification():
"""D-02: GenericOpenAIProvider(supports_json_mode=False) calls parse_classification()
and does NOT send response_format to the API.
"""
raw_content = '{"assigned_topics":["x"],"new_topic_suggestions":[],"reasoning":"r"}'
with patch("ai.openai_provider.AsyncOpenAI") as mock_cls:
mock_create = AsyncMock(
return_value=MagicMock(
choices=[MagicMock(message=MagicMock(content=raw_content))]
)
)
mock_instance = MagicMock()
mock_instance.chat.completions.create = mock_create
mock_cls.return_value = mock_instance
# Wrap parse_classification so we can assert it was called
with patch(
"ai.generic_openai_provider.parse_classification",
wraps=parse_classification,
) as mock_parse:
provider = GenericOpenAIProvider(
api_key="",
model="gemini-2.0-flash",
base_url="https://generativelanguage.googleapis.com/v1beta/openai/",
context_chars=8000,
supports_json_mode=False,
)
result = await provider.classify("doc text", [], "sys")
# Result must be a valid ClassificationResult
assert result.topics == ["x"]
# parse_classification was called with the raw content (D-02 contract)
assert mock_parse.called
mock_parse.assert_called_once_with(raw_content)
# response_format must NOT have been sent to the API (Gemini preset path)
call_kwargs = mock_create.call_args.kwargs
assert "response_format" not in call_kwargs
# ---------------------------------------------------------------------------
# Stub: promoted in Plan 07-03
# ---------------------------------------------------------------------------
@pytest.mark.xfail(strict=False, reason="Wave 0 stub — promoted in Plan 07-03") @pytest.mark.xfail(strict=False, reason="Wave 0 stub — promoted in Plan 07-03")
async def test_anthropic_structured_output(): async def test_anthropic_structured_output():
pytest.xfail("not implemented yet — Plan 07-03") pytest.xfail("not implemented yet — Plan 07-03")
@pytest.mark.xfail(strict=False, reason="Wave 0 stub — promoted in Plan 07-02")
async def test_get_provider_typed():
pytest.xfail("not implemented yet — Plan 07-02")
@pytest.mark.xfail(strict=False, reason="Wave 0 stub — promoted in Plan 07-02")
async def test_client_singleton():
pytest.xfail("not implemented yet — Plan 07-02")
@pytest.mark.xfail(strict=False, reason="Wave 0 stub — promoted in Plan 07-03")
async def test_context_chars_truncation():
pytest.xfail("not implemented yet — Plan 07-03")
@pytest.mark.xfail(strict=False, reason="Wave 0 stub — promoted in Plan 07-03")
async def test_smart_truncation():
pytest.xfail("not implemented yet — Plan 07-03")
@pytest.mark.xfail(strict=False, reason="Wave 0 stub — promoted in Plan 07-02 Task 4 (D-02 Gemini fallback path)")
async def test_gemini_fallback_to_parse_classification():
pytest.xfail("not implemented yet — Plan 07-02")