diff --git a/crates/arr-subs/src/lib.rs b/crates/arr-subs/src/lib.rs index f4a7131..1b6ade9 100644 --- a/crates/arr-subs/src/lib.rs +++ b/crates/arr-subs/src/lib.rs @@ -13,18 +13,24 @@ //! Translation backends each sit behind their own cargo feature — //! `translate-openai`, `translate-deepl`, `translate-google`, //! `translate-command`. The features are declared; the backends themselves are -//! later issues. Which compiled-in backend runs is a database setting, so -//! switching engines never needs a rebuild. +//! later issues (#191, #192, #193). What they share — the [`translate::Backend`] +//! trait, cue chunking, reply validation — lives in [`translate`], and the SRT +//! cues it works in live in [`srt`]. Which compiled-in backend runs is a +//! database setting, so switching engines never needs a rebuild. use std::{fmt, future::Future, pin::Pin}; pub mod error; pub mod model; +pub mod srt; +pub mod translate; pub use error::{Error, Result}; pub use model::{ Candidate, CandidateId, Fetched, MediaFile, MediaRef, ProviderId, SearchRequest, SubtitleFormat, }; +pub use srt::Cue; +pub use translate::{Backend, BackendId, Batch, BatchCue, TranslateFuture, TranslatedCue}; /// The candidates one search turned up. pub type SearchFuture<'a> = Pin>> + Send + 'a>>; diff --git a/crates/arr-subs/src/srt.rs b/crates/arr-subs/src/srt.rs new file mode 100644 index 0000000..63c3fc8 --- /dev/null +++ b/crates/arr-subs/src/srt.rs @@ -0,0 +1,275 @@ +//! SRT cues: the unit translation works in. +//! +//! A cue is a timing span and the text shown during it. Translation sends the +//! text out and reassembles the reply onto the original timings (DESIGN.md +//! §15), so the timings have to survive the round trip untouched — which is +//! why they are parsed into [`Cue`]s here rather than treated as opaque lines. +//! +//! Parsing is tolerant of what real files contain — CRLF, a BOM, missing +//! index lines, `.` instead of `,` before the milliseconds — and rendering is +//! strict: indices renumbered from 1, `,` milliseconds, LF line endings. Text +//! is `&str`, not bytes: decoding a provider's Latin-1 file is the caller's +//! problem, stated where the bytes arrive ([`Fetched::content`]). +//! +//! [`Fetched::content`]: crate::model::Fetched + +use std::{fmt, time::Duration}; + +/// One subtitle cue: when it shows, and what it says. +/// +/// No index field. SRT indices carry no information — renderers ignore them — +/// and keeping them would invite gaps and duplicates from sloppy files into +/// every consumer. Position in the `Vec` is the identity, and +/// [`render`] renumbers from 1. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Cue { + /// When the cue appears. + pub start: Duration, + /// When it disappears. + pub end: Duration, + /// The text shown, lines joined with `\n`. This is the only part of a cue + /// that ever leaves arr (DESIGN.md §15). + pub text: String, +} + +/// Why a file failed to parse as SRT. +#[derive(Debug, thiserror::Error)] +#[error("line {line}: {detail}")] +pub struct ParseError { + /// The 1-based line the problem was found on. + pub line: usize, + /// What was wrong with it. + pub detail: String, +} + +/// Parse SRT text into cues. +/// +/// Accepts CRLF and LF, a leading BOM, blocks with or without an index line, +/// and `.` as the millisecond separator. Cues come back in file order, +/// original indices discarded. +/// +/// # Errors +/// +/// [`ParseError`] when a block has no timing line or a timestamp does not +/// parse. A file that is not SRT at all fails on its first block rather than +/// producing an empty translation source. +pub fn parse(input: &str) -> Result, ParseError> { + let input = input.strip_prefix('\u{feff}').unwrap_or(input); + let mut cues = Vec::new(); + + let mut lines = input.lines().enumerate().peekable(); + while let Some((index, line)) = lines.next() { + if line.trim().is_empty() { + continue; + } + + // An index line is digits alone; the timing line follows. A block may + // omit the index, so a line that already carries `-->` is the timing. + let (timing_index, timing) = if line.contains("-->") { + (index, line) + } else if line.trim().chars().all(|c| c.is_ascii_digit()) { + match lines.next() { + Some((next_index, next)) if next.contains("-->") => (next_index, next), + Some((next_index, _)) => { + return Err(ParseError { + line: next_index + 1, + detail: "expected a timing line after the cue index".to_owned(), + }) + } + None => { + return Err(ParseError { + line: index + 1, + detail: "file ends after a cue index".to_owned(), + }) + } + } + } else { + return Err(ParseError { + line: index + 1, + detail: format!("expected a cue index or timing line, got {line:?}"), + }); + }; + + let (start, end) = parse_timing(timing, timing_index + 1)?; + + let mut text_lines = Vec::new(); + while let Some((_, text)) = lines.peek() { + if text.trim().is_empty() { + break; + } + text_lines.push(*text); + lines.next(); + } + + cues.push(Cue { + start, + end, + text: text_lines.join("\n"), + }); + } + + Ok(cues) +} + +/// Render cues back to SRT, indices renumbered from 1. +#[must_use] +pub fn render(cues: &[Cue]) -> String { + use fmt::Write as _; + + let mut out = String::new(); + for (position, cue) in cues.iter().enumerate() { + if position > 0 { + let _ = writeln!(out); + } + let _ = writeln!(out, "{}", position + 1); + let _ = writeln!(out, "{} --> {}", Timestamp(cue.start), Timestamp(cue.end)); + let _ = writeln!(out, "{}", cue.text); + } + out +} + +/// A `Duration` in SRT's `HH:MM:SS,mmm` spelling. +struct Timestamp(Duration); + +impl fmt::Display for Timestamp { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + let millis = self.0.as_millis(); + let (seconds, millis) = (millis / 1_000, millis % 1_000); + let (minutes, seconds) = (seconds / 60, seconds % 60); + let (hours, minutes) = (minutes / 60, minutes % 60); + write!( + formatter, + "{hours:02}:{minutes:02}:{seconds:02},{millis:03}" + ) + } +} + +/// Split `HH:MM:SS,mmm --> HH:MM:SS,mmm` into its two instants. +/// +/// Anything after the second timestamp — some files append position hints — +/// is ignored. +fn parse_timing(line: &str, line_number: usize) -> Result<(Duration, Duration), ParseError> { + let error = |detail: String| ParseError { + line: line_number, + detail, + }; + + let (start, rest) = line + .split_once("-->") + .ok_or_else(|| error("expected a timing line".to_owned()))?; + let end = rest.split_whitespace().next().unwrap_or(""); + + Ok(( + parse_timestamp(start.trim()).map_err(error)?, + parse_timestamp(end).map_err(error)?, + )) +} + +/// Parse one `HH:MM:SS,mmm` timestamp; `.` accepted for the comma. +fn parse_timestamp(text: &str) -> Result { + let bad = || format!("bad timestamp {text:?}"); + + let mut clock = text.splitn(3, ':'); + let (Some(hours), Some(minutes), Some(rest)) = (clock.next(), clock.next(), clock.next()) + else { + return Err(bad()); + }; + let (seconds, millis) = rest.split_once([',', '.']).ok_or_else(bad)?; + + let field = |s: &str| s.trim().parse::().map_err(|_| bad()); + let (hours, minutes, seconds, millis) = ( + field(hours)?, + field(minutes)?, + field(seconds)?, + field(millis)?, + ); + if minutes >= 60 || seconds >= 60 || millis >= 1_000 { + return Err(bad()); + } + + Ok(Duration::from_millis( + ((hours * 60 + minutes) * 60 + seconds) * 1_000 + millis, + )) +} + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use super::{parse, render, Cue}; + + const FILE: &str = "1\n00:00:01,000 --> 00:00:02,500\nol\u{e1}\n\n2\n00:00:03,000 --> 00:00:04,000\ntwo lines\nof text\n"; + + #[test] + fn a_plain_file_parses_into_cues() { + let cues = parse(FILE).expect("well-formed SRT"); + assert_eq!(cues.len(), 2); + assert_eq!(cues[0].start, Duration::from_millis(1_000)); + assert_eq!(cues[0].end, Duration::from_millis(2_500)); + assert_eq!(cues[0].text, "ol\u{e1}"); + assert_eq!(cues[1].text, "two lines\nof text"); + } + + #[test] + fn crlf_bom_and_dot_milliseconds_are_tolerated() { + let file = "\u{feff}1\r\n00:00:01.000 --> 00:00:02.000\r\nhello\r\n\r\n"; + let cues = parse(file).expect("real-world SRT"); + assert_eq!(cues.len(), 1); + assert_eq!(cues[0].text, "hello"); + } + + #[test] + fn a_block_without_an_index_line_still_parses() { + let file = "00:00:01,000 --> 00:00:02,000\nno index\n"; + let cues = parse(file).expect("index lines are optional"); + assert_eq!(cues.len(), 1); + assert_eq!(cues[0].text, "no index"); + } + + #[test] + fn original_indices_are_discarded_and_render_renumbers() { + let file = + "7\n00:00:01,000 --> 00:00:02,000\nfirst\n\n9\n00:00:03,000 --> 00:00:04,000\nsecond\n"; + let cues = parse(file).expect("gappy indices parse"); + let rendered = render(&cues); + assert!(rendered.starts_with("1\n00:00:01,000 --> 00:00:02,000\nfirst\n")); + assert!(rendered.contains("\n2\n00:00:03,000 --> 00:00:04,000\nsecond\n")); + } + + #[test] + fn parse_and_render_round_trip() { + let cues = parse(FILE).expect("well-formed SRT"); + assert_eq!(render(&cues), FILE); + } + + #[test] + fn hours_render_and_survive_the_round_trip() { + let cue = Cue { + start: Duration::from_millis(3_600_000 + 61_001), + end: Duration::from_millis(3_600_000 + 62_002), + text: "late".to_owned(), + }; + let rendered = render(std::slice::from_ref(&cue)); + assert!(rendered.contains("01:01:01,001 --> 01:01:02,002")); + assert_eq!(parse(&rendered).expect("own output parses"), vec![cue]); + } + + #[test] + fn a_broken_timestamp_names_its_line() { + let file = "1\n00:00:01,000 --> nonsense\ntext\n"; + let error = parse(file).expect_err("timestamp is nonsense"); + assert_eq!(error.line, 2); + } + + #[test] + fn a_file_that_is_not_srt_fails_rather_than_parsing_empty() { + assert!(parse("WEBVTT\n\n00:01.000 --> 00:02.000\nhi\n").is_err()); + assert!(parse("{1}{50}not srt at all").is_err()); + } + + #[test] + fn an_empty_file_is_an_empty_cue_list() { + assert_eq!(parse("").expect("nothing to object to"), vec![]); + assert_eq!(parse("\n\n").expect("blank lines only"), vec![]); + } +} diff --git a/crates/arr-subs/src/translate.rs b/crates/arr-subs/src/translate.rs new file mode 100644 index 0000000..2fa8345 --- /dev/null +++ b/crates/arr-subs/src/translate.rs @@ -0,0 +1,567 @@ +//! Translation: the layer between a subtitle and whichever backend renders it +//! into the wanted language. +//! +//! DESIGN.md §15. The backends — OpenAI-compatible, `DeepL`, Google Translate, +//! a remote command — are separate issues (#191, #192, #193), each behind its +//! own cargo feature. What lives here is everything they share, so that no +//! backend can get it wrong on its own: +//! +//! * **Chunking.** The unit sent out is a batch of cues, not a whole file and +//! not one cue. A movie is 40–80k characters and a season several hundred +//! thousand; one request per file is fragile, one per cue loses the context +//! that makes dialogue translate well. +//! * **Validation.** A reply whose cue count or numbering does not match the +//! batch is rejected. A backend that silently merges or drops lines must +//! fail loudly, not produce a subtitle whose lines have drifted out of sync +//! with the picture. +//! * **Reassembly.** Timing data never leaves arr. Only text goes out, and +//! the translated text comes back onto the original timings. +//! +//! When to translate is the reconcile loop's decision (#196); which backend +//! runs is a database setting (#198). This module only knows how. + +use std::{fmt, future::Future, pin::Pin, time::Duration}; + +use arr_core::Language; + +use crate::srt::Cue; + +/// How many characters of cue text one batch carries at most. +/// +/// The balance chunking strikes: batches small enough that one failure +/// re-does little and a length-limited backend is never overflowed, large +/// enough that a scene's dialogue travels together and a movie is tens of +/// requests, not thousands. Backends with tighter limits override +/// [`Backend::batch_budget`]. +pub const BATCH_CHAR_BUDGET: usize = 4_096; + +/// The name a translation backend answers to in settings and the UI. +/// +/// A string rather than an enum for the same reason as +/// [`ProviderId`](crate::ProviderId): backends arrive one issue at a time, +/// each behind its own cargo feature, and which are compiled in is a +/// deployment fact. +#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub struct BackendId(String); + +impl BackendId { + /// Name a backend. + #[must_use] + pub fn new(name: impl Into) -> Self { + Self(name.into()) + } + + /// The name as written. + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl fmt::Display for BackendId { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(&self.0) + } +} + +/// One numbered line of text on its way out. Number and text, nothing else: +/// timings stay home (DESIGN.md §15). +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct BatchCue { + /// The cue's 1-based position in the whole subtitle, continuous across + /// batches. The reply must quote it back unchanged. + pub number: usize, + /// The text to translate. + pub text: String, +} + +/// One batch of cues put to one backend. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Batch { + /// The language the cues are in. + /// + /// Explicit rather than auto-detected: the source is a subtitle arr + /// already holds, so its language is a recorded fact, and a backend + /// guessing it wrong is a silent way to mistranslate. + pub source: Language, + /// The language wanted back. pt-PT and pt-BR are distinct targets; a + /// backend that cannot express the difference refuses in + /// [`Backend::supports`] rather than quietly returning the wrong one. + pub target: Language, + /// The cues, in subtitle order. + pub cues: Vec, +} + +/// One translated cue in a backend's reply. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TranslatedCue { + /// The number of the [`BatchCue`] this translates. + pub number: usize, + /// The translated text. + pub text: String, +} + +/// The translated batch, in reply order. +pub type TranslateFuture<'a> = + Pin>> + Send + 'a>>; + +/// One translation backend (DESIGN.md §15). +/// +/// Boxed futures rather than `async fn` for the same reason as +/// [`Provider`](crate::Provider): which backend runs is a database setting, +/// so callers hold a `dyn Backend` chosen at runtime. +/// +/// A backend translates one batch and reports what happened. It does not +/// chunk, does not validate its own replies — [`translate`] does both, so a +/// backend cannot skip them — and never sees a timestamp. +pub trait Backend: fmt::Debug + Send + Sync { + /// The name this backend answers to in settings and the UI. + fn id(&self) -> BackendId; + + /// Whether this backend can translate into `target` — exactly, variants + /// respected. A backend that only knows generic Portuguese answers + /// `false` for pt-PT rather than returning pt-BR under its name. + fn supports(&self, target: &Language) -> bool; + + /// The most characters of cue text one batch to this backend may carry. + fn batch_budget(&self) -> usize { + BATCH_CHAR_BUDGET + } + + /// Translate one batch. + /// + /// The reply is expected to carry every cue number the batch carried, + /// in order; [`translate`] rejects anything else. A backend that cannot + /// tell which of its output lines answers which input — a truncated or + /// refused reply, say — returns an error rather than guessing. + /// + /// # Errors + /// + /// Anything in [`Error`]. + fn translate<'a>(&'a self, batch: &'a Batch) -> TranslateFuture<'a>; +} + +/// Result alias for translation. +pub type Result = std::result::Result; + +/// Everything that can go wrong translating a subtitle. +/// +/// Kept apart from the provider [`Error`](crate::Error): the two report to +/// different settings rows and different budget buckets, and a variant like +/// [`Error::CueMismatch`] has no provider counterpart. +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum Error { + /// The backend could not be reached at all, or the connection died. + #[error("{backend} is unreachable")] + Transport { + /// The backend that was called. + backend: BackendId, + /// The underlying transport failure. + #[source] + source: Box, + }, + + /// The backend refused the credentials it was given. + #[error("{backend} rejected the configured credentials")] + Unauthorized { + /// The backend that was called. + backend: BackendId, + }, + + /// The backend's rate limit or the daily budget was hit. A queue state, + /// not a failure (DESIGN.md §15). + #[error("{backend} is at its cap")] + RateLimited { + /// The backend that was called. + backend: BackendId, + /// How long the backend asked to be left alone, when it says. + retry_after: Option, + }, + + /// The backend cannot translate into the requested language variant. + /// + /// Raised by [`translate`] before anything is sent, off + /// [`Backend::supports`]. Configuration, not weather: it never resolves + /// by retrying. + #[error("{backend} cannot translate into {language}")] + UnsupportedTarget { + /// The backend that was asked. + backend: BackendId, + /// The language it cannot express. + language: Language, + }, + + /// The backend answered, but not in the shape a reply must have. + #[error("{backend} answered with something unexpected: {detail}")] + Malformed { + /// The backend that was called. + backend: BackendId, + /// What was wrong, short enough to log. + detail: String, + }, + + /// The reply's cue count or numbering does not match the batch. + /// + /// The rejection DESIGN.md §15 demands: a backend that merged, dropped or + /// invented lines fails the whole translation loudly, and no partially + /// translated subtitle exists to be written. + #[error("{backend} returned a mismatched batch: {detail}")] + CueMismatch { + /// The backend that was called. + backend: BackendId, + /// Which cue diverged, and how. + detail: String, + }, +} + +impl Error { + /// Whether calling the same backend again later is worth doing. + /// + /// A cap lifts and a network recovers. Bad credentials, an unsupported + /// target and a backend that mangles batches do not fix themselves, and + /// retrying them only burns budget. + #[must_use] + pub const fn is_transient(&self) -> bool { + matches!(self, Self::Transport { .. } | Self::RateLimited { .. }) + } +} + +/// Translate a whole subtitle through one backend. +/// +/// Chunks the cues into batches within [`Backend::batch_budget`], sends them +/// in order, validates every reply against its batch, and reassembles the +/// translated text onto the original timings. All or nothing: any failed +/// batch fails the whole call, so a partially translated subtitle never +/// exists (DESIGN.md §15). +/// +/// # Errors +/// +/// [`Error::UnsupportedTarget`] before anything is sent when the backend +/// cannot express `target`; [`Error::CueMismatch`] when a reply does not +/// match its batch; otherwise whatever the backend reported. +pub async fn translate( + backend: &dyn Backend, + cues: &[Cue], + source: &Language, + target: &Language, +) -> Result> { + if !backend.supports(target) { + return Err(Error::UnsupportedTarget { + backend: backend.id(), + language: target.clone(), + }); + } + + let mut texts = Vec::with_capacity(cues.len()); + for batch in batches(cues, source, target, backend.batch_budget()) { + let reply = backend.translate(&batch).await?; + validate(&batch, &reply, &backend.id())?; + texts.extend(reply.into_iter().map(|cue| cue.text)); + } + + Ok(cues + .iter() + .zip(texts) + .map(|(cue, text)| Cue { + start: cue.start, + end: cue.end, + text, + }) + .collect()) +} + +/// Chunk cues into batches of at most `budget` characters of text. +/// +/// Order preserved, numbering 1-based and continuous across batches. A single +/// cue longer than the budget still travels, alone in its own batch — a +/// too-long line is the backend's problem to refuse, not a reason to drop it. +fn batches(cues: &[Cue], source: &Language, target: &Language, budget: usize) -> Vec { + let mut out: Vec = Vec::new(); + let mut current: Vec = Vec::new(); + let mut spent = 0; + + for (position, cue) in cues.iter().enumerate() { + let cost = cue.text.chars().count(); + if !current.is_empty() && spent + cost > budget { + out.push(Batch { + source: source.clone(), + target: target.clone(), + cues: std::mem::take(&mut current), + }); + spent = 0; + } + current.push(BatchCue { + number: position + 1, + text: cue.text.clone(), + }); + spent += cost; + } + + if !current.is_empty() { + out.push(Batch { + source: source.clone(), + target: target.clone(), + cues: current, + }); + } + + out +} + +/// Reject a reply whose count or numbering does not match its batch. +fn validate(batch: &Batch, reply: &[TranslatedCue], backend: &BackendId) -> Result<()> { + let mismatch = |detail: String| { + Err(Error::CueMismatch { + backend: backend.clone(), + detail, + }) + }; + + if reply.len() != batch.cues.len() { + return mismatch(format!( + "sent {} cues, got {} back", + batch.cues.len(), + reply.len() + )); + } + + for (sent, got) in batch.cues.iter().zip(reply) { + if sent.number != got.number { + return mismatch(format!( + "expected cue {}, reply says {}", + sent.number, got.number + )); + } + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use std::{ + sync::atomic::{AtomicUsize, Ordering}, + time::Duration, + }; + + use arr_core::Language; + + use super::{translate, Backend, BackendId, Batch, Error, TranslateFuture, TranslatedCue}; + use crate::srt::Cue; + + /// Uppercases every cue and records how it was called; misbehaves on cue. + #[derive(Debug, Default)] + struct StubBackend { + budget: Option, + calls: AtomicUsize, + drop_last_cue: bool, + renumber_from: Option, + } + + impl Backend for StubBackend { + fn id(&self) -> BackendId { + BackendId::new("stub") + } + + fn supports(&self, target: &Language) -> bool { + *target != Language::PortugueseBrazil + } + + fn batch_budget(&self) -> usize { + self.budget.unwrap_or(super::BATCH_CHAR_BUDGET) + } + + fn translate<'a>(&'a self, batch: &'a Batch) -> TranslateFuture<'a> { + self.calls.fetch_add(1, Ordering::SeqCst); + Box::pin(async move { + let mut reply: Vec = batch + .cues + .iter() + .map(|cue| TranslatedCue { + number: self.renumber_from.unwrap_or(cue.number), + text: cue.text.to_uppercase(), + }) + .collect(); + if self.drop_last_cue { + reply.pop(); + } + Ok(reply) + }) + } + } + + fn cues(texts: &[&str]) -> Vec { + texts + .iter() + .enumerate() + .map(|(index, text)| Cue { + start: Duration::from_secs(index as u64), + end: Duration::from_secs(index as u64 + 1), + text: (*text).to_owned(), + }) + .collect() + } + + #[tokio::test] + async fn text_is_translated_and_timings_never_move() { + let backend = StubBackend::default(); + let source = cues(&["one", "two"]); + + let translated = translate( + &backend, + &source, + &Language::Other("en".to_owned()), + &Language::PortuguesePortugal, + ) + .await + .expect("the stub translates"); + + assert_eq!(translated.len(), 2); + assert_eq!(translated[0].text, "ONE"); + assert_eq!(translated[1].text, "TWO"); + assert_eq!(translated[0].start, source[0].start); + assert_eq!(translated[1].end, source[1].end); + } + + #[tokio::test] + async fn the_budget_splits_cues_into_more_than_one_batch() { + let backend = StubBackend { + budget: Some(6), + ..StubBackend::default() + }; + // 4 + 4 chars overflow a budget of 6, so two batches. + let source = cues(&["aaaa", "bbbb", "cc"]); + + let translated = translate( + &backend, + &source, + &Language::Other("en".to_owned()), + &Language::PortuguesePortugal, + ) + .await + .expect("both batches answer"); + + assert_eq!(backend.calls.load(Ordering::SeqCst), 2); + assert_eq!(translated[2].text, "CC"); + } + + #[tokio::test] + async fn an_unsupported_target_is_refused_before_anything_is_sent() { + let backend = StubBackend::default(); + + let error = translate( + &backend, + &cues(&["hi"]), + &Language::Other("en".to_owned()), + &Language::PortugueseBrazil, + ) + .await + .expect_err("the stub cannot say pt-BR"); + + assert!(matches!(error, Error::UnsupportedTarget { .. })); + assert_eq!(backend.calls.load(Ordering::SeqCst), 0); + assert!(!error.is_transient()); + } + + #[tokio::test] + async fn a_dropped_cue_fails_the_whole_translation() { + let backend = StubBackend { + drop_last_cue: true, + ..StubBackend::default() + }; + + let error = translate( + &backend, + &cues(&["one", "two"]), + &Language::Other("en".to_owned()), + &Language::PortuguesePortugal, + ) + .await + .expect_err("a short reply must be rejected"); + + assert!(matches!(error, Error::CueMismatch { .. })); + } + + #[tokio::test] + async fn renumbered_cues_fail_the_whole_translation() { + let backend = StubBackend { + renumber_from: Some(99), + ..StubBackend::default() + }; + + let error = translate( + &backend, + &cues(&["one"]), + &Language::Other("en".to_owned()), + &Language::PortuguesePortugal, + ) + .await + .expect_err("wrong numbering must be rejected"); + + assert!(matches!(error, Error::CueMismatch { .. })); + } + + #[tokio::test] + async fn an_empty_subtitle_translates_to_nothing_without_a_call() { + let backend = StubBackend::default(); + + let translated = translate( + &backend, + &[], + &Language::Other("en".to_owned()), + &Language::PortuguesePortugal, + ) + .await + .expect("nothing to send"); + + assert!(translated.is_empty()); + assert_eq!(backend.calls.load(Ordering::SeqCst), 0); + } + + #[test] + fn numbering_is_continuous_across_batches() { + let source = cues(&["aaaa", "bbbb", "cc"]); + let batches = super::batches( + &source, + &Language::Other("en".to_owned()), + &Language::PortuguesePortugal, + 6, + ); + + assert_eq!(batches.len(), 2); + assert_eq!(batches[0].cues[0].number, 1); + assert_eq!(batches[1].cues[0].number, 2); + assert_eq!(batches[1].cues[1].number, 3); + } + + #[test] + fn a_cue_over_the_budget_travels_alone_rather_than_being_dropped() { + let source = cues(&["this line is far over budget", "b"]); + let batches = super::batches( + &source, + &Language::Other("en".to_owned()), + &Language::PortuguesePortugal, + 4, + ); + + assert_eq!(batches.len(), 2); + assert_eq!(batches[0].cues.len(), 1); + assert_eq!(batches[1].cues.len(), 1); + } + + #[test] + fn a_cap_is_worth_retrying_and_a_mangled_batch_is_not() { + let backend = BackendId::new("stub"); + assert!(Error::RateLimited { + backend: backend.clone(), + retry_after: None, + } + .is_transient()); + assert!(!Error::CueMismatch { + backend, + detail: "sent 2 cues, got 1 back".to_owned(), + } + .is_transient()); + } +}