Pytest Fixtures for Drift Thresholds
The failure this page removes is a drift-gate test suite that hard-codes 66.7 ms — the ±2-frame FCC 47 CFR § 79.1 tolerance at 29.97 fps — and therefore silently mis-tests every 25 fps PAL and 23.976 fps film delivery, where two frames are 80 ms and 83.4 ms. A gate must trip one millisecond past its per-rate boundary and pass one millisecond inside it, at every framerate, and proving that without copy-pasting setup for each rate is exactly what pytest fixtures and parametrization are for. The conftest.py below supplies a synthetic cue-track factory that injects a known drift, a rate fixture parametrized over the three broadcast rates (so every dependent test runs three times), and a drift_threshold fixture that derives the correct ±2-frame window per rate — plus a boundary test that asserts the gate trips exactly where it should.
# conftest.py — reusable fixtures for testing sync-drift gate behaviour
import pytest
from dataclasses import dataclass
@dataclass
class Rate:
fps: float
drop_frame: bool
label: str
RATES = [
Rate(23.976, False, "23.976p"),
Rate(25.0, False, "25p"),
Rate(29.97, True, "29.97DF"), # NTSC drop-frame
]
def frame_ms(fps: float) -> float:
"""Duration of one frame in ms; ±2 frames is the FCC 47 CFR § 79.1 window."""
return 1000.0 / fps
@pytest.fixture(params=RATES, ids=[r.label for r in RATES])
def rate(request) -> Rate:
"""Parametrized broadcast frame rate — every dependent test runs once per rate."""
return request.param
@pytest.fixture
def drift_threshold(rate) -> float:
"""The gate's ±2-frame tolerance in ms, derived per rate (83.4 / 80.0 / 66.7)."""
return 2 * frame_ms(rate.fps)
@pytest.fixture
def cue_track():
"""Factory: a synthetic cue track with a known linear drift (ms) injected."""
def _make(n: int = 50, spacing_ms: int = 2000, drift_ms: float = 0.0):
cues = []
for i in range(n):
# drift accrues linearly across the program, like a wrong-timebase encode;
# peak deviation at the last cue equals drift_ms
offset = drift_ms * (i / max(n - 1, 1))
start = i * spacing_ms + offset
cues.append({"index": i, "start_ms": start, "end_ms": start + 1500})
return cues
return _make
def gate_trips(cues, threshold_ms: float, spacing_ms: int = 2000) -> bool:
"""Peak deviation of cue starts from the nominal grid vs the ±2-frame threshold."""
peak = max(abs(c["start_ms"] - c["index"] * spacing_ms) for c in cues)
return peak > threshold_ms # strictly greater -> gate blocks the build
def test_gate_trips_just_past_threshold(cue_track, drift_threshold):
over = cue_track(drift_ms=drift_threshold + 1.0) # 1 ms beyond ±2 frames
assert gate_trips(over, drift_threshold) is True
def test_gate_passes_just_within_threshold(cue_track, drift_threshold):
under = cue_track(drift_ms=drift_threshold - 1.0) # 1 ms inside ±2 frames
assert gate_trips(under, drift_threshold) is False
def test_clean_track_never_trips(cue_track, drift_threshold):
assert gate_trips(cue_track(drift_ms=0.0), drift_threshold) is False
Code walkthrough
RATES enumerates the three rates that dominate broadcast and OTT: 23.976p film, 25p PAL, and 29.97 drop-frame NTSC. The rate fixture uses params=RATES with explicit ids, so pytest expands every dependent test into three cases named …[23.976p], …[25p], and …[29.97DF] — a failure report tells you which rate broke without decoding a repr.
drift_threshold depends on rate and returns 2 * frame_ms(fps), so the tolerance is computed, never hard-coded: 83.4 ms, 80.0 ms, and 66.7 ms respectively. Because it consumes the parametrized rate, every test that requests drift_threshold automatically inherits the three-way expansion — one fixture, full framerate coverage.
cue_track is a factory fixture: it returns a _make function rather than a fixed value, so a single test can build several tracks with different injected drift. The drift model is linear — offset = drift_ms * (i / (n-1)) — which mimics a wrong-timebase encode whose error grows across the program; peak deviation lands on the last cue and equals drift_ms exactly, which is what makes the boundary assertion precise.
gate_trips compares the peak absolute deviation from the nominal index * spacing_ms grid against the threshold, using strict > so a track sitting exactly on the boundary passes. The three tests then pin the contract: drift_threshold + 1 trips, drift_threshold - 1 passes, and a clean track never trips — each running once per rate, nine cases from three functions.
The payoff of deriving the threshold instead of hard-coding it becomes obvious the moment a rate is added or a tolerance policy changes. Widening the gate to ±3 frames, or adding 50p and 59.94p to RATES, is a one-line edit that immediately propagates to every dependent test with no copy-paste — the fixtures own the arithmetic, and the tests only ever speak in terms of “one millisecond past the boundary.” That inversion is the whole point of putting the threshold behind a fixture: the numeric limits live in exactly one place, expressed as frames rather than as magic millisecond constants, so the suite can never drift out of agreement with the FCC 47 CFR § 79.1 window it is meant to enforce. Because cue_track is a factory, the same three tests also extend naturally to non-linear drift — a step discontinuity at a reel change, say — by passing a different builder without touching the rate or threshold plumbing.
Threshold reference table
| Frame rate | 1 frame | ±2-frame tolerance | Timebase |
|---|---|---|---|
| 23.976 fps | 41.71 ms | 83.42 ms | Film / SMPTE ST 12-1 |
| 25 fps | 40.00 ms | 80.00 ms | PAL / EBU |
| 29.97 fps (drop-frame) | 33.37 ms | 66.73 ms | NTSC / FCC 47 CFR § 79.1 |
| Boundary: trip | drift > tolerance | gate returns True | strict > |
| Boundary: pass | drift ≤ tolerance | gate returns False | on-boundary passes |
Edge cases & known gotchas
- Drop-frame is timecode, not milliseconds: 29.97DF affects how
HH:MM:SS;FFmaps to real time, but the tolerance in ms is still2 / 29.97seconds. Do not add a drop-frame correction to the ms threshold — apply drop-frame only when converting timecode to thestart_msthe factory produces. - Parametrize ids matter: omit
ids=and pytest labels cases with a dataclass repr likerate0, making a red rate indistinguishable. Always give human ids (23.976p) so a CI failure names the framerate. - Fixture scope: keep
cue_trackat default function scope — a factory that memoized tracks at session scope would leak injected drift from one test into the next. The factory returns a fresh list on every call precisely to stay stateless. - Float vs int frames:
frame_msreturns a float, so thresholds are non-integer (66.73 ms). Compare with a tolerance orpytest.approxif you assert on the threshold value itself; never round it to an int, which would move the 29.97 boundary by a third of a millisecond. - Spacing coupling:
gate_tripsand the factory sharespacing_ms=2000; if a test overrides one it must override both, or the nominal-grid reference no longer matches the injected track.
Integration hook
These fixtures are the test-side counterpart to the gate defined in CI/CD gating for caption builds: they let you prove the sync-drift assertion trips at the right boundary for every framerate before the gate ever runs on a real delivery. Point them at the production detector from detecting sync drift in automated QC pipelines by replacing the local gate_trips with that module’s threshold check, and reuse the same synthetic tracks to feed the timing goldens in golden-file testing for caption output so drift and regression share one fixture vocabulary.
Related
- CI/CD gating for caption builds — the parent gate whose drift threshold these fixtures exercise.
- Detecting sync drift in automated QC pipelines — the production PTS-alignment detector to point these fixtures at.
- Golden-file testing for caption output — approval tests that reuse the same synthetic timing tracks.
Part of: Automated QC Validation & Reporting — the deterministic caption QC and reporting reference.