Add pytest suite and CI (roadmap A1)
37 headless tests covering the logic that is cheap to break: - split planner: plan_disc_split, chapter helpers (whole-fit, multi-disc partition, single-movie chapter/even-cut split, contiguous coverage) - High-profile fill-disc bitrate math (disc count + bounds + capacity) - ffmpeg command generation: profiles, text/image hardsub, 2-pass, nice, progress-to-file, even pad/crop + positive offsets, -ss seek, and the AC3 448k audio-clamp regression - ffprobe subtitle detection: text/image classification, tags/disposition tests/conftest.py makes the in-tree package importable and installs the gettext _ builtin so modules run headless. Forgejo Actions workflow runs the suite on push/PR. Update CLAUDE.md's testing section accordingly. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
b5a6cfe25f
commit
691664095b
9 changed files with 559 additions and 8 deletions
119
tests/conftest.py
Normal file
119
tests/conftest.py
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
"""Shared test fixtures/helpers for the devedeng-extended test suite.
|
||||
|
||||
These tests exercise pure logic and command generation without launching the
|
||||
GTK UI. They import the package from ``src/`` and stub out the gettext ``_``
|
||||
builtin so modules that call ``_(...)`` at runtime work headless.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import gettext
|
||||
|
||||
# make the in-tree package importable without installing
|
||||
_SRC = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "src")
|
||||
if _SRC not in sys.path:
|
||||
sys.path.insert(0, _SRC)
|
||||
|
||||
# ensure the gettext _() builtin exists (the app normally installs it at start)
|
||||
gettext.install("devedeng")
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
class FakeMovie:
|
||||
"""Minimal stand-in for file_movie for the pure split/bitrate planners.
|
||||
|
||||
Only the attributes those functions read are provided.
|
||||
"""
|
||||
|
||||
element_type = "file_movie"
|
||||
|
||||
def __init__(self, rate_kBps=586.5, length=3600, chapters_step=5,
|
||||
divide=True, manual=None, audio_streams=1, subtitles=0):
|
||||
self._rate = rate_kBps
|
||||
self.original_length = length
|
||||
self.chapter_size = chapters_step
|
||||
self.divide_in_chapters = divide
|
||||
self.chapter_list_entry = manual
|
||||
self.audio_streams = audio_streams
|
||||
self.subtitles_list = [None] * subtitles
|
||||
|
||||
def get_kbytes_per_second(self):
|
||||
return self._rate
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def make_movie():
|
||||
"""Factory for FakeMovie instances used by the planner tests."""
|
||||
def _make(**kwargs):
|
||||
return FakeMovie(**kwargs)
|
||||
return _make
|
||||
|
||||
|
||||
class FakeFileProject:
|
||||
"""Stand-in for a file_movie passed to ffmpeg/avconv.convert_file.
|
||||
|
||||
Defaults describe a typical 1080p 16:9 source targeting NTSC DVD with no
|
||||
hardsub. Override fields per test.
|
||||
"""
|
||||
|
||||
def __init__(self, **overrides):
|
||||
self.file_name = "/movies/input.mkv"
|
||||
self.title_name = "input"
|
||||
self.two_pass_encoding = False
|
||||
self.volume = 100
|
||||
self.audio_delay = 0.0
|
||||
self.copy_sound = False
|
||||
self.no_reencode_audio_video = False
|
||||
self.is_mpeg_ps = False
|
||||
self.video_list = [0]
|
||||
self.audio_list = [1]
|
||||
self.deinterlace = "deinterlace_none"
|
||||
self.prerotation = 0
|
||||
self.rotation = "rotation_0"
|
||||
self.mirror_vertical = False
|
||||
self.mirror_horizontal = False
|
||||
self.original_width = 1920
|
||||
self.original_height = 1080
|
||||
self.width_midle = 1920
|
||||
self.height_midle = 1080
|
||||
self.width_final = 720
|
||||
self.height_final = 480
|
||||
self.original_fps = 24
|
||||
self.format_pal = False
|
||||
self.sound5_1 = False
|
||||
self.gop12 = True
|
||||
self.video_rate_final = 4500
|
||||
self.audio_rate_final = 192
|
||||
self.aspect_ratio_final = 1.7777777777777777
|
||||
self.original_length = 7200
|
||||
self.hardsub_index = -1
|
||||
self.hardsub_kind = "text"
|
||||
self.embedded_subtitles = []
|
||||
for k, v in overrides.items():
|
||||
setattr(self, k, v)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def make_file_project():
|
||||
def _make(**overrides):
|
||||
return FakeFileProject(**overrides)
|
||||
return _make
|
||||
|
||||
|
||||
def ffmpeg_converter(disc_type="dvd", profile="compatibility"):
|
||||
"""Build an ffmpeg converter with the config singleton set for a test."""
|
||||
import devedeng.configuration_data
|
||||
import devedeng.ffmpeg
|
||||
cfg = devedeng.configuration_data.configuration.get_config()
|
||||
cfg.disc_type = disc_type
|
||||
cfg.encode_profile = profile
|
||||
return devedeng.ffmpeg.ffmpeg()
|
||||
|
||||
|
||||
def arg_after(cmd, flag):
|
||||
"""Return the argument following ``flag`` in an argv list, or None."""
|
||||
for i, a in enumerate(cmd):
|
||||
if a == flag and i + 1 < len(cmd):
|
||||
return cmd[i + 1]
|
||||
return None
|
||||
136
tests/test_ffmpeg_command.py
Normal file
136
tests/test_ffmpeg_command.py
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
"""Tests for ffmpeg.convert_file command generation (no encoding runs)."""
|
||||
|
||||
from conftest import ffmpeg_converter, arg_after
|
||||
|
||||
|
||||
def _vf(cmd):
|
||||
return arg_after(cmd, "-vf")
|
||||
|
||||
|
||||
def test_compatibility_dvd_basics(make_file_project):
|
||||
conv = ffmpeg_converter(profile="compatibility")
|
||||
conv.convert_file(make_file_project(), "/tmp/out.mpg", 0)
|
||||
cmd = conv.command_var
|
||||
assert "mpeg2video" in cmd
|
||||
assert "ac3" in cmd # fork uses AC3 for NTSC DVD
|
||||
assert arg_after(cmd, "-ar") == "48000"
|
||||
assert arg_after(cmd, "-b:v") == str(4500 * 1000)
|
||||
assert arg_after(cmd, "-b:a") == str(192 * 1000)
|
||||
assert _vf(cmd) == "scale=720:480"
|
||||
assert "-sn" in cmd # no soft-sub passthrough
|
||||
|
||||
|
||||
def test_runs_under_nice(make_file_project):
|
||||
conv = ffmpeg_converter()
|
||||
conv.convert_file(make_file_project(), "/tmp/out.mpg", 0)
|
||||
assert conv.command_var[:4] == ["nice", "-n", "10", "ffmpeg"]
|
||||
|
||||
|
||||
def test_progress_goes_to_file_not_pipe(make_file_project):
|
||||
# the deadlock fix: -progress must target a file, never pipe:N
|
||||
conv = ffmpeg_converter()
|
||||
conv.convert_file(make_file_project(), "/tmp/out.mpg", 0)
|
||||
prog = arg_after(conv.command_var, "-progress")
|
||||
assert prog is not None
|
||||
assert not prog.startswith("pipe:")
|
||||
assert prog.endswith(".progress")
|
||||
|
||||
|
||||
def test_text_hardsub_filter_after_scale(make_file_project):
|
||||
fp = make_file_project(
|
||||
hardsub_index=2, hardsub_kind="text",
|
||||
embedded_subtitles=[{"index": 2, "codec": "subrip", "language": "eng",
|
||||
"title": "", "default": True, "forced": False,
|
||||
"kind": "text"}],
|
||||
)
|
||||
conv = ffmpeg_converter()
|
||||
conv.convert_file(fp, "/tmp/out.mpg", 0)
|
||||
vf = _vf(conv.command_var)
|
||||
assert vf is not None
|
||||
assert vf.startswith("scale=720:480")
|
||||
assert "subtitles=" in vf
|
||||
assert "si=0" in vf # first (and only) subtitle stream
|
||||
|
||||
|
||||
def test_text_hardsub_escapes_path(make_file_project):
|
||||
fp = make_file_project(
|
||||
file_name="/movies/My Movie: 2024.mkv",
|
||||
hardsub_index=2, hardsub_kind="text",
|
||||
embedded_subtitles=[{"index": 2, "codec": "subrip", "language": "eng",
|
||||
"title": "", "default": False, "forced": False,
|
||||
"kind": "text"}],
|
||||
)
|
||||
conv = ffmpeg_converter()
|
||||
conv.convert_file(fp, "/tmp/out.mpg", 0)
|
||||
vf = _vf(conv.command_var)
|
||||
assert r"\:" in vf # the ':' in the path is escaped
|
||||
|
||||
|
||||
def test_image_hardsub_uses_filter_complex_overlay(make_file_project):
|
||||
fp = make_file_project(
|
||||
hardsub_index=3, hardsub_kind="image",
|
||||
embedded_subtitles=[{"index": 3, "codec": "hdmv_pgs_subtitle",
|
||||
"language": "eng", "title": "", "default": True,
|
||||
"forced": False, "kind": "image"}],
|
||||
)
|
||||
conv = ffmpeg_converter()
|
||||
conv.convert_file(fp, "/tmp/out.mpg", 0)
|
||||
cmd = conv.command_var
|
||||
fc = arg_after(cmd, "-filter_complex")
|
||||
assert fc is not None
|
||||
assert "overlay" in fc
|
||||
# overlay must come before scale so bitmap subs stay aligned to the source
|
||||
assert fc.index("overlay") < fc.index("scale=720:480")
|
||||
assert fc.endswith("[vsub]")
|
||||
assert arg_after(cmd, "-map") == "[vsub]" # video taken from the graph
|
||||
assert "-vf" not in cmd # not both -vf and filter_complex
|
||||
|
||||
|
||||
def test_high_profile_two_pass_and_448k_audio(make_file_project):
|
||||
# regression for the AC3 448k -> 384k clamp bug
|
||||
fp = make_file_project(two_pass_encoding=True,
|
||||
video_rate_final=3752, audio_rate_final=448)
|
||||
conv = ffmpeg_converter(profile="high")
|
||||
conv.convert_file(fp, "/tmp/out.mpg", 0)
|
||||
cmd = conv.command_var
|
||||
assert "-pass" in cmd and "-passlogfile" in cmd
|
||||
assert arg_after(cmd, "-b:a") == str(448 * 1000) # NOT clamped to 384k
|
||||
assert arg_after(cmd, "-b:v") == str(3752 * 1000)
|
||||
assert len(conv.childs) == 1 # spawns the pass-2 child
|
||||
|
||||
|
||||
def test_audio_bitrate_table_allows_ac3_rates(make_file_project):
|
||||
conv = ffmpeg_converter()
|
||||
assert conv._adjust_audio_bitrate(448) == 448
|
||||
assert conv._adjust_audio_bitrate(400) == 448 # rounds up to next valid
|
||||
assert conv._adjust_audio_bitrate(192) == 192
|
||||
assert conv._adjust_audio_bitrate(9999) == 640 # clamps to table max
|
||||
|
||||
|
||||
def test_even_dimensions_and_positive_crop_offset(make_file_project):
|
||||
# odd letterbox height (1079) must be rounded even, offset positive
|
||||
fp = make_file_project(width_midle=1920, height_midle=1079)
|
||||
conv = ffmpeg_converter()
|
||||
conv.convert_file(fp, "/tmp/out.mpg", 0)
|
||||
cmd = conv.command_var
|
||||
blob = _vf(cmd) or arg_after(cmd, "-filter_complex") or ""
|
||||
assert "1079" not in blob # no odd dimension
|
||||
assert ":-" not in blob # no negative crop/pad offset
|
||||
assert "1078" in blob # rounded down to even
|
||||
|
||||
|
||||
def test_start_offset_emits_ss_before_inputs(make_file_project):
|
||||
conv = ffmpeg_converter()
|
||||
conv.convert_file(make_file_project(), "/tmp/out.mpg", 0, start_offset=120)
|
||||
cmd = conv.command_var
|
||||
# -ss appears before each -i (two inputs: audio-delay copy + video)
|
||||
assert cmd.count("-ss") == 2
|
||||
first_i = cmd.index("-i")
|
||||
first_ss = cmd.index("-ss")
|
||||
assert first_ss < first_i
|
||||
|
||||
|
||||
def test_no_start_offset_has_no_ss(make_file_project):
|
||||
conv = ffmpeg_converter()
|
||||
conv.convert_file(make_file_project(), "/tmp/out.mpg", 0)
|
||||
assert "-ss" not in conv.command_var
|
||||
82
tests/test_ffprobe_detection.py
Normal file
82
tests/test_ffprobe_detection.py
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
"""Tests for embedded subtitle detection in ffprobe.process_json."""
|
||||
|
||||
import json
|
||||
import devedeng.ffprobe
|
||||
|
||||
|
||||
def _probe(streams):
|
||||
p = devedeng.ffprobe.ffprobe()
|
||||
data = json.dumps({"streams": streams})
|
||||
err = p.process_json("/movies/input.mkv", data)
|
||||
return p, err
|
||||
|
||||
|
||||
def _video(index=0, duration="120.0"):
|
||||
return {"index": index, "codec_type": "video", "codec_name": "h264",
|
||||
"width": 1920, "height": 1080, "avg_frame_rate": "24/1",
|
||||
"duration": duration}
|
||||
|
||||
|
||||
def _audio(index=1):
|
||||
return {"index": index, "codec_type": "audio", "codec_name": "aac",
|
||||
"sample_rate": "48000"}
|
||||
|
||||
|
||||
def _sub(index, codec, language="eng", title="", default=0, forced=0):
|
||||
return {"index": index, "codec_type": "subtitle", "codec_name": codec,
|
||||
"tags": {"language": language, "title": title},
|
||||
"disposition": {"default": default, "forced": forced}}
|
||||
|
||||
|
||||
def test_no_subtitles_empty_list():
|
||||
p, err = _probe([_video(), _audio()])
|
||||
assert err is False
|
||||
assert p.subtitle_tracks == []
|
||||
|
||||
|
||||
def test_text_subtitle_classified_text():
|
||||
p, _ = _probe([_video(), _audio(), _sub(2, "subrip", "eng", default=1)])
|
||||
assert len(p.subtitle_tracks) == 1
|
||||
t = p.subtitle_tracks[0]
|
||||
assert t["index"] == 2
|
||||
assert t["codec"] == "subrip"
|
||||
assert t["language"] == "eng"
|
||||
assert t["kind"] == "text"
|
||||
assert t["default"] is True
|
||||
assert t["forced"] is False
|
||||
|
||||
|
||||
def test_image_subtitles_classified_image():
|
||||
for codec in ("hdmv_pgs_subtitle", "dvd_subtitle", "dvdsub", "pgssub", "xsub"):
|
||||
p, _ = _probe([_video(), _audio(), _sub(2, codec)])
|
||||
assert p.subtitle_tracks[0]["kind"] == "image", codec
|
||||
|
||||
|
||||
def test_various_text_codecs_classified_text():
|
||||
for codec in ("subrip", "ass", "ssa", "mov_text", "webvtt"):
|
||||
p, _ = _probe([_video(), _audio(), _sub(2, codec)])
|
||||
assert p.subtitle_tracks[0]["kind"] == "text", codec
|
||||
|
||||
|
||||
def test_multiple_subtitle_tracks_in_order():
|
||||
p, _ = _probe([
|
||||
_video(), _audio(),
|
||||
_sub(2, "subrip", "eng", default=1),
|
||||
_sub(3, "hdmv_pgs_subtitle", "fre", forced=1),
|
||||
_sub(4, "ass", "jpn", title="Signs"),
|
||||
])
|
||||
assert [t["index"] for t in p.subtitle_tracks] == [2, 3, 4]
|
||||
assert [t["kind"] for t in p.subtitle_tracks] == ["text", "image", "text"]
|
||||
assert p.subtitle_tracks[1]["forced"] is True
|
||||
assert p.subtitle_tracks[2]["title"] == "Signs"
|
||||
|
||||
|
||||
def test_missing_tags_and_disposition_defaults():
|
||||
# a subtitle stream with no tags/disposition must not crash
|
||||
p, _ = _probe([_video(), _audio(),
|
||||
{"index": 2, "codec_type": "subtitle",
|
||||
"codec_name": "subrip"}])
|
||||
t = p.subtitle_tracks[0]
|
||||
assert t["language"] == ""
|
||||
assert t["default"] is False
|
||||
assert t["forced"] is False
|
||||
67
tests/test_high_profile_bitrate.py
Normal file
67
tests/test_high_profile_bitrate.py
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
"""Tests for the High-quality profile fill-the-disc bitrate math."""
|
||||
|
||||
from devedeng.project import compute_high_profile_bitrate as calc
|
||||
|
||||
CAP_KB = 3_780_000.0 # 4.2 GB * 0.90 margin, in kBytes
|
||||
FLOOR = 3000
|
||||
CAP = 9000
|
||||
|
||||
|
||||
def audio_kbits(rate_kbps, runtime_s, streams=1):
|
||||
return rate_kbps * streams * runtime_s
|
||||
|
||||
|
||||
def test_short_movie_one_disc_at_cap():
|
||||
# 30 min: tiny, so bitrate is pinned at the 9000k cap, one disc
|
||||
n, br = calc(1800, audio_kbits(448, 1800), CAP_KB)
|
||||
assert n == 1
|
||||
assert br == CAP
|
||||
|
||||
|
||||
def test_two_hours_fills_one_disc():
|
||||
n, br = calc(7200, audio_kbits(448, 7200), CAP_KB)
|
||||
assert n == 1
|
||||
assert FLOOR <= br <= CAP
|
||||
# should be comfortably above the floor (filling the disc)
|
||||
assert br > 3500
|
||||
|
||||
|
||||
def test_four_hours_two_discs():
|
||||
n, br = calc(14400, audio_kbits(448, 14400), CAP_KB)
|
||||
assert n == 2
|
||||
assert FLOOR <= br <= CAP
|
||||
|
||||
|
||||
def test_eight_hours_multiple_discs():
|
||||
n, br = calc(28800, audio_kbits(448, 28800), CAP_KB)
|
||||
assert n >= 3
|
||||
assert br >= FLOOR
|
||||
|
||||
|
||||
def test_zero_runtime_guard():
|
||||
assert calc(0, 0, CAP_KB) == (1, CAP)
|
||||
|
||||
|
||||
def test_bitrate_always_within_bounds():
|
||||
for rt in (300, 1800, 3600, 7200, 14400, 30000, 60000):
|
||||
n, br = calc(rt, audio_kbits(448, rt), CAP_KB)
|
||||
assert FLOOR <= br <= CAP, (rt, br)
|
||||
assert n >= 1
|
||||
|
||||
|
||||
def test_filled_content_does_not_exceed_disc_capacity():
|
||||
# the chosen bitrate must not overflow the n discs it claims to fill
|
||||
for rt in (1800, 7200, 14400, 28800):
|
||||
ak = audio_kbits(448, rt)
|
||||
n, br = calc(rt, ak, CAP_KB)
|
||||
used_kbits = br * rt + ak
|
||||
assert used_kbits <= n * CAP_KB * 8 + 1 # +1 for rounding slack
|
||||
|
||||
|
||||
def test_menu_reservation_reduces_video_budget():
|
||||
rt = 7200
|
||||
ak = audio_kbits(448, rt)
|
||||
n0, br0 = calc(rt, ak, CAP_KB, menu_kbytes=0)
|
||||
n1, br1 = calc(rt, ak, CAP_KB, menu_kbytes=200_000)
|
||||
# with a menu eating space, the fill bitrate can only be <= the no-menu one
|
||||
assert br1 <= br0
|
||||
115
tests/test_split_planner.py
Normal file
115
tests/test_split_planner.py
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
"""Tests for the pure disc-split planner in project.py."""
|
||||
|
||||
from devedeng.project import (
|
||||
plan_disc_split,
|
||||
_movie_chapter_seconds,
|
||||
_nearest_chapter_before,
|
||||
)
|
||||
|
||||
# ~4.2 GB usable disc in kBytes (matches get_dvd_size()[0]*1000 with margin)
|
||||
CAP = 4_200_000.0
|
||||
# 4500k video + 192k audio = 4692 kbit/s -> /8 = 586.5 kByte/s
|
||||
RATE = (4500 + 192) / 8.0
|
||||
# seconds that fill one disc at RATE
|
||||
SECS_PER_DISC = CAP / RATE
|
||||
|
||||
|
||||
def names(plan):
|
||||
return [[seg[0] for seg in disc] for disc in plan]
|
||||
|
||||
|
||||
def test_single_short_movie_one_disc(make_movie):
|
||||
m = make_movie(rate_kBps=RATE, length=3600)
|
||||
plan = plan_disc_split([m], CAP)
|
||||
assert len(plan) == 1
|
||||
assert plan[0] == [(m, 0, 0)]
|
||||
|
||||
|
||||
def test_two_movies_fit_one_disc(make_movie):
|
||||
a = make_movie(rate_kBps=RATE, length=3000)
|
||||
b = make_movie(rate_kBps=RATE, length=3000)
|
||||
plan = plan_disc_split([a, b], CAP)
|
||||
assert len(plan) == 1
|
||||
assert names(plan) == [[a, b]]
|
||||
|
||||
|
||||
def test_two_movies_overflow_to_two_discs(make_movie):
|
||||
a = make_movie(rate_kBps=RATE, length=5000)
|
||||
b = make_movie(rate_kBps=RATE, length=5000)
|
||||
plan = plan_disc_split([a, b], CAP)
|
||||
assert len(plan) == 2
|
||||
assert plan[0] == [(a, 0, 0)]
|
||||
assert plan[1] == [(b, 0, 0)]
|
||||
|
||||
|
||||
def test_single_long_movie_splits_at_chapter(make_movie):
|
||||
# 200 min movie, chapters every 5 min (300 s)
|
||||
m = make_movie(rate_kBps=RATE, length=12000, chapters_step=5)
|
||||
plan = plan_disc_split([m], CAP)
|
||||
assert len(plan) >= 2
|
||||
first = plan[0][0]
|
||||
assert first[0] is m and first[1] == 0
|
||||
cut = first[2]
|
||||
# cut lands on a 300-s chapter boundary, just under one disc's capacity
|
||||
assert cut % 300 == 0
|
||||
assert SECS_PER_DISC - 300 < cut <= SECS_PER_DISC
|
||||
# last segment runs to the end (duration 0)
|
||||
assert plan[-1][0][2] == 0
|
||||
|
||||
|
||||
def test_single_long_movie_no_chapters_even_cut(make_movie):
|
||||
m = make_movie(rate_kBps=RATE, length=12000, divide=False)
|
||||
plan = plan_disc_split([m], CAP)
|
||||
assert len(plan) >= 2
|
||||
# first cut is an exact time cut at capacity
|
||||
assert abs(plan[0][0][2] - SECS_PER_DISC) < 1.0
|
||||
|
||||
|
||||
def test_segments_cover_whole_movie(make_movie):
|
||||
m = make_movie(rate_kBps=RATE, length=12000, divide=False)
|
||||
plan = plan_disc_split([m], CAP)
|
||||
# reconstruct covered span from (start, duration) segments
|
||||
covered = 0.0
|
||||
for disc in plan:
|
||||
movie, start, duration = disc[0]
|
||||
end = movie.original_length if duration == 0 else start + duration
|
||||
assert start == covered # contiguous, no gaps/overlaps
|
||||
covered = end
|
||||
assert covered == m.original_length
|
||||
|
||||
|
||||
def test_menu_reservation_on_first_disc(make_movie):
|
||||
m = make_movie(rate_kBps=RATE, length=3000)
|
||||
# reserve a chunk on disc 1; small movie still fits
|
||||
plan = plan_disc_split([m], CAP, first_disc_reserved_kbytes=500_000)
|
||||
assert len(plan) == 1
|
||||
|
||||
|
||||
def test_empty_project_returns_one_empty_disc():
|
||||
plan = plan_disc_split([], CAP)
|
||||
assert plan == [[]]
|
||||
|
||||
|
||||
def test_zero_rate_does_not_crash(make_movie):
|
||||
m = make_movie(rate_kBps=0, length=3600)
|
||||
plan = plan_disc_split([m], CAP)
|
||||
assert len(plan) >= 1
|
||||
|
||||
|
||||
# --- chapter helpers ---
|
||||
|
||||
def test_chapter_seconds_auto_and_manual(make_movie):
|
||||
m = make_movie(length=1000, chapters_step=5, manual="2:30,9:00")
|
||||
# auto: 300, 600, 900 (< length-4); manual: 150 (2:30), 540 (9:00)
|
||||
assert _movie_chapter_seconds(m) == [150, 300, 540, 600, 900]
|
||||
|
||||
|
||||
def test_chapter_seconds_disabled(make_movie):
|
||||
m = make_movie(length=1000, divide=False)
|
||||
assert _movie_chapter_seconds(m) == []
|
||||
|
||||
|
||||
def test_nearest_chapter_before():
|
||||
assert _nearest_chapter_before([300, 600, 900], 0, 700) == 600
|
||||
assert _nearest_chapter_before([300, 600, 900], 0, 250) is None
|
||||
assert _nearest_chapter_before([300, 600, 900], 600, 1000) == 900
|
||||
Loading…
Add table
Add a link
Reference in a new issue