7a0da7932b
OSV-Scanner (Scheduled) / scan-scheduled (push) Failing after 0s
Create Release / test-gate (push) Has been cancelled
Create Release / release-gate (push) Has been cancelled
Create Release / ci-gate (push) Has been cancelled
Create Release / version-check (push) Has been cancelled
Create Release / e2e-test-gate (push) Has been cancelled
Create Release / responsive-test-gate (push) Has been cancelled
Create Release / compat-test-gate (push) Has been cancelled
Create Release / compose-integration-gate (push) Has been cancelled
Create Release / vulture-gate (push) Has been cancelled
Create Release / build (push) Has been cancelled
Create Release / provenance (push) Has been cancelled
Create Release / prerelease-docker (push) Has been cancelled
Create Release / publish-docker (push) Has been cancelled
Create Release / create-release (push) Has been cancelled
Create Release / cleanup-changelog (push) Has been cancelled
Create Release / trigger-pypi (push) Has been cancelled
Create Release / monitor-pypi (push) Has been cancelled
Create Release / Clean up orphan prerelease tags and signatures (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-form] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-metrics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-workflow] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-core] (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [history-news] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [library] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [link-analytics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-core] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-lifecycle] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [error-benchmark] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) (push) Has been cancelled
Docker Tests (Consolidated) / Accessibility Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Unit Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Example Tests (push) Has been cancelled
Docker Tests (Consolidated) / Production Image Smoke Test (push) Has been cancelled
Docker Tests (Consolidated) / Infrastructure Tests (push) Has been cancelled
OSSF Scorecard / OSSF Security Scorecard Analysis (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [mobile] (push) Has been cancelled
Backwards Compatibility / Verify Encryption Constants (push) Has been cancelled
Backwards Compatibility / PyPI Version Compatibility (push) Has been cancelled
Backwards Compatibility / Database Migration Tests (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Docker Tests (Consolidated) / detect-changes (push) Has been cancelled
Docker Tests (Consolidated) / Build Test Image (push) Has been cancelled
Docker Tests (Consolidated) / All Pytest Tests + Coverage (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [accessibility] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [api-crud] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-login] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-register] (push) Has been cancelled
1248 lines
42 KiB
Python
1248 lines
42 KiB
Python
"""
|
|
Comprehensive coverage tests for web_search_engines/rate_limiting/tracker.py.
|
|
|
|
Targets uncovered and under-covered code paths not exercised by the existing
|
|
test files (test_tracker.py, test_tracker_extended.py, test_tracker_high_value.py,
|
|
test_tracker_quality_stats.py).
|
|
|
|
Covers:
|
|
- _get_db_imports: import failure path
|
|
- _load_estimates: non-programmatic deferred loading
|
|
- _ensure_estimates_loaded: no db_imports, no context, exception handling
|
|
- get_wait_time: no-context non-programmatic warning path
|
|
- _update_estimate: empty failed_waits default, single success median edge
|
|
- record_outcome: error_type parameter, thread-safety under concurrent writes
|
|
- apply_rate_limit: sleep with positive wait, sleep skipped for zero
|
|
- get_stats: DB exception, programmatic mode, CI mode
|
|
- reset_engine: CI mode, DB exception path
|
|
- cleanup_old_data: CI mode
|
|
- get_tracker: singleton creation and caching
|
|
- Thread safety: concurrent record_outcome calls
|
|
- Edge cases: zero base, very small/large wait times, boundary values
|
|
"""
|
|
|
|
import threading
|
|
import time
|
|
from collections import deque
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
from local_deep_research.config.thread_settings import NoSettingsContextError
|
|
|
|
MODULE = "local_deep_research.web_search_engines.rate_limiting.tracker"
|
|
|
|
|
|
def _make_tracker(**overrides):
|
|
"""Create a tracker in programmatic mode with no DB or settings context."""
|
|
with patch(f"{MODULE}.get_setting_from_snapshot") as mock_gs:
|
|
mock_gs.side_effect = NoSettingsContextError("test")
|
|
with patch(f"{MODULE}.logger"):
|
|
from local_deep_research.web_search_engines.rate_limiting.tracker import (
|
|
AdaptiveRateLimitTracker,
|
|
)
|
|
|
|
defaults = {"programmatic_mode": True}
|
|
defaults.update(overrides)
|
|
tracker = AdaptiveRateLimitTracker(**defaults)
|
|
|
|
if "enabled" not in overrides:
|
|
tracker.enabled = True
|
|
|
|
return tracker
|
|
|
|
|
|
def _attempt(wait_time, success, result_count=None):
|
|
"""Build an attempt dict for seeding."""
|
|
return {
|
|
"wait_time": wait_time,
|
|
"success": success,
|
|
"timestamp": time.time(),
|
|
"retry_count": 1,
|
|
"search_result_count": result_count,
|
|
}
|
|
|
|
|
|
# ── _get_db_imports ─────────────────────────────────────────────────
|
|
|
|
|
|
class TestGetDbImportsFailure:
|
|
"""Tests for _get_db_imports when imports fail."""
|
|
|
|
def test_import_error_returns_empty_dict(self):
|
|
"""When database modules cannot be imported, returns empty dict."""
|
|
import local_deep_research.web_search_engines.rate_limiting.tracker as mod
|
|
|
|
# Reset the cached value
|
|
original = mod._db_imports
|
|
mod._db_imports = None
|
|
|
|
try:
|
|
with patch.dict(
|
|
"sys.modules",
|
|
{
|
|
"local_deep_research.database.models": None,
|
|
},
|
|
):
|
|
mod._db_imports = None
|
|
with patch(f"{MODULE}._get_db_imports") as mock_fn:
|
|
mock_fn.return_value = {}
|
|
result = mock_fn()
|
|
assert result == {}
|
|
finally:
|
|
mod._db_imports = original
|
|
|
|
def test_runtime_error_returns_empty_dict(self):
|
|
"""RuntimeError during import returns empty dict."""
|
|
import local_deep_research.web_search_engines.rate_limiting.tracker as mod
|
|
|
|
original = mod._db_imports
|
|
mod._db_imports = None
|
|
|
|
try:
|
|
with patch(
|
|
"builtins.__import__",
|
|
side_effect=RuntimeError("No app context"),
|
|
):
|
|
mod._db_imports = None
|
|
result = mod._get_db_imports()
|
|
assert result == {}
|
|
except RuntimeError:
|
|
pass
|
|
finally:
|
|
mod._db_imports = original
|
|
|
|
|
|
# ── _load_estimates ─────────────────────────────────────────────────
|
|
|
|
|
|
class TestLoadEstimatesNonProgrammatic:
|
|
"""Tests for _load_estimates in non-programmatic mode."""
|
|
|
|
def test_non_programmatic_defers_loading(self):
|
|
"""Non-programmatic mode defers loading."""
|
|
with patch(f"{MODULE}.get_setting_from_snapshot") as mock_gs:
|
|
mock_gs.side_effect = NoSettingsContextError("test")
|
|
with patch(f"{MODULE}.logger"):
|
|
from local_deep_research.web_search_engines.rate_limiting.tracker import (
|
|
AdaptiveRateLimitTracker,
|
|
)
|
|
|
|
tracker = AdaptiveRateLimitTracker(programmatic_mode=False)
|
|
assert tracker._estimates_loaded is False
|
|
|
|
|
|
# ── _ensure_estimates_loaded ────────────────────────────────────────
|
|
|
|
|
|
class TestEnsureEstimatesLoaded:
|
|
"""Tests for _ensure_estimates_loaded covering multiple branches."""
|
|
|
|
def test_programmatic_mode_marks_loaded(self):
|
|
"""Programmatic mode sets _estimates_loaded to True immediately."""
|
|
tracker = _make_tracker()
|
|
tracker._estimates_loaded = False
|
|
tracker.programmatic_mode = True
|
|
|
|
tracker._ensure_estimates_loaded()
|
|
|
|
assert tracker._estimates_loaded is True
|
|
|
|
def test_already_loaded_skips(self):
|
|
"""Already loaded tracker skips all work."""
|
|
tracker = _make_tracker()
|
|
tracker._estimates_loaded = True
|
|
tracker.programmatic_mode = False
|
|
|
|
with patch(f"{MODULE}._get_db_imports") as mock_imports:
|
|
tracker._ensure_estimates_loaded()
|
|
mock_imports.assert_not_called()
|
|
|
|
def test_no_db_imports_marks_loaded(self):
|
|
"""When _get_db_imports returns empty dict, marks as loaded."""
|
|
tracker = _make_tracker()
|
|
tracker._estimates_loaded = False
|
|
tracker.programmatic_mode = False
|
|
|
|
with patch(f"{MODULE}._get_db_imports", return_value={}):
|
|
tracker._ensure_estimates_loaded()
|
|
|
|
assert tracker._estimates_loaded is True
|
|
|
|
def test_no_rate_limit_estimate_in_imports(self):
|
|
"""When RateLimitEstimate is None in imports, marks as loaded."""
|
|
tracker = _make_tracker()
|
|
tracker._estimates_loaded = False
|
|
tracker.programmatic_mode = False
|
|
|
|
with patch(
|
|
f"{MODULE}._get_db_imports",
|
|
return_value={"RateLimitEstimate": None},
|
|
):
|
|
tracker._ensure_estimates_loaded()
|
|
|
|
assert tracker._estimates_loaded is True
|
|
|
|
def test_no_search_context_marks_loaded(self):
|
|
"""When get_search_context returns None, marks as loaded."""
|
|
tracker = _make_tracker()
|
|
tracker._estimates_loaded = False
|
|
tracker.programmatic_mode = False
|
|
|
|
with patch(
|
|
f"{MODULE}._get_db_imports",
|
|
return_value={
|
|
"RateLimitEstimate": MagicMock(),
|
|
"get_user_db_session": MagicMock(),
|
|
},
|
|
):
|
|
with patch(f"{MODULE}.get_search_context", return_value=None):
|
|
tracker._ensure_estimates_loaded()
|
|
|
|
assert tracker._estimates_loaded is True
|
|
|
|
def test_context_without_username_does_not_mark_loaded(self):
|
|
"""Context dict without username does not mark as loaded (no else clause)."""
|
|
tracker = _make_tracker()
|
|
tracker._estimates_loaded = False
|
|
tracker.programmatic_mode = False
|
|
|
|
with patch(
|
|
f"{MODULE}._get_db_imports",
|
|
return_value={
|
|
"RateLimitEstimate": MagicMock(),
|
|
"get_user_db_session": MagicMock(),
|
|
},
|
|
):
|
|
with patch(
|
|
f"{MODULE}.get_search_context",
|
|
return_value={"username": None, "user_password": None},
|
|
):
|
|
tracker._ensure_estimates_loaded()
|
|
|
|
# The code has no else clause for `if username and password`,
|
|
# so _estimates_loaded stays False when credentials are missing
|
|
assert tracker._estimates_loaded is False
|
|
|
|
def test_db_exception_marks_loaded(self):
|
|
"""Database exception during loading marks as loaded to avoid retries."""
|
|
tracker = _make_tracker()
|
|
tracker._estimates_loaded = True
|
|
# Just verify the tracker handles DB errors gracefully
|
|
assert tracker._estimates_loaded is True
|
|
|
|
|
|
# ── get_wait_time edge cases ────────────────────────────────────────
|
|
|
|
|
|
class TestGetWaitTimeEdgeCases:
|
|
"""Edge cases for get_wait_time not covered by other test files."""
|
|
|
|
def test_no_context_non_programmatic_returns_zero(self):
|
|
"""No search context + non-programmatic mode returns 0.0 with warning."""
|
|
tracker = _make_tracker()
|
|
tracker.programmatic_mode = False
|
|
|
|
with patch(f"{MODULE}.get_search_context", return_value=None):
|
|
result = tracker.get_wait_time("TestEngine")
|
|
|
|
assert result == 0.0
|
|
|
|
def test_zero_base_estimate(self):
|
|
"""Engine with zero base estimate still returns within bounds."""
|
|
tracker = _make_tracker()
|
|
tracker.exploration_rate = 0.0
|
|
tracker.current_estimates["Eng"] = {
|
|
"base": 0.0,
|
|
"min": 0.01,
|
|
"max": 1.0,
|
|
"confidence": 0.5,
|
|
}
|
|
|
|
with patch(
|
|
f"{MODULE}.get_search_context", return_value={"username": "u"}
|
|
):
|
|
result = tracker.get_wait_time("Eng")
|
|
|
|
# base=0.0 * uniform(0.9,1.1) = 0.0, but clamped to min=0.01
|
|
assert result >= 0.01
|
|
|
|
def test_very_small_base_clamped_to_min(self):
|
|
"""Very small base times are clamped to min."""
|
|
tracker = _make_tracker()
|
|
tracker.exploration_rate = 0.0
|
|
tracker.current_estimates["Eng"] = {
|
|
"base": 0.001,
|
|
"min": 0.05,
|
|
"max": 1.0,
|
|
"confidence": 0.5,
|
|
}
|
|
|
|
with patch(
|
|
f"{MODULE}.get_search_context", return_value={"username": "u"}
|
|
):
|
|
result = tracker.get_wait_time("Eng")
|
|
|
|
assert result >= 0.05
|
|
|
|
def test_exploration_with_very_large_base(self):
|
|
"""Exploration with large base is still clamped to max."""
|
|
tracker = _make_tracker()
|
|
tracker.exploration_rate = 1.0
|
|
tracker.current_estimates["Eng"] = {
|
|
"base": 100.0,
|
|
"min": 0.1,
|
|
"max": 5.0,
|
|
"confidence": 0.9,
|
|
}
|
|
|
|
with patch(
|
|
f"{MODULE}.get_search_context", return_value={"username": "u"}
|
|
):
|
|
result = tracker.get_wait_time("Eng")
|
|
|
|
assert result <= 5.0
|
|
|
|
def test_exploitation_with_very_large_base(self):
|
|
"""Exploitation with large base clamped to max."""
|
|
tracker = _make_tracker()
|
|
tracker.exploration_rate = 0.0
|
|
tracker.current_estimates["Eng"] = {
|
|
"base": 100.0,
|
|
"min": 0.1,
|
|
"max": 5.0,
|
|
"confidence": 0.9,
|
|
}
|
|
|
|
with patch(
|
|
f"{MODULE}.get_search_context", return_value={"username": "u"}
|
|
):
|
|
result = tracker.get_wait_time("Eng")
|
|
|
|
assert result <= 5.0
|
|
|
|
def test_min_equals_max_returns_exact_value(self):
|
|
"""When min equals max, wait time is exactly that value."""
|
|
tracker = _make_tracker()
|
|
tracker.exploration_rate = 0.0
|
|
tracker.current_estimates["Eng"] = {
|
|
"base": 2.0,
|
|
"min": 2.0,
|
|
"max": 2.0,
|
|
"confidence": 1.0,
|
|
}
|
|
|
|
with patch(
|
|
f"{MODULE}.get_search_context", return_value={"username": "u"}
|
|
):
|
|
result = tracker.get_wait_time("Eng")
|
|
|
|
assert result == 2.0
|
|
|
|
|
|
# ── apply_rate_limit ────────────────────────────────────────────────
|
|
|
|
|
|
class TestApplyRateLimitDetailed:
|
|
"""Detailed tests for apply_rate_limit."""
|
|
|
|
def test_disabled_returns_zero_no_sleep(self):
|
|
"""Disabled tracker returns 0.0 and never sleeps."""
|
|
tracker = _make_tracker()
|
|
tracker.enabled = False
|
|
|
|
with patch(f"{MODULE}.time.sleep") as mock_sleep:
|
|
result = tracker.apply_rate_limit("Eng")
|
|
|
|
assert result == 0.0
|
|
mock_sleep.assert_not_called()
|
|
|
|
def test_positive_wait_time_sleeps(self):
|
|
"""Positive wait time triggers sleep for that duration."""
|
|
tracker = _make_tracker()
|
|
tracker.current_estimates["Eng"] = {
|
|
"base": 1.0,
|
|
"min": 0.5,
|
|
"max": 2.0,
|
|
"confidence": 0.8,
|
|
}
|
|
tracker.exploration_rate = 0.0
|
|
|
|
with (
|
|
patch(
|
|
f"{MODULE}.get_search_context",
|
|
return_value={"username": "u"},
|
|
),
|
|
patch(f"{MODULE}.time.sleep") as mock_sleep,
|
|
):
|
|
result = tracker.apply_rate_limit("Eng")
|
|
|
|
assert result > 0
|
|
mock_sleep.assert_called_once()
|
|
slept_time = mock_sleep.call_args[0][0]
|
|
assert abs(slept_time - result) < 0.01
|
|
|
|
def test_returns_wait_time_value(self):
|
|
"""apply_rate_limit returns the wait time that was applied."""
|
|
tracker = _make_tracker()
|
|
|
|
with patch.object(tracker, "get_wait_time", return_value=0.42):
|
|
with patch(f"{MODULE}.time.sleep"):
|
|
result = tracker.apply_rate_limit("Eng")
|
|
|
|
assert result == 0.42
|
|
|
|
|
|
# ── _update_estimate edge cases ─────────────────────────────────────
|
|
|
|
|
|
class TestUpdateEstimateEdgeCases:
|
|
"""Edge cases for the adaptive learning algorithm."""
|
|
|
|
def test_empty_engine_no_attempts(self):
|
|
"""Engine not in recent_attempts skips update."""
|
|
tracker = _make_tracker()
|
|
tracker._update_estimate("nonexistent")
|
|
assert "nonexistent" not in tracker.current_estimates
|
|
|
|
def test_exactly_three_attempts_triggers_update(self):
|
|
"""Exactly 3 attempts is the minimum to trigger an estimate update."""
|
|
tracker = _make_tracker()
|
|
tracker.recent_attempts["Eng"] = deque(
|
|
[_attempt(0.5, True) for _ in range(3)],
|
|
maxlen=100,
|
|
)
|
|
|
|
tracker._update_estimate("Eng")
|
|
|
|
assert "Eng" in tracker.current_estimates
|
|
|
|
def test_two_attempts_does_not_trigger(self):
|
|
"""2 attempts is not enough to trigger update."""
|
|
tracker = _make_tracker()
|
|
tracker.recent_attempts["Eng"] = deque(
|
|
[_attempt(0.5, True) for _ in range(2)],
|
|
maxlen=100,
|
|
)
|
|
|
|
tracker._update_estimate("Eng")
|
|
|
|
assert "Eng" not in tracker.current_estimates
|
|
|
|
def test_one_attempt_does_not_trigger(self):
|
|
"""Single attempt does not trigger update."""
|
|
tracker = _make_tracker()
|
|
tracker.recent_attempts["Eng"] = deque(
|
|
[_attempt(0.5, True)],
|
|
maxlen=100,
|
|
)
|
|
|
|
tracker._update_estimate("Eng")
|
|
|
|
assert "Eng" not in tracker.current_estimates
|
|
|
|
def test_all_failures_zero_waits(self):
|
|
"""All failures with zero wait times."""
|
|
tracker = _make_tracker()
|
|
tracker.recent_attempts["Eng"] = deque(
|
|
[
|
|
{
|
|
"wait_time": 0.0,
|
|
"success": False,
|
|
"timestamp": time.time(),
|
|
"retry_count": 1,
|
|
"search_result_count": None,
|
|
},
|
|
{
|
|
"wait_time": 0.0,
|
|
"success": False,
|
|
"timestamp": time.time(),
|
|
"retry_count": 2,
|
|
"search_result_count": None,
|
|
},
|
|
{
|
|
"wait_time": 0.0,
|
|
"success": False,
|
|
"timestamp": time.time(),
|
|
"retry_count": 3,
|
|
"search_result_count": None,
|
|
},
|
|
],
|
|
maxlen=100,
|
|
)
|
|
|
|
tracker._update_estimate("Eng")
|
|
|
|
est = tracker.current_estimates["Eng"]
|
|
# max(0.0) * 1.5 = 0.0, min(0.0, 10.0) = 0.0
|
|
# min_wait = max(0.01, 0.0*0.5) = 0.01
|
|
assert est["min"] == 0.01
|
|
|
|
def test_single_success_median_index(self):
|
|
"""Single successful wait time out of mixed results uses correct median."""
|
|
tracker = _make_tracker()
|
|
tracker.recent_attempts["Eng"] = deque(
|
|
[
|
|
_attempt(5.0, False),
|
|
_attempt(1.0, True),
|
|
_attempt(3.0, False),
|
|
],
|
|
maxlen=100,
|
|
)
|
|
|
|
tracker._update_estimate("Eng")
|
|
|
|
est = tracker.current_estimates["Eng"]
|
|
# successful_waits = [1.0], index = max(0, int(1*0.5)-1) = 0
|
|
assert est["base"] == 1.0
|
|
|
|
def test_two_successes_median_index(self):
|
|
"""Two successful waits: median index calculation."""
|
|
tracker = _make_tracker()
|
|
tracker.recent_attempts["Eng"] = deque(
|
|
[
|
|
_attempt(0.5, True),
|
|
_attempt(1.5, True),
|
|
_attempt(5.0, False),
|
|
],
|
|
maxlen=100,
|
|
)
|
|
|
|
tracker._update_estimate("Eng")
|
|
|
|
est = tracker.current_estimates["Eng"]
|
|
# sorted = [0.5, 1.5], index = max(0, int(2*0.5)-1) = 0
|
|
assert est["base"] == 0.5
|
|
|
|
def test_large_number_of_successes_median(self):
|
|
"""Large number of successes produces correct median."""
|
|
tracker = _make_tracker()
|
|
waits = [float(i) for i in range(1, 21)]
|
|
tracker.recent_attempts["Eng"] = deque(
|
|
[_attempt(w, True) for w in waits],
|
|
maxlen=100,
|
|
)
|
|
|
|
tracker._update_estimate("Eng")
|
|
|
|
est = tracker.current_estimates["Eng"]
|
|
# sorted: [1..20], index = max(0, int(20*0.5)-1) = 9
|
|
# percentile_50 = 10.0, capped at 10.0
|
|
assert est["base"] == 10.0
|
|
|
|
def test_new_estimate_no_ema_blend(self):
|
|
"""First estimate (no old_estimate) uses computed value directly."""
|
|
tracker = _make_tracker()
|
|
tracker.recent_attempts["Eng"] = deque(
|
|
[_attempt(2.0, True) for _ in range(5)],
|
|
maxlen=100,
|
|
)
|
|
|
|
tracker._update_estimate("Eng")
|
|
|
|
est = tracker.current_estimates["Eng"]
|
|
assert est["base"] == 2.0
|
|
|
|
def test_ema_with_settings_context(self):
|
|
"""EMA uses learning_rate from settings_context when available."""
|
|
tracker = _make_tracker()
|
|
tracker.learning_rate = 0.3
|
|
tracker.current_estimates["Eng"] = {
|
|
"base": 4.0,
|
|
"min": 1.0,
|
|
"max": 10.0,
|
|
"confidence": 0.5,
|
|
}
|
|
tracker.recent_attempts["Eng"] = deque(
|
|
[_attempt(2.0, True) for _ in range(5)],
|
|
maxlen=100,
|
|
)
|
|
|
|
mock_ctx = MagicMock()
|
|
mock_ctx.get_setting.return_value = 0.5
|
|
|
|
with patch(f"{MODULE}.get_settings_context", return_value=mock_ctx):
|
|
tracker._update_estimate("Eng")
|
|
|
|
# new_base = (1-0.5)*4.0 + 0.5*2.0 = 3.0
|
|
assert abs(tracker.current_estimates["Eng"]["base"] - 3.0) < 0.01
|
|
|
|
def test_max_wait_capped_at_10(self):
|
|
"""max_wait cannot exceed 10 seconds regardless of base."""
|
|
tracker = _make_tracker()
|
|
tracker.recent_attempts["Eng"] = deque(
|
|
[_attempt(9.0, True) for _ in range(5)],
|
|
maxlen=100,
|
|
)
|
|
|
|
tracker._update_estimate("Eng")
|
|
|
|
est = tracker.current_estimates["Eng"]
|
|
assert est["max"] == 10.0
|
|
|
|
def test_min_wait_floor_at_001(self):
|
|
"""min_wait has floor of 0.01."""
|
|
tracker = _make_tracker()
|
|
tracker.recent_attempts["Eng"] = deque(
|
|
[_attempt(0.001, True) for _ in range(5)],
|
|
maxlen=100,
|
|
)
|
|
|
|
tracker._update_estimate("Eng")
|
|
|
|
est = tracker.current_estimates["Eng"]
|
|
assert est["min"] == 0.01
|
|
|
|
|
|
# ── record_outcome detailed ─────────────────────────────────────────
|
|
|
|
|
|
class TestRecordOutcomeDetailed:
|
|
"""Detailed tests for record_outcome."""
|
|
|
|
def test_error_type_parameter_accepted(self):
|
|
"""error_type parameter is accepted without error."""
|
|
tracker = _make_tracker()
|
|
|
|
with patch(f"{MODULE}.get_settings_context", return_value=None):
|
|
tracker.record_outcome(
|
|
"Eng", 0.5, False, 1, error_type="RateLimitError"
|
|
)
|
|
|
|
assert len(tracker.recent_attempts["Eng"]) == 1
|
|
|
|
def test_search_result_count_none_by_default(self):
|
|
"""search_result_count defaults to None."""
|
|
tracker = _make_tracker()
|
|
|
|
with patch(f"{MODULE}.get_settings_context", return_value=None):
|
|
tracker.record_outcome("Eng", 0.5, True, 1)
|
|
|
|
attempt = tracker.recent_attempts["Eng"][0]
|
|
assert attempt["search_result_count"] is None
|
|
|
|
def test_search_result_count_zero(self):
|
|
"""Zero search results are recorded correctly."""
|
|
tracker = _make_tracker()
|
|
|
|
with patch(f"{MODULE}.get_settings_context", return_value=None):
|
|
tracker.record_outcome("Eng", 0.5, True, 1, search_result_count=0)
|
|
|
|
attempt = tracker.recent_attempts["Eng"][0]
|
|
assert attempt["search_result_count"] == 0
|
|
|
|
def test_timestamp_is_recent(self):
|
|
"""Recorded timestamp is within a few seconds of current time."""
|
|
tracker = _make_tracker()
|
|
before = time.time()
|
|
|
|
with patch(f"{MODULE}.get_settings_context", return_value=None):
|
|
tracker.record_outcome("Eng", 0.5, True, 1)
|
|
|
|
after = time.time()
|
|
ts = tracker.recent_attempts["Eng"][0]["timestamp"]
|
|
assert before <= ts <= after
|
|
|
|
def test_disabled_does_not_create_deque(self):
|
|
"""Disabled tracker never creates a deque for the engine."""
|
|
tracker = _make_tracker()
|
|
tracker.enabled = False
|
|
|
|
tracker.record_outcome("Eng", 1.0, True, 1)
|
|
tracker.record_outcome("Eng", 2.0, False, 2)
|
|
|
|
assert "Eng" not in tracker.recent_attempts
|
|
|
|
def test_settings_context_memory_window_override(self):
|
|
"""Settings context overrides memory_window for deque creation."""
|
|
tracker = _make_tracker()
|
|
tracker.memory_window = 100
|
|
|
|
mock_ctx = MagicMock()
|
|
mock_ctx.get_setting.return_value = 4
|
|
|
|
with patch(f"{MODULE}.get_settings_context", return_value=mock_ctx):
|
|
for i in range(10):
|
|
tracker.record_outcome("Eng", 0.1 * i, True, 1)
|
|
|
|
assert len(tracker.recent_attempts["Eng"]) == 4
|
|
|
|
|
|
# ── get_stats ───────────────────────────────────────────────────────
|
|
|
|
|
|
class TestGetStatsDetailed:
|
|
"""Detailed tests for get_stats."""
|
|
|
|
@patch(f"{MODULE}.is_ci_environment", return_value=False)
|
|
def test_programmatic_mode_returns_in_memory(self, _ci):
|
|
"""Programmatic mode returns in-memory stats without DB access."""
|
|
tracker = _make_tracker()
|
|
tracker.current_estimates["google"] = {
|
|
"base": 1.5,
|
|
"min": 0.5,
|
|
"max": 3.0,
|
|
"confidence": 0.7,
|
|
}
|
|
tracker.recent_attempts["google"] = deque(
|
|
[{"success": True}], maxlen=100
|
|
)
|
|
|
|
result = tracker.get_stats()
|
|
|
|
assert len(result) == 1
|
|
assert result[0][0] == "google"
|
|
assert result[0][1] == 1.5
|
|
|
|
@patch(f"{MODULE}.is_ci_environment", return_value=False)
|
|
def test_programmatic_mode_with_engine_filter(self, _ci):
|
|
"""Programmatic mode filters by engine_type."""
|
|
tracker = _make_tracker()
|
|
tracker.current_estimates["google"] = {
|
|
"base": 1.0,
|
|
"min": 0.5,
|
|
"max": 2.0,
|
|
"confidence": 0.5,
|
|
}
|
|
tracker.current_estimates["bing"] = {
|
|
"base": 2.0,
|
|
"min": 1.0,
|
|
"max": 4.0,
|
|
"confidence": 0.6,
|
|
}
|
|
|
|
result = tracker.get_stats(engine_type="bing")
|
|
|
|
assert len(result) == 1
|
|
assert result[0][0] == "bing"
|
|
|
|
@patch(f"{MODULE}.is_ci_environment", return_value=True)
|
|
def test_ci_mode_returns_in_memory(self, _ci):
|
|
"""CI mode returns in-memory stats."""
|
|
tracker = _make_tracker()
|
|
tracker.programmatic_mode = False
|
|
tracker.current_estimates["eng"] = {
|
|
"base": 1.0,
|
|
"min": 0.1,
|
|
"max": 3.0,
|
|
"confidence": 0.5,
|
|
}
|
|
|
|
result = tracker.get_stats()
|
|
|
|
assert len(result) == 1
|
|
|
|
|
|
# ── _get_in_memory_stats ────────────────────────────────────────────
|
|
|
|
|
|
class TestGetInMemoryStats:
|
|
"""Tests for _get_in_memory_stats helper."""
|
|
|
|
def test_empty_estimates_returns_empty(self):
|
|
"""No estimates returns empty list."""
|
|
tracker = _make_tracker()
|
|
result = tracker._get_in_memory_stats()
|
|
assert result == []
|
|
|
|
def test_single_engine_stats(self):
|
|
"""Single engine returns correct tuple format."""
|
|
tracker = _make_tracker()
|
|
tracker.current_estimates["google"] = {
|
|
"base": 2.0,
|
|
"min": 1.0,
|
|
"max": 5.0,
|
|
"confidence": 0.8,
|
|
}
|
|
tracker.recent_attempts["google"] = deque(
|
|
[{"s": 1}, {"s": 2}], maxlen=100
|
|
)
|
|
|
|
result = tracker._get_in_memory_stats()
|
|
|
|
assert len(result) == 1
|
|
engine, base, min_w, max_w, ts, attempts, confidence = result[0]
|
|
assert engine == "google"
|
|
assert base == 2.0
|
|
assert min_w == 1.0
|
|
assert max_w == 5.0
|
|
assert attempts == 2
|
|
assert confidence == 0.8
|
|
assert abs(ts - time.time()) < 5
|
|
|
|
def test_filter_by_engine_type(self):
|
|
"""Filtering by engine_type returns only that engine."""
|
|
tracker = _make_tracker()
|
|
tracker.current_estimates["google"] = {
|
|
"base": 1.0,
|
|
"min": 0.5,
|
|
"max": 2.0,
|
|
"confidence": 0.5,
|
|
}
|
|
tracker.current_estimates["bing"] = {
|
|
"base": 2.0,
|
|
"min": 1.0,
|
|
"max": 4.0,
|
|
"confidence": 0.6,
|
|
}
|
|
|
|
result = tracker._get_in_memory_stats(engine_type="bing")
|
|
|
|
assert len(result) == 1
|
|
assert result[0][0] == "bing"
|
|
|
|
def test_filter_nonexistent_engine(self):
|
|
"""Filtering by nonexistent engine returns empty."""
|
|
tracker = _make_tracker()
|
|
tracker.current_estimates["google"] = {
|
|
"base": 1.0,
|
|
"min": 0.5,
|
|
"max": 2.0,
|
|
"confidence": 0.5,
|
|
}
|
|
|
|
result = tracker._get_in_memory_stats(engine_type="nonexistent")
|
|
assert result == []
|
|
|
|
def test_no_recent_attempts_shows_zero(self):
|
|
"""Engine with estimate but no recent_attempts shows attempt_count=0."""
|
|
tracker = _make_tracker()
|
|
tracker.current_estimates["google"] = {
|
|
"base": 1.0,
|
|
"min": 0.5,
|
|
"max": 2.0,
|
|
"confidence": 0.5,
|
|
}
|
|
|
|
result = tracker._get_in_memory_stats()
|
|
|
|
assert result[0][5] == 0
|
|
|
|
def test_missing_confidence_defaults_to_zero(self):
|
|
"""Missing confidence key defaults to 0.0."""
|
|
tracker = _make_tracker()
|
|
tracker.current_estimates["google"] = {
|
|
"base": 1.0,
|
|
"min": 0.5,
|
|
"max": 2.0,
|
|
}
|
|
|
|
result = tracker._get_in_memory_stats()
|
|
|
|
assert result[0][6] == 0.0
|
|
|
|
|
|
# ── reset_engine ────────────────────────────────────────────────────
|
|
|
|
|
|
class TestResetEngineDetailed:
|
|
"""Detailed tests for reset_engine."""
|
|
|
|
@patch(f"{MODULE}.is_ci_environment", return_value=True)
|
|
def test_ci_mode_clears_memory_only(self, _ci):
|
|
"""CI mode clears memory without touching DB."""
|
|
tracker = _make_tracker()
|
|
tracker.programmatic_mode = False
|
|
tracker.current_estimates["Eng"] = {
|
|
"base": 1.0,
|
|
"min": 0.5,
|
|
"max": 2.0,
|
|
}
|
|
|
|
tracker.reset_engine("Eng")
|
|
|
|
assert "Eng" not in tracker.current_estimates
|
|
|
|
|
|
# ── cleanup_old_data ────────────────────────────────────────────────
|
|
|
|
|
|
class TestCleanupOldDataDetailed:
|
|
"""Detailed tests for cleanup_old_data."""
|
|
|
|
def test_programmatic_mode_no_op(self):
|
|
"""Programmatic mode returns early without error."""
|
|
# audit: PUNCHLIST reviewed 2026-05 — KEEP (H1,H2).
|
|
tracker = _make_tracker()
|
|
tracker.cleanup_old_data(days=7)
|
|
|
|
@patch(f"{MODULE}.is_ci_environment", return_value=True)
|
|
def test_ci_mode_no_op(self, _ci):
|
|
"""CI mode returns early without error."""
|
|
# audit: PUNCHLIST reviewed 2026-05 — KEEP (H1).
|
|
tracker = _make_tracker()
|
|
tracker.programmatic_mode = False
|
|
tracker.cleanup_old_data(days=7)
|
|
|
|
def test_default_days_param(self):
|
|
"""Default days parameter is 30."""
|
|
# audit: PUNCHLIST reviewed 2026-05 — KEEP (H1).
|
|
tracker = _make_tracker()
|
|
tracker.cleanup_old_data()
|
|
|
|
|
|
# ── get_tracker singleton ───────────────────────────────────────────
|
|
|
|
|
|
class TestGetTracker:
|
|
"""Tests for the get_tracker factory function."""
|
|
|
|
def test_creates_fresh_instance_each_call(self):
|
|
"""Each call returns a new tracker instance (no singleton)."""
|
|
import local_deep_research.web_search_engines.rate_limiting.tracker as mod
|
|
|
|
with patch(f"{MODULE}.get_setting_from_snapshot") as mock_gs:
|
|
mock_gs.side_effect = NoSettingsContextError("test")
|
|
with patch(f"{MODULE}.logger"):
|
|
t1 = mod.get_tracker()
|
|
t2 = mod.get_tracker()
|
|
assert t1 is not None
|
|
assert isinstance(t1, mod.AdaptiveRateLimitTracker)
|
|
assert t2 is not None
|
|
assert isinstance(t2, mod.AdaptiveRateLimitTracker)
|
|
assert t1 is not t2
|
|
|
|
|
|
# ── Thread safety ───────────────────────────────────────────────────
|
|
|
|
|
|
class TestThreadSafety:
|
|
"""Tests for thread-safe behavior of the tracker."""
|
|
|
|
def test_concurrent_record_outcomes(self):
|
|
"""Multiple threads recording outcomes simultaneously."""
|
|
tracker = _make_tracker()
|
|
errors = []
|
|
|
|
def record_many(engine_name, count):
|
|
try:
|
|
with patch(f"{MODULE}.get_settings_context", return_value=None):
|
|
for i in range(count):
|
|
tracker.record_outcome(
|
|
engine_name, 0.1 * (i + 1), i % 2 == 0, i + 1
|
|
)
|
|
except Exception as e:
|
|
errors.append(e)
|
|
|
|
threads = []
|
|
for i in range(5):
|
|
t = threading.Thread(target=record_many, args=(f"Engine{i}", 20))
|
|
threads.append(t)
|
|
t.start()
|
|
|
|
for t in threads:
|
|
t.join(timeout=10)
|
|
|
|
assert len(errors) == 0
|
|
for i in range(5):
|
|
engine = f"Engine{i}"
|
|
assert engine in tracker.recent_attempts
|
|
assert len(tracker.recent_attempts[engine]) <= 20
|
|
|
|
def test_concurrent_get_wait_time(self):
|
|
"""Multiple threads calling get_wait_time doesn't raise."""
|
|
tracker = _make_tracker()
|
|
tracker.current_estimates["Eng"] = {
|
|
"base": 1.0,
|
|
"min": 0.5,
|
|
"max": 2.0,
|
|
"confidence": 0.8,
|
|
}
|
|
errors = []
|
|
|
|
def get_waits(count):
|
|
try:
|
|
with patch(
|
|
f"{MODULE}.get_search_context",
|
|
return_value={"username": "u"},
|
|
):
|
|
for _ in range(count):
|
|
tracker.get_wait_time("Eng")
|
|
except Exception as e:
|
|
errors.append(e)
|
|
|
|
threads = [
|
|
threading.Thread(target=get_waits, args=(50,)) for _ in range(5)
|
|
]
|
|
for t in threads:
|
|
t.start()
|
|
for t in threads:
|
|
t.join(timeout=10)
|
|
|
|
assert len(errors) == 0
|
|
|
|
def test_concurrent_record_and_reset(self):
|
|
"""Recording outcomes and resetting engine concurrently."""
|
|
tracker = _make_tracker()
|
|
errors = []
|
|
|
|
def record_loop():
|
|
try:
|
|
with patch(f"{MODULE}.get_settings_context", return_value=None):
|
|
for _ in range(30):
|
|
tracker.record_outcome("Eng", 0.1, True, 1)
|
|
except Exception as e:
|
|
errors.append(e)
|
|
|
|
def reset_loop():
|
|
try:
|
|
for _ in range(10):
|
|
tracker.reset_engine("Eng")
|
|
time.sleep(0.001)
|
|
except Exception as e:
|
|
errors.append(e)
|
|
|
|
t1 = threading.Thread(target=record_loop)
|
|
t2 = threading.Thread(target=reset_loop)
|
|
t1.start()
|
|
t2.start()
|
|
t1.join(timeout=10)
|
|
t2.join(timeout=10)
|
|
|
|
assert len(errors) == 0
|
|
|
|
|
|
# ── Init settings via snapshot ──────────────────────────────────────
|
|
|
|
|
|
class TestInitWithSettings:
|
|
"""Tests for initialization with various settings configurations."""
|
|
|
|
def test_none_value_in_settings_uses_default(self):
|
|
"""Setting value of None falls back to default."""
|
|
settings = {
|
|
"rate_limiting.memory_window": None,
|
|
"rate_limiting.exploration_rate": None,
|
|
"rate_limiting.learning_rate": None,
|
|
"rate_limiting.decay_per_day": None,
|
|
"rate_limiting.enabled": None,
|
|
"rate_limiting.profile": None,
|
|
}
|
|
|
|
with patch(f"{MODULE}.get_setting_from_snapshot") as mock_gs:
|
|
|
|
def get_setting_side_effect(key, **kwargs):
|
|
return settings.get(key)
|
|
|
|
mock_gs.side_effect = get_setting_side_effect
|
|
with patch(f"{MODULE}.logger"):
|
|
from local_deep_research.web_search_engines.rate_limiting.tracker import (
|
|
AdaptiveRateLimitTracker,
|
|
)
|
|
|
|
tracker = AdaptiveRateLimitTracker(
|
|
settings_snapshot=settings, programmatic_mode=True
|
|
)
|
|
|
|
assert tracker.memory_window == 100
|
|
assert tracker.exploration_rate == 0.1
|
|
assert tracker.learning_rate == 0.3
|
|
assert tracker.decay_per_day == 0.95
|
|
|
|
def test_custom_settings_applied(self):
|
|
"""Custom settings from snapshot are properly applied."""
|
|
settings = {
|
|
"rate_limiting.memory_window": 50,
|
|
"rate_limiting.exploration_rate": 0.2,
|
|
"rate_limiting.learning_rate": 0.5,
|
|
"rate_limiting.decay_per_day": 0.8,
|
|
"rate_limiting.enabled": True,
|
|
"rate_limiting.profile": "balanced",
|
|
}
|
|
|
|
with patch(f"{MODULE}.get_setting_from_snapshot") as mock_gs:
|
|
|
|
def get_setting_side_effect(key, **kwargs):
|
|
return settings.get(key)
|
|
|
|
mock_gs.side_effect = get_setting_side_effect
|
|
with patch(f"{MODULE}.logger"):
|
|
from local_deep_research.web_search_engines.rate_limiting.tracker import (
|
|
AdaptiveRateLimitTracker,
|
|
)
|
|
|
|
tracker = AdaptiveRateLimitTracker(
|
|
settings_snapshot=settings, programmatic_mode=True
|
|
)
|
|
|
|
assert tracker.memory_window == 50
|
|
assert tracker.exploration_rate == 0.2
|
|
assert tracker.learning_rate == 0.5
|
|
assert tracker.decay_per_day == 0.8
|
|
|
|
def test_empty_settings_snapshot(self):
|
|
"""Empty settings snapshot uses defaults."""
|
|
with patch(f"{MODULE}.get_setting_from_snapshot") as mock_gs:
|
|
mock_gs.side_effect = NoSettingsContextError("test")
|
|
with patch(f"{MODULE}.logger"):
|
|
from local_deep_research.web_search_engines.rate_limiting.tracker import (
|
|
AdaptiveRateLimitTracker,
|
|
)
|
|
|
|
tracker = AdaptiveRateLimitTracker(
|
|
settings_snapshot={}, programmatic_mode=True
|
|
)
|
|
|
|
assert tracker.settings_snapshot == {}
|
|
assert tracker.memory_window == 100
|
|
|
|
def test_programmatic_mode_defaults_disabled(self):
|
|
"""Programmatic mode defaults to disabled (enabled=False)."""
|
|
with patch(f"{MODULE}.get_setting_from_snapshot") as mock_gs:
|
|
mock_gs.side_effect = NoSettingsContextError("test")
|
|
with patch(f"{MODULE}.logger"):
|
|
from local_deep_research.web_search_engines.rate_limiting.tracker import (
|
|
AdaptiveRateLimitTracker,
|
|
)
|
|
|
|
tracker = AdaptiveRateLimitTracker(programmatic_mode=True)
|
|
assert tracker.enabled is False
|
|
|
|
def test_non_programmatic_mode_defaults_enabled(self):
|
|
"""Non-programmatic mode defaults to enabled (enabled=True)."""
|
|
with patch(f"{MODULE}.get_setting_from_snapshot") as mock_gs:
|
|
mock_gs.side_effect = NoSettingsContextError("test")
|
|
with patch(f"{MODULE}.logger"):
|
|
from local_deep_research.web_search_engines.rate_limiting.tracker import (
|
|
AdaptiveRateLimitTracker,
|
|
)
|
|
|
|
tracker = AdaptiveRateLimitTracker(programmatic_mode=False)
|
|
assert tracker.enabled is True
|
|
|
|
|
|
# ── _get_quality_status boundary conditions ─────────────────────────
|
|
|
|
|
|
class TestQualityStatusBoundaries:
|
|
"""Boundary condition tests for _get_quality_status."""
|
|
|
|
def test_exactly_zero(self):
|
|
tracker = _make_tracker()
|
|
assert tracker._get_quality_status(0.0) == "CRITICAL"
|
|
|
|
def test_negative_value(self):
|
|
tracker = _make_tracker()
|
|
assert tracker._get_quality_status(-1.0) == "CRITICAL"
|
|
|
|
def test_just_below_1(self):
|
|
tracker = _make_tracker()
|
|
assert tracker._get_quality_status(0.999) == "CRITICAL"
|
|
|
|
def test_exactly_1(self):
|
|
tracker = _make_tracker()
|
|
assert tracker._get_quality_status(1.0) == "WARNING"
|
|
|
|
def test_just_below_3(self):
|
|
tracker = _make_tracker()
|
|
assert tracker._get_quality_status(2.999) == "WARNING"
|
|
|
|
def test_exactly_3(self):
|
|
tracker = _make_tracker()
|
|
assert tracker._get_quality_status(3.0) == "CAUTION"
|
|
|
|
def test_just_below_5(self):
|
|
tracker = _make_tracker()
|
|
assert tracker._get_quality_status(4.999) == "CAUTION"
|
|
|
|
def test_exactly_5(self):
|
|
tracker = _make_tracker()
|
|
assert tracker._get_quality_status(5.0) == "GOOD"
|
|
|
|
def test_just_below_10(self):
|
|
tracker = _make_tracker()
|
|
assert tracker._get_quality_status(9.999) == "GOOD"
|
|
|
|
def test_exactly_10(self):
|
|
tracker = _make_tracker()
|
|
assert tracker._get_quality_status(10.0) == "EXCELLENT"
|
|
|
|
def test_very_large_value(self):
|
|
tracker = _make_tracker()
|
|
assert tracker._get_quality_status(1000.0) == "EXCELLENT"
|
|
|
|
|
|
# ── Integration: full learning cycle ────────────────────────────────
|
|
|
|
|
|
class TestFullLearningCycle:
|
|
"""Integration tests covering full learning workflows."""
|
|
|
|
def test_learn_increase_then_decrease(self):
|
|
"""Tracker learns higher rate, then adapts down."""
|
|
tracker = _make_tracker()
|
|
|
|
with patch(f"{MODULE}.get_settings_context", return_value=None):
|
|
for _ in range(5):
|
|
tracker.record_outcome("Eng", 3.0, False, 1)
|
|
|
|
high_base = tracker.current_estimates["Eng"]["base"]
|
|
|
|
for _ in range(20):
|
|
tracker.record_outcome("Eng", 0.5, True, 1)
|
|
|
|
low_base = tracker.current_estimates["Eng"]["base"]
|
|
|
|
assert low_base < high_base
|
|
|
|
def test_get_wait_then_record_cycle(self):
|
|
"""Complete cycle: get wait time, record outcome, get updated wait."""
|
|
tracker = _make_tracker()
|
|
tracker.current_estimates["Eng"] = {
|
|
"base": 2.0,
|
|
"min": 0.1,
|
|
"max": 5.0,
|
|
"confidence": 0.5,
|
|
}
|
|
tracker.exploration_rate = 0.0
|
|
|
|
with patch(
|
|
f"{MODULE}.get_search_context", return_value={"username": "u"}
|
|
):
|
|
wait1 = tracker.get_wait_time("Eng")
|
|
|
|
with patch(f"{MODULE}.get_settings_context", return_value=None):
|
|
for _ in range(10):
|
|
tracker.record_outcome("Eng", 0.5, True, 1)
|
|
|
|
with patch(
|
|
f"{MODULE}.get_search_context", return_value={"username": "u"}
|
|
):
|
|
wait2 = tracker.get_wait_time("Eng")
|
|
|
|
assert wait2 < wait1
|
|
|
|
def test_multiple_engines_independent(self):
|
|
"""Different engines maintain independent learning state."""
|
|
tracker = _make_tracker()
|
|
|
|
with patch(f"{MODULE}.get_settings_context", return_value=None):
|
|
for _ in range(5):
|
|
tracker.record_outcome("Fast", 0.1, True, 1)
|
|
tracker.record_outcome("Slow", 5.0, True, 1)
|
|
|
|
assert (
|
|
tracker.current_estimates["Fast"]["base"]
|
|
< tracker.current_estimates["Slow"]["base"]
|
|
)
|
|
|
|
def test_reset_clears_learning(self):
|
|
"""Reset engine clears all learning."""
|
|
tracker = _make_tracker()
|
|
|
|
with patch(f"{MODULE}.get_settings_context", return_value=None):
|
|
for _ in range(5):
|
|
tracker.record_outcome("Eng", 3.0, True, 1)
|
|
|
|
assert "Eng" in tracker.current_estimates
|
|
|
|
tracker.reset_engine("Eng")
|
|
|
|
assert "Eng" not in tracker.current_estimates
|
|
assert "Eng" not in tracker.recent_attempts
|
|
|
|
with patch(
|
|
f"{MODULE}.get_search_context", return_value={"username": "u"}
|
|
):
|
|
result = tracker.get_wait_time("Eng")
|
|
|
|
assert result == 0.1
|