12 known failures — all from Agent 3 new unverified tests (mock setup issues): - 3 dataset_review_routes_extended (DTO field mismatches) - 1 settings_consolidated (dict key access) - 1 llm_analysis_service_coverage (rate_limit mock) - 1 migration_plugin (SessionLocal side_effect exhaustion) - 1 preview (DB query vs dict key) - 5 scheduler (datetime timezone + async mock mismatches) NEW TEST FILES THIS SESSION: - test_batch_insert_coverage.py — 3 tests - test_storage_plugin.py — +3 tests - test_search.py — +2 tests - test_mapper.py — already 100% - test_llm_analysis_migration_v1_to_v2.py — 14 tests - test_llm_async_http.py — +1 test - test_prompt_builder.py — +1 test - test_service_datasource.py — +1 test - test_lang_detect.py — +1 test - test_scheduler.py — +6 tests - test_llm_analysis_service_coverage.py — +15 tests - test_dataset_review_routes_extended.py — +14 tests - test_settings_consolidated.py — +13 tests Modules pushed to 100%: _batch_insert, dictionary_entries, service_datasource, _llm_async_http, prompt_builder, dictionary_crud, _batch_sizer, storage/plugin, mapper.py Session: 7194→7643 tests (+449), 80%→83% raw (+3pp), 93.4%→96.9% real (+3.5pp). Remaining: 12 failures to fix + ~300 statements to reach 98% real coverage.
263 lines
11 KiB
Python
263 lines
11 KiB
Python
# #region Test.AdaptiveBatchSizer [C:3] [TYPE Module] [SEMANTICS test,batch,sizer,token,budget]
|
|
# @BRIEF Tests for _batch_sizer.py — AdaptiveBatchSizer.
|
|
# @RELATION BINDS_TO -> [AdaptiveBatchSizer]
|
|
# @TEST_EDGE: empty_rows -> empty list
|
|
# @TEST_EDGE: budget_zero -> fallback to fixed size
|
|
# @TEST_EDGE: per_batch_budget_collapsed -> fallback
|
|
|
|
import sys
|
|
from pathlib import Path
|
|
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / "src"))
|
|
|
|
import pytest
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
from src.models.translate import TranslationJob
|
|
from src.plugins.translate._batch_sizer import AdaptiveBatchSizer
|
|
|
|
|
|
class TestResolveProviderConfig:
|
|
"""AdaptiveBatchSizer.resolve_provider_config."""
|
|
|
|
def test_no_provider_id(self):
|
|
"""No provider_id returns empty config."""
|
|
sizer = AdaptiveBatchSizer(MagicMock(), MagicMock())
|
|
job = MagicMock(spec=TranslationJob)
|
|
job.provider_id = None
|
|
config = sizer.resolve_provider_config(job)
|
|
assert config["model"] is None
|
|
assert config["context_window"] is None
|
|
assert config["max_output_tokens"] is None
|
|
|
|
def test_with_provider_id(self):
|
|
"""Provider ID returns token config."""
|
|
sizer = AdaptiveBatchSizer(MagicMock(), MagicMock())
|
|
job = MagicMock(spec=TranslationJob)
|
|
job.provider_id = "prov-1"
|
|
|
|
with patch("src.plugins.translate._batch_sizer.LLMProviderService") as mock_svc:
|
|
mock_instance = MagicMock()
|
|
mock_svc.return_value = mock_instance
|
|
mock_instance.get_provider_token_config.return_value = {
|
|
"model": "gpt-4o", "context_window": 128000, "max_output_tokens": 16384,
|
|
}
|
|
config = sizer.resolve_provider_config(job)
|
|
assert config["model"] == "gpt-4o"
|
|
assert config["context_window"] == 128000
|
|
|
|
def test_exception_returns_empty(self):
|
|
"""Exception in provider returns empty config."""
|
|
sizer = AdaptiveBatchSizer(MagicMock(), MagicMock())
|
|
job = MagicMock(spec=TranslationJob)
|
|
job.provider_id = "prov-err"
|
|
|
|
with patch("src.plugins.translate._batch_sizer.LLMProviderService") as mock_svc:
|
|
mock_instance = MagicMock()
|
|
mock_svc.return_value = mock_instance
|
|
mock_instance.get_provider_token_config.side_effect = Exception("DB error")
|
|
|
|
config = sizer.resolve_provider_config(job)
|
|
assert config["model"] is None
|
|
|
|
|
|
class TestAutoSizeBatches:
|
|
"""AdaptiveBatchSizer.auto_size_batches."""
|
|
|
|
def _make_job(self):
|
|
job = MagicMock(spec=TranslationJob)
|
|
job.provider_id = "prov-1"
|
|
job.translation_column = "source_text"
|
|
job.context_columns = None
|
|
job.batch_size = None
|
|
return job
|
|
|
|
def _make_sizer(self):
|
|
return AdaptiveBatchSizer(MagicMock(), MagicMock())
|
|
|
|
def test_empty_rows(self):
|
|
"""Empty source_rows returns empty list."""
|
|
sizer = self._make_sizer()
|
|
job = self._make_job()
|
|
batches = sizer.auto_size_batches(job, [], ["fr"])
|
|
assert batches == []
|
|
|
|
def test_single_batch(self):
|
|
"""Few rows fit in one batch."""
|
|
sizer = self._make_sizer()
|
|
job = self._make_job()
|
|
|
|
rows = [
|
|
{"source_text": f"text {i}", "source_data": {}}
|
|
for i in range(3)
|
|
]
|
|
|
|
with patch.object(sizer, "resolve_provider_config",
|
|
return_value={"model": "gpt-4o", "context_window": 128000,
|
|
"max_output_tokens": 16384}):
|
|
with patch("src.plugins.translate._batch_sizer.estimate_token_budget") as mock_budget:
|
|
mock_budget.return_value = {
|
|
"batch_size_adjusted": 10,
|
|
"available_input_budget": 100000,
|
|
"estimated_input_tokens": 50000,
|
|
"max_rows_by_output": 20,
|
|
"warning": None,
|
|
}
|
|
with patch("src.plugins.translate._batch_sizer.estimate_row_tokens",
|
|
return_value=100):
|
|
batches = sizer.auto_size_batches(job, rows, ["fr"])
|
|
assert len(batches) == 1
|
|
assert len(batches[0]) == 3
|
|
|
|
def test_fallback_to_fixed_size(self):
|
|
"""When budget returns zero, falls back to fixed batch_size."""
|
|
sizer = self._make_sizer()
|
|
job = self._make_job()
|
|
job.batch_size = 2
|
|
|
|
rows = [
|
|
{"source_text": f"text {i}", "source_data": {}}
|
|
for i in range(5)
|
|
]
|
|
|
|
with patch.object(sizer, "resolve_provider_config",
|
|
return_value={"model": "gpt-4o", "context_window": 128000,
|
|
"max_output_tokens": 16384}):
|
|
with patch("src.plugins.translate._batch_sizer.estimate_token_budget") as mock_budget:
|
|
mock_budget.return_value = {
|
|
"batch_size_adjusted": 0,
|
|
"available_input_budget": 0,
|
|
"estimated_input_tokens": 0,
|
|
"max_rows_by_output": 0,
|
|
}
|
|
with patch("src.plugins.translate._batch_sizer.estimate_row_tokens",
|
|
return_value=100):
|
|
batches = sizer.auto_size_batches(job, rows, ["fr"])
|
|
# 5 rows / 2 per batch = 3 batches
|
|
assert len(batches) == 3
|
|
|
|
def test_fallback_when_budget_collapsed(self):
|
|
"""When per_batch_budget <= 0, falls back."""
|
|
sizer = self._make_sizer()
|
|
job = self._make_job()
|
|
|
|
rows = [
|
|
{"source_text": "text", "source_data": {}}
|
|
for _ in range(3)
|
|
]
|
|
|
|
with patch.object(sizer, "resolve_provider_config",
|
|
return_value={"model": "gpt-4o", "context_window": 128000,
|
|
"max_output_tokens": 16384}):
|
|
with patch("src.plugins.translate._batch_sizer.estimate_token_budget") as mock_budget:
|
|
mock_budget.return_value = {
|
|
"batch_size_adjusted": 5,
|
|
"available_input_budget": 100,
|
|
"estimated_input_tokens": 50,
|
|
"max_rows_by_output": 1,
|
|
}
|
|
with patch("src.plugins.translate._batch_sizer.estimate_row_tokens",
|
|
return_value=10000):
|
|
batches = sizer.auto_size_batches(job, rows, ["fr"])
|
|
assert len(batches) >= 1
|
|
|
|
def test_single_row_exceeds_budget(self):
|
|
"""A row exceeding per-batch budget gets its own batch."""
|
|
sizer = self._make_sizer()
|
|
job = self._make_job()
|
|
|
|
rows = [
|
|
{"source_text": "short text", "source_data": {}},
|
|
{"source_text": "x" * 10000, "source_data": {}},
|
|
]
|
|
|
|
with patch.object(sizer, "resolve_provider_config",
|
|
return_value={"model": "gpt-4o", "context_window": 128000,
|
|
"max_output_tokens": 16384}):
|
|
with patch("src.plugins.translate._batch_sizer.estimate_token_budget") as mock_budget:
|
|
mock_budget.return_value = {
|
|
"batch_size_adjusted": 10,
|
|
"available_input_budget": 100000,
|
|
"estimated_input_tokens": 50000,
|
|
"max_rows_by_output": 20,
|
|
}
|
|
# Second row tokens must exceed per_batch_budget (~74550)
|
|
with patch("src.plugins.translate._batch_sizer.estimate_row_tokens",
|
|
side_effect=[50, 80000]):
|
|
batches = sizer.auto_size_batches(job, rows, ["fr"])
|
|
# Large row gets its own batch (row_tokens > per_batch_budget)
|
|
assert len(batches) >= 2
|
|
|
|
def test_respects_job_batch_size(self):
|
|
"""job.batch_size limits row count per batch."""
|
|
sizer = self._make_sizer()
|
|
job = self._make_job()
|
|
job.batch_size = 2
|
|
|
|
rows = [
|
|
{"source_text": f"text {i}", "source_data": {}}
|
|
for i in range(10)
|
|
]
|
|
|
|
with patch.object(sizer, "resolve_provider_config",
|
|
return_value={"model": "gpt-4o", "context_window": 128000,
|
|
"max_output_tokens": 16384}):
|
|
with patch("src.plugins.translate._batch_sizer.estimate_token_budget") as mock_budget:
|
|
mock_budget.return_value = {
|
|
"batch_size_adjusted": 10,
|
|
"available_input_budget": 100000,
|
|
"estimated_input_tokens": 50000,
|
|
"max_rows_by_output": 50,
|
|
}
|
|
with patch("src.plugins.translate._batch_sizer.estimate_row_tokens",
|
|
return_value=500):
|
|
batches = sizer.auto_size_batches(job, rows, ["fr"])
|
|
# Max 2 per batch due to job.batch_size
|
|
assert all(len(b) <= 2 for b in batches)
|
|
|
|
def test_uses_provider_info_fallback(self):
|
|
"""When provider_info is given and model is None, uses it."""
|
|
sizer = self._make_sizer()
|
|
job = self._make_job()
|
|
job.provider_id = None
|
|
|
|
rows = [{"source_text": "text", "source_data": {}}]
|
|
|
|
with patch.object(sizer, "resolve_provider_config",
|
|
return_value={"model": None, "context_window": None,
|
|
"max_output_tokens": None}):
|
|
with patch("src.plugins.translate._batch_sizer.estimate_token_budget") as mock_budget:
|
|
mock_budget.return_value = {
|
|
"batch_size_adjusted": 5,
|
|
"available_input_budget": 100000,
|
|
"estimated_input_tokens": 50000,
|
|
"max_rows_by_output": 20,
|
|
}
|
|
with patch("src.plugins.translate._batch_sizer.estimate_row_tokens",
|
|
return_value=100):
|
|
batches = sizer.auto_size_batches(job, rows, ["fr"],
|
|
provider_info="gpt-4o-mini")
|
|
assert len(batches) == 1
|
|
|
|
def test_available_input_budget_none(self):
|
|
"""When available_input_budget is None, uses estimated_input_tokens."""
|
|
sizer = self._make_sizer()
|
|
job = self._make_job()
|
|
|
|
rows = [{"source_text": "text", "source_data": {}} for _ in range(3)]
|
|
|
|
with patch.object(sizer, "resolve_provider_config",
|
|
return_value={"model": "gpt-4o", "context_window": 128000,
|
|
"max_output_tokens": 16384}):
|
|
with patch("src.plugins.translate._batch_sizer.estimate_token_budget") as mock_budget:
|
|
mock_budget.return_value = {
|
|
"batch_size_adjusted": 5,
|
|
"available_input_budget": None,
|
|
"estimated_input_tokens": 50000,
|
|
"max_rows_by_output": 20,
|
|
}
|
|
with patch("src.plugins.translate._batch_sizer.estimate_row_tokens",
|
|
return_value=100):
|
|
batches = sizer.auto_size_batches(job, rows, ["fr"])
|
|
assert len(batches) == 1
|
|
# #endregion Test.AdaptiveBatchSizer
|