4195854bf8
Vier neue reine Bausteine, jeder mit Tests. Sie beantworten Fragen, die Rippy bisher geraten oder gar nicht gestellt hat. 1. caps.parse_preset_liste - die Preset-NAMEN, die das HandBrake DIESES Workers wirklich kennt (`--preset-list`, Format im Worker-Image gemessen). Damit endet das Raten: die Namen unterscheiden sich je HandBrake-Version, und ein erfundener Name laesst die Kompression scheitern. Richtigstellung zum SAVEPOINT v3.16: Dort galt es als unmoeglich, die Hardware-Preset-Namen auf der Rippy-VM zu ermitteln, weil dort kein Hardware-Encoder laeuft. Gemessen ist das falsch - die Kategorie `Hardware/` steht vollstaendig in der Liste (VCN, NVENC, QSV, MF). HandBrake trennt zwei Fragen: --preset-list nennt alle Presets, --help nur die nutzbaren Encoder. Zwei Fragen, zwei Quellen. 2. mounts.pfad_map_vorschlag/pfad_map_zeile - der fehlende Anschluss fuer RIPPY_PATH_MAP. Geraten werden muss dafuer nichts: Rippy hat die Freigabe selbst eingehaengt und kennt ihre Quelle (//host/share). Mountpunkt plus Quelle IST das Mapping. NFS gibt bewusst "" - Windows-Schreibweise ist nicht ableitbar (AGENTS Regel D). Der Test fand dabei sofort die dokumentierte Windows-Falle: os.path.join baute `/app/media\rippy` in einen Container-Pfad, das Mapping waere still wirkungslos geblieben. _mountpoint nutzt jetzt posixpath. 3. presets.empfehlung - "immer das Beste" (Commander-Anforderung) ohne Punktesystem: fuer jede Lage eine feste Reihenfolge echter Namen, genommen wird der erste, den der Worker kennt. Hardware nur, wenn die Familie wirklich gemeldet ist (vce -> Preset heisst VCN, sonst liefe eine AMD-Karte unter dem Intel-Namen). vaapi und MF werden nie empfohlen: fuer vaapi gibt es kein Preset, bei MF ist die Nutzbarkeit nicht ablesbar. 4. eta - Restzeit aus dem gemessenen Fortschritt. Messreihe je Phase (Rip und Kompression haben nichts miteinander zu tun), Stillstand verlaengert die Schaetzung, und unter zwei Messwerten oder 60 Sekunden Spanne gibt es ehrlich keine Aussage. Test mit den echten 25.07.-Werten: 1,44 % in 29 min landet bei "noch ca. 1 Tag 23 h" - genau die Angabe, deren Fehlen den 50-Stunden-Lauf unsichtbar machte. Dazu: HandBrakes "Invalid preset <Name>" wird uebersetzt. Vorher stand im UI nur "HandBrake endete mit Code 3" - dass der Preset-NAME das Problem ist, war daraus nicht zu erraten. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
160 lines
6.7 KiB
Python
160 lines
6.7 KiB
Python
"""Tests der Restzeit-Schätzung — reine Funktionen, keine Infrastruktur.
|
||
|
||
Die Zahlen in den Szenarien sind die echten Messwerte vom 25.07.2026: Der
|
||
4K-Encode von Akira kam in 29 Minuten von 0 auf 1,44 % (an der Leseposition im
|
||
Quellstrom gemessen, `/proc/<pid>/fdinfo/`) — hochgerechnet 28-55 Stunden. Genau
|
||
dieser Fall muss eine ehrliche Antwort geben, statt „gleich fertig" zu behaupten.
|
||
"""
|
||
|
||
import eta
|
||
|
||
|
||
def test_ohne_genug_punkte_keine_aussage():
|
||
assert eta.restzeit_sekunden(None, 100.0) == -1
|
||
assert eta.restzeit_sekunden({"status": "transcoding", "punkte": []}, 100.0) == -1
|
||
einer = {"status": "transcoding", "punkte": [[0.0, 5]]}
|
||
assert eta.restzeit_sekunden(einer, 100.0) == -1
|
||
|
||
|
||
def test_zu_kurze_spanne_keine_aussage():
|
||
"""Zwei Punkte 10 s auseinander sagen nichts über einen Encode, der Stunden
|
||
läuft — MINDEST_SPANNE_SEKUNDEN verhindert die Hochrechnung."""
|
||
reihe = {"status": "transcoding", "punkte": [[0.0, 1], [10.0, 2]]}
|
||
assert eta.restzeit_sekunden(reihe, 10.0) == -1
|
||
|
||
|
||
def test_kein_fortschritt_keine_aussage():
|
||
"""Eine Stunde ohne einen einzigen Prozentpunkt: die Rate ist unbekannt,
|
||
nicht null. Ohne diese Sperre käme eine Division durch 0."""
|
||
reihe = {"status": "transcoding", "punkte": [[0.0, 3]]}
|
||
reihe = eta.beobachtung_hinzufuegen(reihe, "transcoding", 3, 3600.0)
|
||
assert eta.restzeit_sekunden(reihe, 3600.0) == -1
|
||
|
||
|
||
def test_einfache_hochrechnung():
|
||
"""10 % in 10 Minuten → 90 % brauchen 90 Minuten."""
|
||
reihe = {"status": "ripping", "punkte": [[0.0, 0], [600.0, 10]]}
|
||
assert eta.restzeit_sekunden(reihe, 600.0) == 90 * 60
|
||
|
||
|
||
def test_der_echte_4k_fall_landet_in_der_groessenordnung_tage():
|
||
"""Gemessen: 1,44 % in 29 Minuten. Die Schätzung muss in der Größenordnung
|
||
TAGE landen — das ist die Angabe, deren Fehlen den 50-Stunden-Lauf am
|
||
25.07.2026 unsichtbar machte. Die genaue Stundenzahl ist Nebensache; wer
|
||
„1 Tag 23 h" liest, bricht ab, wer nichts liest, wartet."""
|
||
reihe = {"status": "transcoding", "punkte": [[0.0, 0], [29 * 60.0, 1]]}
|
||
sekunden = eta.restzeit_sekunden(reihe, 29 * 60.0)
|
||
stunden = sekunden / 3600
|
||
assert 45 < stunden < 50 # 99 % × 29 min ≈ 47,85 h
|
||
assert eta.formatiere_restzeit(sekunden) == "noch ca. 1 Tag 23 h"
|
||
# Etwas langsamer, und es heißt nur noch „mehr als 2 Tage" — bei der
|
||
# Größenordnung wäre jede Stundenangabe erfundene Genauigkeit.
|
||
langsamer = {"status": "transcoding", "punkte": [[0.0, 0], [45 * 60.0, 1]]}
|
||
assert eta.formatiere_restzeit(
|
||
eta.restzeit_sekunden(langsamer, 45 * 60.0)) == "mehr als 2 Tage"
|
||
|
||
|
||
def test_stillstand_verlaengert_die_schaetzung():
|
||
"""Kernpunkt: Bleibt der Fortschritt stehen, MUSS die Restzeit steigen —
|
||
sonst zeigt ein hängender Job stundenlang „noch 5 Minuten"."""
|
||
reihe = {"status": "transcoding", "punkte": [[0.0, 0], [600.0, 50]]}
|
||
frisch = eta.restzeit_sekunden(reihe, 600.0)
|
||
# ... eine Stunde später steht der Fortschritt immer noch bei 50 %
|
||
reihe = eta.beobachtung_hinzufuegen(reihe, "transcoding", 50, 4200.0)
|
||
spaeter = eta.restzeit_sekunden(reihe, 4200.0)
|
||
assert spaeter > frisch * 5
|
||
|
||
|
||
def test_statuswechsel_verwirft_die_reihe():
|
||
"""Rip (eine Stunde) und Kompression (Tage) haben nichts miteinander zu tun.
|
||
Ohne diesen Schnitt entstünde beim Phasenwechsel eine Phantasiezahl."""
|
||
reihe = {"status": "ripping", "punkte": [[0.0, 0], [600.0, 50]]}
|
||
neu = eta.beobachtung_hinzufuegen(reihe, "transcoding", 2, 610.0)
|
||
assert neu["status"] == "transcoding"
|
||
assert neu["punkte"] == [[610.0, 2]]
|
||
assert eta.restzeit_sekunden(neu, 610.0) == -1
|
||
|
||
|
||
def test_nur_das_fenster_zaehlt():
|
||
"""Alte Punkte fliegen raus — HandBrake wird bei komplexen Szenen langsamer,
|
||
und dann lügt der Anfang der Messreihe."""
|
||
reihe = None
|
||
for i in range(20):
|
||
reihe = eta.beobachtung_hinzufuegen(reihe, "transcoding", i, float(i * 60))
|
||
assert len(reihe["punkte"]) == eta.FENSTER
|
||
assert reihe["punkte"][0][1] == 10 # die ersten zehn sind weg
|
||
|
||
|
||
def test_gleicher_fortschritt_haengt_keinen_punkt_an():
|
||
reihe = eta.beobachtung_hinzufuegen(None, "transcoding", 7, 0.0)
|
||
reihe = eta.beobachtung_hinzufuegen(reihe, "transcoding", 7, 30.0)
|
||
reihe = eta.beobachtung_hinzufuegen(reihe, "transcoding", 7, 60.0)
|
||
assert reihe["punkte"] == [[0.0, 7]]
|
||
|
||
|
||
def test_fertig_ist_null():
|
||
reihe = {"status": "transcoding", "punkte": [[0.0, 50], [600.0, 100]]}
|
||
assert eta.restzeit_sekunden(reihe, 600.0) == 0
|
||
assert eta.formatiere_restzeit(0) == "unter einer Minute"
|
||
|
||
|
||
# --- Textform ---------------------------------------------------------------
|
||
|
||
|
||
def test_textform_deckt_alle_groessenordnungen():
|
||
assert eta.formatiere_restzeit(-1) == ""
|
||
assert eta.formatiere_restzeit(None) == ""
|
||
assert eta.formatiere_restzeit(30) == "unter einer Minute"
|
||
assert eta.formatiere_restzeit(90) == "noch ca. 1 min"
|
||
assert eta.formatiere_restzeit(45 * 60) == "noch ca. 45 min"
|
||
assert eta.formatiere_restzeit(3 * 3600 + 7 * 60) == "noch ca. 3 h 07 min"
|
||
assert eta.formatiere_restzeit(30 * 3600) == "noch ca. 1 Tag 6 h"
|
||
assert eta.formatiere_restzeit(60 * 3600) == "mehr als 2 Tage"
|
||
|
||
|
||
# --- Der Weg über den Cache -------------------------------------------------
|
||
|
||
|
||
def test_aktualisiere_und_schaetze_ueber_zwei_aufrufe():
|
||
"""So läuft es live: /jobs wird alle vier Sekunden abgefragt, jeder Aufruf
|
||
schreibt die Reihe fort."""
|
||
speicher = {}
|
||
|
||
def hole(k):
|
||
return speicher.get(k)
|
||
|
||
def lege(k, wert, expire=None):
|
||
speicher[k] = wert
|
||
|
||
erst = eta.aktualisiere_und_schaetze(
|
||
"job1", "transcoding", 10, 0.0, hole, lege)
|
||
assert erst == {"sekunden": -1, "text": ""} # ein Punkt sagt nichts
|
||
|
||
dann = eta.aktualisiere_und_schaetze(
|
||
"job1", "transcoding", 20, 600.0, hole, lege)
|
||
assert dann["sekunden"] == 80 * 60
|
||
assert dann["text"] == "noch ca. 1 h 20 min"
|
||
|
||
|
||
def test_kaputter_cache_bringt_nichts_zum_absturz():
|
||
"""Redis weg → keine ETA, aber die Job-Liste muss weiter funktionieren."""
|
||
def kaputt_holen(k):
|
||
raise RuntimeError("Redis weg")
|
||
|
||
def kaputt_legen(k, wert, expire=None):
|
||
raise RuntimeError("Redis weg")
|
||
|
||
ergebnis = eta.aktualisiere_und_schaetze(
|
||
"job1", "transcoding", 10, 0.0, kaputt_holen, kaputt_legen)
|
||
assert ergebnis == {"sekunden": -1, "text": ""}
|
||
|
||
|
||
def test_fertige_und_wartende_jobs_bekommen_keine_eta():
|
||
speicher = {}
|
||
for status, progress in (("completed", 100), ("failed", 42), ("pending", 0)):
|
||
ergebnis = eta.aktualisiere_und_schaetze(
|
||
"x", status, progress, 0.0,
|
||
speicher.get, lambda k, v, expire=None: speicher.__setitem__(k, v))
|
||
assert ergebnis == {"sekunden": -1, "text": ""}
|
||
assert speicher == {} # nichts geschrieben
|