# #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