Merge #190: pluggable subtitle translation

Closes #190
This commit is contained in:
Miguel Palhas
2026-08-24 22:36:13 +01:00
3 changed files with 850 additions and 2 deletions
+8 -2
View File
@@ -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<Box<dyn Future<Output = Result<Vec<Candidate>>> + Send + 'a>>;
+275
View File
@@ -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<Vec<Cue>, 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<Duration, String> {
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::<u64>().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![]);
}
}
+567
View File
@@ -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<String>) -> 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<BatchCue>,
}
/// 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<Box<dyn Future<Output = Result<Vec<TranslatedCue>>> + 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<T> = std::result::Result<T, Error>;
/// 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<dyn std::error::Error + Send + Sync>,
},
/// 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<Duration>,
},
/// 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<Vec<Cue>> {
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<Batch> {
let mut out: Vec<Batch> = Vec::new();
let mut current: Vec<BatchCue> = 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<usize>,
calls: AtomicUsize,
drop_last_cue: bool,
renumber_from: Option<usize>,
}
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<TranslatedCue> = 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<Cue> {
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());
}
}