Files
wehub-resource-sync 09e9f3545f
Test / Code Quality (push) Has been cancelled
Test / Test (macos-latest, Python 3.10) (push) Has been cancelled
Test / Test (macos-latest, Python 3.11) (push) Has been cancelled
Test / Test (macos-latest, Python 3.12) (push) Has been cancelled
Test / Test (macos-latest, Python 3.13) (push) Has been cancelled
Test / Test (macos-latest, Python 3.14) (push) Has been cancelled
Test / Test (ubuntu-latest, Python 3.10) (push) Has been cancelled
Test / Test (ubuntu-latest, Python 3.11) (push) Has been cancelled
Test / Test (ubuntu-latest, Python 3.12) (push) Has been cancelled
Test / Test (ubuntu-latest, Python 3.13) (push) Has been cancelled
Test / Test (ubuntu-latest, Python 3.14) (push) Has been cancelled
Test / Test (windows-latest, Python 3.10) (push) Has been cancelled
Test / Test (windows-latest, Python 3.11) (push) Has been cancelled
Test / Test (windows-latest, Python 3.12) (push) Has been cancelled
Test / Test (windows-latest, Python 3.13) (push) Has been cancelled
Test / Test (windows-latest, Python 3.14) (push) Has been cancelled
CodeQL / Analyze (push) Has been cancelled
dependency-audit / pip-audit (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:30:13 +08:00

200 lines
7.2 KiB
Python

"""Tests for ``notebooklm._backoff.compute_backoff_delay``.
The helper is pure math; these tests cover the invariants the call sites
rely on (monotonic exponential growth, bounded jitter, hard cap) and the
``rng`` injection seam used by deterministic regression tests.
"""
from __future__ import annotations
import random
import pytest
import notebooklm._backoff as backoff_module
from notebooklm._backoff import compute_backoff_delay
# ---------------------------------------------------------------------------
# Determinism
# ---------------------------------------------------------------------------
def test_seeded_rng_is_reproducible():
"""Two calls with seed-equal RNGs must return identical sequences."""
rng_a = random.Random(42)
rng_b = random.Random(42)
seq_a = [compute_backoff_delay(n, rng=rng_a) for n in range(6)]
seq_b = [compute_backoff_delay(n, rng=rng_b) for n in range(6)]
assert seq_a == seq_b
def test_zero_jitter_skips_rng_and_returns_exact_power_of_two():
"""jitter_ratio=0 must short-circuit the rng entirely."""
class _ExplodingRng:
def uniform(self, low: float, high: float) -> float: # pragma: no cover
raise AssertionError("rng must not be consulted when jitter_ratio is 0")
for n in range(5):
delay = compute_backoff_delay(
n,
base=1.0,
cap=8.0,
jitter_ratio=0.0,
rng=_ExplodingRng(), # type: ignore[arg-type]
)
assert delay == min(2**n, 8.0)
# ---------------------------------------------------------------------------
# Jitter bounds
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("attempt", range(8))
def test_delay_within_jitter_band(attempt: int):
"""delay ∈ [raw * (1 - jitter_ratio), raw * (1 + jitter_ratio)]."""
base, cap, jitter_ratio = 1.0, 30.0, 0.2
raw = min(base * 2**attempt, cap)
lower = raw * (1 - jitter_ratio)
upper = raw * (1 + jitter_ratio)
rng = random.Random(2026)
# 200 samples per attempt to exercise both extremes of uniform().
for _ in range(200):
delay = compute_backoff_delay(
attempt,
base=base,
cap=cap,
jitter_ratio=jitter_ratio,
rng=rng,
)
assert lower <= delay <= upper, (delay, lower, upper)
def test_jitter_uses_module_random_when_rng_is_none(monkeypatch):
"""rng=None must consult the module-level ``random`` source.
This is the production contract: the 3 transport call sites all pass
``rng=None`` and rely on the shared ``random`` module both for default
behavior and for test monkeypatching.
"""
calls: list[tuple[float, float]] = []
def _fake_uniform(low: float, high: float) -> float:
calls.append((low, high))
return 0.0
monkeypatch.setattr(backoff_module._random, "uniform", _fake_uniform)
delay = compute_backoff_delay(2, base=1.0, cap=30.0, jitter_ratio=0.2)
# raw = 4.0; jitter = 0.0; delay = 4.0
assert delay == 4.0
assert calls == [(-0.8, 0.8)]
# ---------------------------------------------------------------------------
# Monotonicity (median behavior, since jitter can briefly invert adjacent samples)
# ---------------------------------------------------------------------------
def test_raw_curve_is_monotonic_then_flat_at_cap():
"""With jitter_ratio=0, the curve is strictly increasing until it caps."""
base, cap = 1.0, 30.0
series = [compute_backoff_delay(n, base=base, cap=cap, jitter_ratio=0.0) for n in range(8)]
# 1, 2, 4, 8, 16, 30, 30, 30
assert series == [1.0, 2.0, 4.0, 8.0, 16.0, 30.0, 30.0, 30.0]
def test_jittered_curve_is_monotonic_in_expectation():
"""Averaging out jitter, the curve must still grow until the cap.
Per-sample monotonicity is not guaranteed (jitter can invert adjacent
points), but the mean across many trials must.
"""
base, cap, jitter_ratio = 1.0, 30.0, 0.2
rng = random.Random(9)
n_samples = 500
means: list[float] = []
for attempt in range(7):
total = sum(
compute_backoff_delay(
attempt,
base=base,
cap=cap,
jitter_ratio=jitter_ratio,
rng=rng,
)
for _ in range(n_samples)
)
means.append(total / n_samples)
# 1, 2, 4, 8, 16 strict; then 30, 30 cap (32→30, 64→30).
for prev, nxt in zip(means[:5], means[1:6], strict=True):
assert nxt > prev, (means, prev, nxt)
# After cap, means converge.
assert abs(means[5] - means[6]) < 1.0
# ---------------------------------------------------------------------------
# Cap behavior
# ---------------------------------------------------------------------------
def test_cap_bounds_raw_growth():
"""Without jitter, the value is clamped to ``cap`` for large ``attempt``."""
# 2**20 = ~1e6 would dwarf any reasonable cap.
delay = compute_backoff_delay(20, base=1.0, cap=30.0, jitter_ratio=0.0)
assert delay == 30.0
def test_cap_applied_before_jitter_so_max_is_bounded():
"""delay ≤ cap * (1 + jitter_ratio) even when raw would explode."""
cap, jitter_ratio = 30.0, 0.2
rng = random.Random(3)
for _ in range(50):
delay = compute_backoff_delay(
20,
base=1.0,
cap=cap,
jitter_ratio=jitter_ratio,
rng=rng,
)
assert delay <= cap * (1 + jitter_ratio)
assert delay >= cap * (1 - jitter_ratio)
# ---------------------------------------------------------------------------
# Edge cases
# ---------------------------------------------------------------------------
def test_negative_attempt_clamps_to_zero():
"""Defensive: negative attempts shouldn't panic; treat as attempt=0."""
assert compute_backoff_delay(-1, base=1.0, cap=30.0, jitter_ratio=0.0) == 1.0
assert compute_backoff_delay(-100, base=2.5, cap=30.0, jitter_ratio=0.0) == 2.5
def test_negative_jitter_ratio_rejected():
"""Contract guard: jitter_ratio is a non-negative spread percentage."""
with pytest.raises(ValueError, match="jitter_ratio must be non-negative"):
compute_backoff_delay(0, jitter_ratio=-0.1)
def test_attempt_zero_returns_base_without_jitter():
assert compute_backoff_delay(0, base=1.0, cap=30.0, jitter_ratio=0.0) == 1.0
assert compute_backoff_delay(0, base=3.0, cap=30.0, jitter_ratio=0.0) == 3.0
def test_call_site_artifact_polling_matches_legacy_min_powers_of_two():
"""Pin the artifact_polling call-site curve (no jitter, cap=8)."""
# poll_retry_count is 1-indexed at the call site; verify 1..N produces
# the expected pre-extraction sequence.
out = [compute_backoff_delay(n, base=1.0, cap=8.0, jitter_ratio=0.0) for n in range(1, 6)]
assert out == [2.0, 4.0, 8.0, 8.0, 8.0]
def test_call_site_transport_matches_legacy_with_zero_jitter(monkeypatch):
"""Pin the transport retry curve with jitter monkeypatched to 0."""
monkeypatch.setattr(backoff_module._random, "uniform", lambda a, b: 0.0)
out = [compute_backoff_delay(n, base=1.0, cap=30.0, jitter_ratio=0.2) for n in range(7)]
# 1, 2, 4, 8, 16, 30, 30
assert out == [1.0, 2.0, 4.0, 8.0, 16.0, 30.0, 30.0]