video-accessibility/backend/tests/unit/test_whisper_snap.py
Vadym Samoilenko 2f4925353a feat(pause-insert): adaptive buffer, forward-snap, timeline drag + share link fix
Backend (Phase A):
- A1: Adaptive silence buffer — natural_gap_ms persisted per cue; renderer computes
  per-cue silence_before/silence_after instead of fixed 500ms; per-cue silence files
- A2: Forward-preferred snap — snap_pause_point prefers boundaries up to 4s ahead
  over boundaries within 1.5s behind, reducing mid-scene cuts
- A3: Min-gap validation — pause points with < 200ms gap trigger forward search
  to the next acceptable gap
- natural_gap_ms added to PausePointData model and api.ts type
- New config fields: whisper_snap_forward_window, whisper_snap_backward_window,
  ad_silence_buffer_default, ad_silence_buffer_min_after, ad_min_acceptable_gap
- Tests: test_whisper_snap.py (13 tests), test_video_renderer_buffers.py

Frontend (Phase B):
- B1: Drag pause-point markers — pointer state machine with 3px move threshold,
  clamp to min/max bounds, click-without-move still opens PausePointEditor
- B2: Drag freeze blocks — orange blocks translate with linked pause point
- B3: Time tooltip visible during drag, hidden on release
- Tests: TimelinePreview.drag.test.tsx (10 tests)

Fixes:
- Share link pointed to ai-sandbox.oliver.solutions — added app_url to Settings
  with correct optical-dev.oliver.solutions default; share_url now configurable
  via APP_URL env var
- Removed all ai-sandbox.oliver.solutions references from docker-compose,
  apache config, docs, and scripts

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-01 16:09:09 +01:00

