Files
VoiceStudio/tests/test_dub_pipeline_state.py
T
6b91205036 fix(dub): completed tracks always show their tabs + history keeps its language (P0) (#956)
* fix(dub): completed tracks always show their tabs + history keeps its language (P0)

Root cause chain: the track switcher's visibility expression required
dubLangCode !== 'und' and ended in a tautology (dubTracks?.length > 0 ||
!!dubTracks), so it was effectively keyed to the language dropdown, not
the persisted tracks. History restore always handed the frontend 'und'
because the dub_history language/language_code COLUMNS froze at the
ingest-time "" — the save_job UPSERT never updated them after generation
set them on the job dict (only the job_data JSON carried the real value).
Net effect: a restored project with finished tracks showed no track tabs
until the user re-picked a language.

- DubTab: hasDubbedTrack = done && dubTracks.length > 0 (tracks only;
  also stops the tautology from showing a trackless switcher).
- DubTab auto-jump: membership-guarded — the preview only jumps to a
  language that has a track, else tracks[0]. Kills the preview-404 class
  (restores falling back to 'en' with tracks ['bn'] pointed the player
  at /dub/preview-video?lang=en).
- dub_pipeline.save_job UPSERT: language/language_code now update when
  non-empty (same CASE guard as content_hash), so new saves heal the
  frozen columns and empty re-saves can't clobber them back.
- App.restoreDubHistory: falls back to job_data's language/language_code
  so EXISTING rows in users' DBs restore correctly with no migration.
- P0.2 polish: track pills get duration + timing-strategy tooltips,
  hydrated lazily and failure-silently from the existing
  GET /dub/tracks/{job_id} via new api/dub.dubListTracks; all new
  strings through i18n (en.json).

Tests (fail-before/pass-after): DubTab-level visibility + auto-jump
membership-guard tests (3 of 4 fail pre-fix), pill-tooltip hydration
tests, and save_job language heal/no-clobber tests (heal fails pre-fix).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(changelog): open [Unreleased] with the dub track-tabs fix (#956)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 01:21:23 +05:30

161 lines
5.4 KiB
Python

"""Phase 2.4/2.7 — `services/dub_pipeline` state helpers.
Covers the non-ingest, non-streaming surface: path safety, cache lookup,
process tracking, in-memory + DB job round-trip.
"""
import os
os.environ.setdefault("OMNIVOICE_DISABLE_FILE_LOG", "1")
import uuid
import pytest
from core.db import db_conn, init_db
from services import dub_pipeline as dp
@pytest.fixture(autouse=True)
def _init():
init_db()
yield
def _jid():
return f"p_{uuid.uuid4().hex[:8]}"
# ── Path safety ─────────────────────────────────────────────────────────────
def test_safe_job_dir_rejects_traversal():
assert dp.safe_job_dir("") is None
assert dp.safe_job_dir("../etc") is None
assert dp.safe_job_dir("..") is None
assert dp.safe_job_dir("a/b") is None
# Legit ids resolve under DUB_DIR.
ok = dp.safe_job_dir("abc123")
assert ok is not None
assert ok.endswith("abc123")
# ── SSE event shape ─────────────────────────────────────────────────────────
def test_prep_event_contains_type_and_fields():
out = dp.prep_event("extract_done", job_id="x", duration=1.5)
assert out.startswith("data: ")
assert '"type": "extract_done"' in out
assert '"job_id": "x"' in out
assert '"duration": 1.5' in out
assert out.endswith("\n\n")
def test_sse_event_shape():
out = dp.sse_event("segments", {"n": 3})
assert out.startswith(b"event: segments\ndata: ")
assert out.endswith(b"\n\n")
# ── Process tracking ────────────────────────────────────────────────────────
class _FakeProc:
def __init__(self):
self.returncode = None
self.killed = False
def kill(self):
self.killed = True
self.returncode = -9
def test_register_unregister_has_active():
jid = _jid()
proc = _FakeProc()
assert not dp.has_active_procs(jid)
dp.register_proc(jid, proc)
assert dp.has_active_procs(jid)
dp.unregister_proc(jid, proc)
assert not dp.has_active_procs(jid)
def test_kill_job_procs_is_idempotent():
jid = _jid()
dp.register_proc(jid, _FakeProc())
dp.register_proc(jid, _FakeProc())
dp.kill_job_procs(jid)
# Called twice — second call is a no-op.
dp.kill_job_procs(jid)
assert not dp.has_active_procs(jid)
# ── Job state round-trip ────────────────────────────────────────────────────
def test_put_get_job_in_memory():
jid = _jid()
assert dp.get_job(jid) is None
dp.put_job(jid, {"filename": "x.mp4", "duration": 1.23})
got = dp.get_job(jid)
assert got["filename"] == "x.mp4"
assert got["duration"] == 1.23
def test_save_job_persists_to_dub_history():
"""save_job writes to dub_history so a subsequent get_job on a cold cache
can hydrate from disk."""
jid = _jid()
dp.put_job(jid, {"filename": "disk.mp4", "duration": 9.0, "dubbed_tracks": {}, "segments": []})
dp.save_job(jid, dp.get_job(jid), filename="disk.mp4", duration=9.0)
# Simulate fresh process: drop in-memory entry, force re-hydrate.
dp._dub_jobs.pop(jid, None)
rehydrated = dp.get_job(jid)
assert rehydrated is not None
assert rehydrated["filename"] == "disk.mp4"
assert rehydrated["duration"] == 9.0
def _lang_row(jid):
with db_conn() as conn:
return conn.execute(
"SELECT language, language_code FROM dub_history WHERE id=?", (jid,)
).fetchone()
def test_save_job_upsert_heals_language_columns():
"""Completed-tracks-hidden P0: the ingest-time insert writes language /
language_code as "" (target language not chosen yet). Generation sets them
on the job dict, so the next save_job UPSERT must update the columns —
before the fix the update list skipped them and the row stayed "" forever,
which made history restore hand the frontend 'und' and hide the finished
tracks' tabs."""
jid = _jid()
job = {"filename": "v.mp4", "duration": 3.0, "segments": [], "dubbed_tracks": {}}
dp.save_job(jid, job) # ingest-time insert: both columns ""
row = _lang_row(jid)
assert row["language"] == "" and row["language_code"] == ""
job["language"] = "Bengali"
job["language_code"] = "bn"
dp.save_job(jid, job) # post-generation re-save heals the columns
row = _lang_row(jid)
assert row["language"] == "Bengali"
assert row["language_code"] == "bn"
def test_save_job_upsert_does_not_clobber_language_with_empty():
"""A later save without language info (e.g. a segment edit on a job dict
that predates the fields) must NOT reset the healed columns — same
non-empty guard the UPSERT already applies to content_hash."""
jid = _jid()
job = {
"filename": "v.mp4", "duration": 3.0, "segments": [], "dubbed_tracks": {},
"language": "Bengali", "language_code": "bn",
}
dp.save_job(jid, job)
job.pop("language")
job.pop("language_code")
dp.save_job(jid, job) # empty values must lose to the stored ones
row = _lang_row(jid)
assert row["language"] == "Bengali"
assert row["language_code"] == "bn"