Retention deletions are sequenced so the guarantee actually holds: the video goes only after the FLAC is extracted, verified and committed, and the FLAC only after every output is written and committed. Each deletion records itself both in state.json and in events.jsonl, so a later run can tell "removed as requested" from "gone and should not be". Two bugs the tests caught while writing this: - params_changed compared the requested model id against the resolved one, so every re-run looked like a parameter change and re-transcribed. An unpinned model now matches whatever ran; only an explicit --model can disagree. - chain=DEFAULT_CHAIN as a default argument binds at import time, which made the backend chain impossible to override. It is resolved at call time now. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
168 lines
6.2 KiB
Python
168 lines
6.2 KiB
Python
from __future__ import annotations
|
|
|
|
import pytest
|
|
|
|
from ccn_transcribe import errors
|
|
from ccn_transcribe.jobs import state as job_state
|
|
|
|
|
|
def make() -> job_state.JobState:
|
|
return job_state.JobState.new(job_id="youtube-abc", source_url="https://y.test/watch?v=abc")
|
|
|
|
|
|
class TestNew:
|
|
def test_starts_with_everything_missing(self) -> None:
|
|
job = make()
|
|
assert job.video.status is job_state.ArtifactStatus.MISSING
|
|
assert job.audio.status is job_state.ArtifactStatus.MISSING
|
|
assert job.outputs.status is job_state.ArtifactStatus.MISSING
|
|
|
|
def test_records_identity_and_timestamps(self) -> None:
|
|
job = make()
|
|
assert job.job_id == "youtube-abc"
|
|
assert job.source_url == "https://y.test/watch?v=abc"
|
|
assert job.created_at and job.updated_at
|
|
|
|
def test_carries_the_current_schema_version(self) -> None:
|
|
assert make().schema_version == job_state.SCHEMA_VERSION
|
|
|
|
|
|
class TestRoundTrip:
|
|
def test_survives_serialization(self) -> None:
|
|
job = make()
|
|
job.video = job_state.Artifact(
|
|
job_state.ArtifactStatus.PRESENT, path="media/v.mkv", duration_s=12.5
|
|
)
|
|
job.title = "A title"
|
|
assert job_state.JobState.from_dict(job.to_dict()) == job
|
|
|
|
def test_enums_serialize_as_plain_strings(self) -> None:
|
|
doc = make().to_dict()
|
|
assert doc["artifacts"]["video"]["status"] == "missing"
|
|
|
|
def test_outputs_paths_round_trip(self) -> None:
|
|
job = make()
|
|
job.outputs = job_state.Outputs(
|
|
job_state.ArtifactStatus.PRESENT, paths={"srt": "out/transcript.srt"}
|
|
)
|
|
assert job_state.JobState.from_dict(job.to_dict()).outputs.paths == {
|
|
"srt": "out/transcript.srt"
|
|
}
|
|
|
|
def test_transcription_record_round_trips(self) -> None:
|
|
job = make()
|
|
job.transcription = job_state.TranscriptionRecord(
|
|
model_id="m",
|
|
model_revision="r",
|
|
backend="openvino",
|
|
device="GPU",
|
|
language="en",
|
|
task="transcribe",
|
|
num_beams=1,
|
|
)
|
|
restored = job_state.JobState.from_dict(job.to_dict())
|
|
assert restored.transcription is not None
|
|
assert restored.transcription.device == "GPU"
|
|
|
|
def test_unknown_keys_are_ignored_not_fatal(self) -> None:
|
|
doc = make().to_dict()
|
|
doc["something_from_the_future"] = 1
|
|
assert job_state.JobState.from_dict(doc).job_id == "youtube-abc"
|
|
|
|
|
|
class TestSchemaVersioning:
|
|
def test_a_newer_schema_is_refused(self) -> None:
|
|
doc = make().to_dict()
|
|
doc["schema_version"] = job_state.SCHEMA_VERSION + 1
|
|
# Guessing at a future layout risks destroying the user's state dir.
|
|
with pytest.raises(errors.SchemaTooNewError):
|
|
job_state.JobState.from_dict(doc)
|
|
|
|
def test_the_current_schema_is_accepted(self) -> None:
|
|
assert job_state.JobState.from_dict(make().to_dict()) is not None
|
|
|
|
|
|
class TestParamsChanged:
|
|
def _record(self, **kw: object) -> job_state.TranscriptionRecord:
|
|
base: dict[str, object] = {
|
|
"model_id": "m",
|
|
"model_revision": "r",
|
|
"backend": "openvino",
|
|
"device": "GPU",
|
|
"language": "en",
|
|
"task": "transcribe",
|
|
"num_beams": 1,
|
|
}
|
|
base.update(kw)
|
|
return job_state.TranscriptionRecord(**base) # type: ignore[arg-type]
|
|
|
|
def test_no_prior_run_counts_as_changed(self) -> None:
|
|
assert make().params_changed(self._record()) is True
|
|
|
|
def test_identical_params_are_unchanged(self) -> None:
|
|
job = make()
|
|
job.transcription = self._record()
|
|
assert job.params_changed(self._record()) is False
|
|
|
|
@pytest.mark.parametrize(
|
|
("field", "value"),
|
|
[("model_id", "other"), ("language", "de"), ("task", "translate"), ("num_beams", 5)],
|
|
)
|
|
def test_a_different_request_forces_a_rerun(self, field: str, value: object) -> None:
|
|
job = make()
|
|
job.transcription = self._record()
|
|
assert job.params_changed(self._record(**{field: value})) is True
|
|
|
|
def test_device_and_backend_do_not_force_a_rerun(self) -> None:
|
|
# Falling back from GPU to CPU must not invalidate a good transcript.
|
|
job = make()
|
|
job.transcription = self._record()
|
|
assert job.params_changed(self._record(device="CPU", backend="x")) is False
|
|
|
|
|
|
class TestDefaultsFromSparseDocuments:
|
|
def test_a_document_with_no_artifacts_key_gets_defaults(self) -> None:
|
|
doc = make().to_dict()
|
|
del doc["artifacts"]
|
|
restored = job_state.JobState.from_dict(doc)
|
|
assert restored.video.status is job_state.ArtifactStatus.MISSING
|
|
assert not restored.outputs.paths
|
|
|
|
def test_an_empty_artifact_entry_gets_defaults(self) -> None:
|
|
doc = make().to_dict()
|
|
doc["artifacts"] = {"video": None, "audio": {}, "outputs": None}
|
|
restored = job_state.JobState.from_dict(doc)
|
|
assert restored.video.status is job_state.ArtifactStatus.MISSING
|
|
assert restored.audio.status is job_state.ArtifactStatus.MISSING
|
|
assert restored.outputs.status is job_state.ArtifactStatus.MISSING
|
|
|
|
|
|
class TestModelComparison:
|
|
def _record(self, model_id: str | None) -> job_state.TranscriptionRecord:
|
|
return job_state.TranscriptionRecord(
|
|
model_id=model_id,
|
|
model_revision=None,
|
|
backend="openvino",
|
|
device="GPU",
|
|
language="en",
|
|
task="transcribe",
|
|
num_beams=1,
|
|
)
|
|
|
|
def test_an_unpinned_model_matches_whatever_ran(self) -> None:
|
|
# The request carries None; the record carries the resolved id. Comparing
|
|
# them directly would make every re-run look like a parameter change.
|
|
job = make()
|
|
job.transcription = self._record("OpenVINO/whisper-large-v3-turbo-int8-ov")
|
|
assert job.params_changed(self._record(None)) is False
|
|
|
|
def test_an_explicit_pin_that_differs_forces_a_rerun(self) -> None:
|
|
job = make()
|
|
job.transcription = self._record("model-a")
|
|
assert job.params_changed(self._record("model-b")) is True
|
|
|
|
def test_an_explicit_pin_that_matches_does_not(self) -> None:
|
|
job = make()
|
|
job.transcription = self._record("model-a")
|
|
assert job.params_changed(self._record("model-a")) is False
|