231 lines
9.3 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Tests for the improved snap_pause_point algorithm (A1/A2/A3)."""
import sys
from unittest.mock import MagicMock
# faster_whisper ships only in the Docker image; stub it so pytest can run locally.
if 'faster_whisper' not in sys.modules:
sys.modules['faster_whisper'] = MagicMock()
import pytest
from app.services.whisper_service import (
WhisperService,
WordTimestamp,
SpeechGap,
SentenceBoundary,
)
# ── fixtures ────────────────────────────────────────────────────────────────
@pytest.fixture
def svc():
"""WhisperService instance with default settings."""
return WhisperService()
def _word(start: float, end: float, text: str = "word") -> WordTimestamp:
return WordTimestamp(word=text, start=start, end=end)
def _gap(start: float, end: float, gap_type: str = "sentence") -> SpeechGap:
return SpeechGap(start=start, end=end, duration=end - start, gap_type=gap_type)
def _boundary(
time: float,
btype: str = "sentence_end",
has_prev: bool = True,
has_next: bool = True,
gap: SpeechGap | None = None,
) -> SentenceBoundary:
return SentenceBoundary(
time=time,
boundary_type=btype,
word_index=0,
has_previous_sentence=has_prev,
has_next_sentence=has_next,
gap=gap,
)
# ── A2: forward-preferred snap ───────────────────────────────────────────────
class TestForwardPreferredSnap:
def test_picks_forward_over_equidistant_backward(self, svc):
"""Gemini=10.5s; forward boundary@11.2s and backward@9.8s — must pick forward."""
gap = _gap(11.2, 11.8)
boundaries = [
_boundary(9.8, gap=_gap(9.8, 10.0)),
_boundary(11.2, gap=gap),
]
words = [_word(9.0, 9.5), _word(10.0, 10.5), _word(11.0, 11.2)]
gaps = [_gap(9.8, 10.0), gap]
pause, _, warning, _ = svc.snap_pause_point(10.5, words, gaps, boundaries)
assert pause == pytest.approx(11.5, abs=0.01) # midpoint of 11.211.8
assert warning is None
def test_forward_boundary_within_window_is_preferred(self, svc):
"""Even a slightly farther forward boundary beats a closer backward one."""
gap_fwd = _gap(12.0, 12.6)
gap_bwd = _gap(10.1, 10.4)
boundaries = [
_boundary(10.1, gap=gap_bwd),
_boundary(12.0, gap=gap_fwd),
]
words = [_word(9.0, 10.1), _word(10.5, 12.0)]
gaps = [gap_bwd, gap_fwd]
pause, _, _, _ = svc.snap_pause_point(10.5, words, gaps, boundaries)
assert pause == pytest.approx(12.3, abs=0.01) # midpoint of 12.012.6
def test_falls_back_to_backward_when_no_forward_within_window(self, svc):
"""No forward boundary within snap_forward_window → use backward (within 1.5s)."""
# Boundary at 9.2s: distance = 10.5 - 9.2 = 1.3s ≤ snap_backward_window (1.5s) ✓
gap = _gap(9.0, 9.4)
boundaries = [_boundary(9.0, gap=gap)]
words = [_word(7.0, 9.0), _word(9.4, 10.5)]
gaps = [gap]
pause, _, warning, _ = svc.snap_pause_point(10.5, words, gaps, boundaries)
assert pause == pytest.approx(9.2, abs=0.01) # midpoint of 9.09.4
def test_no_boundary_in_any_window_returns_gemini_with_warning(self, svc):
"""Boundary exists but outside both windows → exact Gemini point + warning."""
# Put boundaries 10s away in both directions (beyond any window)
boundaries = [
_boundary(0.1, gap=_gap(0.0, 0.5)),
_boundary(50.0, gap=_gap(49.0, 50.0)),
]
words = [_word(9.0, 12.0)]
gaps = []
pause, resume, warning, _ = svc.snap_pause_point(10.5, words, gaps, boundaries)
assert pause == pytest.approx(10.5)
assert warning is not None
assert "snap windows" in warning.lower()
def test_not_during_speaking_uses_exact_point(self, svc):
"""Pause point far from all words → no snap, exact point returned."""
boundaries = [_boundary(5.0, gap=_gap(4.8, 5.3))]
words = [_word(0.0, 3.0)] # speech ends at 3s; pause at 7s
# Gap covers 3.010.0; pause at 7.0 is inside it
gaps = [_gap(3.0, 10.0)]
pause, _, warning, natural_gap_ms = svc.snap_pause_point(7.0, words, gaps, boundaries)
assert pause == pytest.approx(7.0)
assert warning is None
# natural_gap covers the pause (7.0 is inside gap 3.010.0)
assert natural_gap_ms > 0
# ── A1: natural_gap_ms returned correctly ───────────────────────────────────
class TestNaturalGapMs:
def test_case_c_returns_gap_duration(self, svc):
"""Case C (gap midpoint) must return gap.duration * 1000 as natural_gap_ms."""
gap = _gap(10.0, 11.2)
boundaries = [_boundary(10.0, gap=gap)]
words = [_word(9.0, 10.0), _word(11.2, 12.0)]
gaps = [gap]
_, _, _, natural_gap_ms = svc.snap_pause_point(10.5, words, gaps, boundaries)
assert natural_gap_ms == pytest.approx(1200.0, abs=1.0) # 1.2s gap
def test_no_gap_returns_zero(self, svc):
"""Fallback case with no gap → natural_gap_ms == 0."""
b = _boundary(10.0, gap=None) # no gap attached
words = [_word(9.0, 10.1), _word(10.1, 11.0)]
gaps = []
_, _, _, natural_gap_ms = svc.snap_pause_point(10.5, words, gaps, [b])
assert natural_gap_ms == 0.0
def test_not_during_speaking_reads_gap_from_gaps_list(self, svc):
"""Not-during-speaking path should read natural gap from the gaps list."""
gap = _gap(5.0, 6.0) # covers pause at 5.5s
words = [_word(0.0, 3.0)] # all speech before 3s
gaps = [gap]
_, _, _, natural_gap_ms = svc.snap_pause_point(5.5, words, gaps, [])
assert natural_gap_ms == pytest.approx(1000.0, abs=1.0)
# ── A3: minimum gap validation ───────────────────────────────────────────────
class TestMinGapValidation:
def test_short_gap_triggers_forward_search(self, svc):
"""Case C gap < min_acceptable_gap → searches forward for a better gap."""
short_gap = _gap(10.0, 10.1) # 0.1s < 0.2s threshold
good_gap = _gap(11.5, 12.0) # 0.5s — acceptable
boundaries = [_boundary(10.0, gap=short_gap)]
words = [_word(9.0, 10.0), _word(10.2, 11.5)]
gaps = [short_gap, good_gap]
pause, _, _, natural_gap_ms = svc.snap_pause_point(10.5, words, gaps, boundaries)
# Should snap forward to midpoint of good_gap (11.5+12.0)/2 = 11.75
assert pause == pytest.approx(11.75, abs=0.01)
assert natural_gap_ms == pytest.approx(500.0, abs=1.0)
def test_short_gap_no_forward_alternative_keeps_original(self, svc):
"""Short gap, no acceptable gap ahead → stays at original point with warning."""
short_gap = _gap(10.0, 10.1)
boundaries = [_boundary(10.0, gap=short_gap)]
words = [_word(9.0, 10.0), _word(10.2, 14.0)]
gaps = [short_gap] # no other gap
pause, _, warning, _ = svc.snap_pause_point(10.5, words, gaps, boundaries)
# Falls back to midpoint of short_gap since no alternative
assert pause == pytest.approx(10.05, abs=0.01)
assert warning is None # no warning for "stayed at original"
def test_fallback_no_gap_triggers_forward_search(self, svc):
"""Fallback case (no gap on boundary) with no nearby gap → searches forward."""
b = _boundary(10.0, gap=None)
good_gap = _gap(11.0, 11.8)
words = [_word(9.0, 10.0), _word(10.1, 11.0)]
gaps = [good_gap]
pause, _, warning, natural_gap_ms = svc.snap_pause_point(10.5, words, gaps, [b])
assert pause == pytest.approx(11.4, abs=0.01)
assert natural_gap_ms == pytest.approx(800.0, abs=1.0)
assert warning is not None # warns that it snapped forward
# ── refine_all_pause_points integration ─────────────────────────────────────
class TestRefineAllPausePointsIntegration:
def test_stores_natural_gap_ms_on_placement(self, svc):
"""refine_all_pause_points must persist natural_gap_ms onto each placement."""
gap = _gap(10.0, 11.0)
words = [_word(8.0, 10.0), _word(11.0, 12.0)]
gaps = [gap]
placements = [{"ad_cue_index": 0, "pause_point": 10.5, "ad_duration": 3.0}]
refined, _ = svc.refine_all_pause_points(placements, words, gaps)
assert "natural_gap_ms" in refined[0]
assert refined[0]["natural_gap_ms"] == pytest.approx(1000.0, abs=1.0)
def test_no_whisper_data_returns_original_with_zero_gap(self, svc):
"""No words → _is_during_speaking=False → exact point, no warning, natural_gap_ms=0."""
placements = [{"ad_cue_index": 0, "pause_point": 5.0, "ad_duration": 2.0}]
refined, warnings = svc.refine_all_pause_points(placements, [], [])
assert refined[0]["pause_point"] == pytest.approx(5.0)
assert refined[0].get("natural_gap_ms", 0) == 0.0
# No words → not-during-speaking path → no snap → no warning
assert len(warnings) == 0