v4: 7643 tests passing, 83% raw / 96.9% real coverage (excl __tests__).
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.
This commit is contained in:
262
backend/tests/plugins/translate/test_batch_sizer.py
Normal file
262
backend/tests/plugins/translate/test_batch_sizer.py
Normal file
@@ -0,0 +1,262 @@
|
||||
# #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
|
||||
Reference in New Issue
Block a user