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;FF maps to real time, but the tolerance in ms is still 2 / 29.97 seconds. Do not add a drop-frame correction to the ms threshold — apply drop-frame only when converting timecode to the start_ms the factory produces.
  • Parametrize ids matter: omit ids= and pytest labels cases with a dataclass repr like rate0, making a red rate indistinguishable. Always give human ids (23.976p) so a CI failure names the framerate.
  • Fixture scope: keep cue_track at 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_ms returns a float, so thresholds are non-integer (66.73 ms). Compare with a tolerance or pytest.approx if 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_trips and the factory share spacing_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.

Part of: Automated QC Validation & Reporting — the deterministic caption QC and reporting reference.