import json import sys import pytest from cadence import ( Segment, Word, _cache_key, _read_cache, _write_cache, build_timeline, compute_jl_cut_shifts, frames_to_tc, map_words_to_edited_timeline, normalize_token, parse_args, ) # --------------------------------------------------------------------------- # normalize_token # --------------------------------------------------------------------------- @pytest.mark.parametrize( ("raw", "expected"), [ ("Uh...", "uh"), ("—", ""), ('"Hello,"', "hello"), (" Word? ", "word"), ("...", ""), ("Umm!", "umm"), ], ) def test_normalize_token(raw, expected): assert normalize_token(raw) == expected # --------------------------------------------------------------------------- # frames_to_tc # --------------------------------------------------------------------------- def test_frames_to_tc_zero_and_negative(): assert frames_to_tc(0, 30.0) == "00:00:00.000" assert frames_to_tc(-7, 30.0) == "00:00:00.000" def _parse_tc(tc: str) -> float: h, m, s = tc.split(":") return int(h) * 3600 + int(m) * 60 + float(s) @pytest.mark.parametrize("frames", [1, 30, 100, 1000, 30000, 90000]) def test_frames_to_tc_roundtrip_ntsc(frames): fps = 30000 / 1001 tc = frames_to_tc(frames, fps) secs = _parse_tc(tc) back = round(secs * fps) assert abs(back - frames) <= 1 # --------------------------------------------------------------------------- # build_timeline # --------------------------------------------------------------------------- def _assert_tiling(timeline, duration, min_keep): assert timeline, "timeline must not be empty" # Alternating keep/drop actions. for prev, nxt in zip(timeline, timeline[1:]): assert prev.action != nxt.action # Contiguous tiling of [0, duration]. assert timeline[0].start == pytest.approx(0.0) for prev, nxt in zip(timeline, timeline[1:]): assert prev.end == pytest.approx(nxt.start) assert timeline[-1].end == pytest.approx(duration) keep = sum(s.end - s.start for s in timeline if s.action == "keep") drop = sum(s.end - s.start for s in timeline if s.action == "drop") assert keep == pytest.approx(duration - drop) for seg in timeline: if seg.action == "keep": assert (seg.end - seg.start) >= min_keep - 1e-9 @pytest.mark.parametrize( ("cuts", "duration"), [ ([], 10.0), ([(2.0, 3.0, "silence"), (5.0, 6.0, "filler")], 10.0), ([(0.0, 1.0, "x"), (9.5, 10.0, "y")], 10.0), ([(0.0, 2.0, "a"), (2.02, 4.0, "b")], 5.0), ([(1.0, 2.0, "a"), (2.0, 3.0, "b"), (3.0, 4.0, "c")], 4.0), ], ) def test_build_timeline_invariants(cuts, duration): min_keep = 0.08 timeline = build_timeline(cuts, duration, min_keep_dur=min_keep) _assert_tiling(timeline, duration, min_keep) def test_build_timeline_no_cuts_single_keep(): assert build_timeline([], 5.0) == [Segment(0.0, 5.0, "keep")] def test_build_timeline_zero_duration_returns_keep(): # Verified real behavior: the empty list falls through to a single keep # segment spanning [0, 0]. assert build_timeline([], 0.0) == [Segment(0.0, 0.0, "keep")] # --------------------------------------------------------------------------- # compute_jl_cut_shifts # --------------------------------------------------------------------------- def _keeps(*pairs): return [Segment(s, e, "keep") for s, e in pairs] def _assert_jl_bounds(segs, shifts, fps, jl_frames): for i in range(len(segs) - 1): cur_in = int(round(segs[i].start * fps)) cur_out = int(round(segs[i].end * fps)) cur_dur = max(1, cur_out - cur_in) next_in = int(round(segs[i + 1].start * fps)) next_out = int(round(segs[i + 1].end * fps)) next_dur = max(1, next_out - next_in) handle = max(0, next_in - cur_out - 1) bound = min(jl_frames, handle, cur_dur // 4, next_dur // 4) assert abs(shifts[i]) <= bound assert shifts[-1] == 0 def test_jl_off_and_trivial_cases(): segs = _keeps((0, 1), (2, 3), (4, 5)) assert compute_jl_cut_shifts(segs, 30.0, "off", 10) == [0, 0, 0] assert compute_jl_cut_shifts(segs, 30.0, "j", 0) == [0, 0, 0] assert compute_jl_cut_shifts(_keeps((0, 1)), 30.0, "j", 10) == [0] assert compute_jl_cut_shifts([], 30.0, "j", 10) == [] def test_jl_j_signs_and_bounds(): segs = _keeps((0, 1), (2, 3), (4, 5)) shifts = compute_jl_cut_shifts(segs, 30.0, "j", 2) assert shifts == [2, 2, 0] assert all(x >= 0 for x in shifts) _assert_jl_bounds(segs, shifts, 30.0, 2) def test_jl_l_signs_and_bounds(): segs = _keeps((0, 1), (2, 3), (4, 5)) shifts = compute_jl_cut_shifts(segs, 30.0, "l", 2) assert shifts == [-2, -2, 0] assert all(x <= 0 for x in shifts) _assert_jl_bounds(segs, shifts, 30.0, 2) def test_jl_clamped_by_handle(): # Touching segments leave no handle, so even a large request becomes 0. segs = _keeps((0, 1), (1, 2)) assert compute_jl_cut_shifts(segs, 30.0, "j", 100) == [0, 0] def test_jl_last_element_always_zero(): segs = _keeps((0, 1), (2, 3), (4, 5), (6, 7)) for mode in ("j", "l"): assert compute_jl_cut_shifts(segs, 30.0, mode, 2)[-1] == 0 # --------------------------------------------------------------------------- # map_words_to_edited_timeline # --------------------------------------------------------------------------- def test_map_words_to_edited_timeline(make_track): td1 = make_track( name="a.mp4", duration=4.0, timeline=[ Segment(0.0, 2.0, "keep"), Segment(2.0, 3.0, "drop"), Segment(3.0, 4.0, "keep"), ], words=[ Word("a", 1.0, 1.2), Word("dropped", 2.1, 2.3), Word("b", 3.1, 3.3), ], ) td2 = make_track( name="b.mp4", duration=3.0, timeline=[Segment(0.0, 3.0, "keep")], words=[Word("c", 0.5, 0.7)], ) mapped = map_words_to_edited_timeline([td1, td2]) onsets = {text: (s, e) for s, e, text in mapped} assert "dropped" not in onsets assert onsets["b"][0] == pytest.approx(2.1) assert onsets["c"][0] == pytest.approx(3.5) assert len(mapped) == 3 # --------------------------------------------------------------------------- # cache helpers # --------------------------------------------------------------------------- def test_cache_roundtrip_and_no_version_key(tmp_path): path = tmp_path / "cache.json" words = [Word("Hello", 0.0, 0.5), Word("world", 0.5, 1.0)] _write_cache(path, words) assert _read_cache(path) == words payload = json.loads(path.read_text(encoding="utf-8")) assert "version" not in payload assert payload["words"][0] == {"text": "Hello", "start": 0.0, "end": 0.5} def test_write_cache_empty_words_writes_nothing(tmp_path): path = tmp_path / "empty.json" _write_cache(path, []) assert not path.exists() def test_read_cache_corrupt_returns_none(tmp_path): path = tmp_path / "bad.json" path.write_text("{not valid json", encoding="utf-8") assert _read_cache(path) is None def test_read_cache_empty_words_returns_empty_list(tmp_path): path = tmp_path / "empty.json" path.write_text(json.dumps({"words": []}), encoding="utf-8") assert _read_cache(path) == [] def test_read_cache_bare_list_returns_none(tmp_path): path = tmp_path / "list.json" path.write_text(json.dumps([{"text": "x", "start": 0, "end": 1}]), encoding="utf-8") assert _read_cache(path) is None def test_cache_key_varies_with_model_and_language(): base = _cache_key("large-v3", "faster-whisper", "en") assert base != _cache_key("small", "faster-whisper", "en") assert base != _cache_key("large-v3", "faster-whisper", "fr") assert base == _cache_key("large-v3", "faster-whisper", "en") # --------------------------------------------------------------------------- # parse_args # --------------------------------------------------------------------------- def _parse(monkeypatch, argv): monkeypatch.setattr(sys, "argv", ["cadence.py", *argv]) return parse_args() @pytest.mark.parametrize( "argv", [ ["video.mp4", "--audio-track", "0"], ["video.mp4", "--jl-frames", "-1"], ["video.mp4", "--pad", "-0.1"], ["video.mp4", "--max-silence", "abc"], ], ) def test_parse_args_rejects_invalid(monkeypatch, argv): with pytest.raises(SystemExit) as exc: _parse(monkeypatch, argv) assert exc.value.code != 0 def test_parse_args_defaults_are_none(monkeypatch): args = _parse(monkeypatch, ["video.mp4"]) assert args.max_silence is None assert args.pad is None assert args.min_keep is None assert args.jl_frames is None assert args.preset is None def test_parse_args_version_exits_zero(monkeypatch): with pytest.raises(SystemExit) as exc: _parse(monkeypatch, ["--version"]) assert exc.value.code == 0