Skip to main content

subx_core/core/translation/
engine.rs

1//! High-level translation engine that orchestrates parsing, AI calls, and
2//! reapplication of translated text.
3//!
4//! See [`crate::core::translation`] for the broader design rationale.
5
6use std::collections::{BTreeMap, BTreeSet};
7use std::sync::Arc;
8
9use serde_json::json;
10
11use crate::Result;
12use crate::core::formats::Subtitle;
13use crate::core::formats::manager::FormatManager;
14use crate::core::translation::CueIdGenerator;
15use crate::core::translation::request::{
16    GlossaryEntry, TerminologyMap, TranslationOutcome, TranslationRequest, TranslationResult,
17    merge_terminology,
18};
19use crate::error::SubXError;
20use crate::services::ai::AIProvider;
21use crate::services::ai::translation_prompts::{
22    TERMINOLOGY_SYSTEM_MESSAGE, TRANSLATION_SYSTEM_MESSAGE, build_terminology_prompt,
23    build_translation_prompt, is_unknown_cue_id_error, parse_terminology_response,
24    parse_translation_response_partial,
25};
26
27/// Orchestrates subtitle translation through an [`AIProvider`].
28///
29/// The engine is deliberately thin: it is composed of an AI provider, a
30/// [`FormatManager`] for parsing/serialization, and a configurable batch
31/// size. Constructors are provided so [`crate::core::factory::ComponentFactory`]
32/// can wire the engine from runtime configuration without leaking provider
33/// details.
34pub struct TranslationEngine {
35    ai_provider: Arc<dyn AIProvider>,
36    format_manager: FormatManager,
37    batch_size: usize,
38    reporter: Arc<dyn crate::core::report::Reporter>,
39}
40
41impl std::fmt::Debug for TranslationEngine {
42    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
43        f.debug_struct("TranslationEngine")
44            .field("batch_size", &self.batch_size)
45            .finish()
46    }
47}
48
49impl TranslationEngine {
50    /// Create an engine with a custom batch size.
51    ///
52    /// # Errors
53    ///
54    /// Returns [`SubXError::config`] when `batch_size` is zero.
55    pub fn new(ai_provider: Arc<dyn AIProvider>, batch_size: usize) -> Result<Self> {
56        if batch_size == 0 {
57            return Err(SubXError::config(
58                "Translation batch size must be greater than 0",
59            ));
60        }
61        Ok(Self {
62            ai_provider,
63            format_manager: FormatManager::new(),
64            batch_size,
65            reporter: crate::core::report::noop(),
66        })
67    }
68
69    /// Attach a reporting sink, consuming and returning the engine.
70    ///
71    /// # Arguments
72    ///
73    /// * `reporter` - Sink for progress chatter (batch progress, retry
74    ///   notices); the CLI attaches its `TerminalReporter` at command
75    ///   boundaries, library consumers may pass any implementation.
76    pub fn with_reporter(mut self, reporter: Arc<dyn crate::core::report::Reporter>) -> Self {
77        self.reporter = reporter;
78        self
79    }
80
81    /// Clone the attached reporting sink for use across `await` points.
82    fn reporter(&self) -> Arc<dyn crate::core::report::Reporter> {
83        Arc::clone(&self.reporter)
84    }
85
86    /// Get the configured AI batch size.
87    pub fn batch_size(&self) -> usize {
88        self.batch_size
89    }
90
91    /// Borrow the underlying [`FormatManager`].
92    pub fn format_manager(&self) -> &FormatManager {
93        &self.format_manager
94    }
95
96    /// Translate a subtitle that has already been parsed into the shared
97    /// [`Subtitle`] data model.
98    ///
99    /// This is the primary entry point used by the translate command and
100    /// integration tests. It does not perform any filesystem I/O.
101    ///
102    /// The order of operations matches the OpenSpec design:
103    ///
104    /// 1. Generate UUIDv7 cue IDs with strict 1ms spacing.
105    /// 2. Run a terminology extraction pass (single AI request) and merge
106    ///    the result with the user glossary.
107    /// 3. Translate cues in configurable batches, validating each response
108    ///    against the requested cue IDs before applying any text changes.
109    /// 4. Reapply translated text to the parsed subtitle while preserving
110    ///    timing, ordering, cue counts, and styling metadata.
111    pub async fn translate_subtitle(
112        &self,
113        subtitle: Subtitle,
114        request: &TranslationRequest,
115    ) -> Result<TranslationResult> {
116        if request.target_language.trim().is_empty() {
117            return Err(SubXError::config(
118                "Translation target language must be provided",
119            ));
120        }
121
122        let mut subtitle = subtitle;
123        if subtitle.entries.is_empty() {
124            return Ok(TranslationResult {
125                subtitle,
126                outcome: TranslationOutcome::default(),
127            });
128        }
129
130        // 1. Cue ID assignment in subtitle order.
131        let mut id_gen = CueIdGenerator::new();
132        let cue_ids: Vec<String> = subtitle
133            .entries
134            .iter()
135            .map(|_| id_gen.next_id().to_string())
136            .collect();
137        let protected_cues: Vec<ProtectedCueText> = subtitle
138            .entries
139            .iter()
140            .enumerate()
141            .map(|(idx, entry)| protect_inline_formatting(&entry.text, idx))
142            .collect();
143        let terminology_texts: Vec<String> = protected_cues
144            .iter()
145            .map(|cue| cue.visible_text.clone())
146            .collect();
147
148        // 2. Terminology extraction + glossary merge.
149        let generated_terms = self
150            .extract_terminology(&terminology_texts, request)
151            .await?;
152        let effective_terminology = merge_terminology(generated_terms, &request.glossary_entries);
153
154        // 3. Batched translation with cue ID validation. Missing IDs are
155        //    retried once after all initial batches have completed.
156        let mut translations: BTreeMap<String, String> = BTreeMap::new();
157        let mut batch_count = 0usize;
158        for chunk_indices in chunk_ranges(subtitle.entries.len(), self.batch_size) {
159            let mut batch_cues: Vec<(String, String)> = Vec::with_capacity(chunk_indices.len());
160            let mut batch_ids: Vec<String> = Vec::with_capacity(chunk_indices.len());
161            for &i in &chunk_indices {
162                batch_cues.push((cue_ids[i].clone(), protected_cues[i].prompt_text.clone()));
163                batch_ids.push(cue_ids[i].clone());
164            }
165
166            let (map, issued_batches) = self
167                .translate_batch_with_unknown_retry(
168                    &batch_cues,
169                    &batch_ids,
170                    request,
171                    &effective_terminology,
172                )
173                .await?;
174            for (id, text) in map {
175                translations.insert(id, text);
176            }
177            batch_count += issued_batches;
178            self.reporter()
179                .progress(&crate::core::report::ProgressEvent::Message(
180                    &format_translation_progress(translations.len(), cue_ids.len()),
181                ));
182        }
183
184        let mut empty_fallback_ids = BTreeSet::new();
185        let missing_after_initial = missing_translation_indices(&cue_ids, &translations);
186        if !missing_after_initial.is_empty() {
187            let (retry_map, issued_batches) = self
188                .retry_missing_translations(
189                    &cue_ids,
190                    &protected_cues,
191                    &missing_after_initial,
192                    request,
193                    &effective_terminology,
194                )
195                .await?;
196            for (id, text) in retry_map {
197                translations.insert(id, text);
198            }
199            batch_count += issued_batches;
200
201            for idx in missing_translation_indices(&cue_ids, &translations) {
202                let id = cue_ids[idx].clone();
203                translations.insert(id.clone(), String::new());
204                empty_fallback_ids.insert(id);
205            }
206            self.reporter()
207                .progress(&crate::core::report::ProgressEvent::Message(
208                    &format_translation_progress(translations.len(), cue_ids.len()),
209                ));
210        }
211
212        // 4. Reapply translated text to the original subtitle entries while
213        //    preserving timing, ordering, and styling metadata.
214        for ((entry, id), protected) in subtitle
215            .entries
216            .iter_mut()
217            .zip(cue_ids.iter())
218            .zip(protected_cues.iter())
219        {
220            if let Some(translated) = translations.get(id) {
221                if empty_fallback_ids.contains(id) {
222                    entry.text = String::new();
223                } else {
224                    entry.text = restore_inline_formatting(translated, protected)?;
225                }
226            }
227        }
228
229        let translated_cue_count = subtitle.entries.len();
230        Ok(TranslationResult {
231            subtitle,
232            outcome: TranslationOutcome {
233                effective_terminology,
234                translated_cue_count,
235                batch_count,
236            },
237        })
238    }
239
240    /// Parse subtitle text content and translate it.
241    ///
242    /// Convenience wrapper that detects the format using
243    /// [`FormatManager::parse_auto`] before delegating to
244    /// [`Self::translate_subtitle`].
245    pub async fn translate_content(
246        &self,
247        content: &str,
248        request: &TranslationRequest,
249    ) -> Result<TranslationResult> {
250        let subtitle = self.format_manager.parse_auto(content)?;
251        self.translate_subtitle(subtitle, request).await
252    }
253
254    /// Run only the terminology extraction pass.
255    ///
256    /// Exposed so callers can preview the terminology map before issuing
257    /// translation requests.
258    pub async fn extract_terminology(
259        &self,
260        cue_texts: &[String],
261        request: &TranslationRequest,
262    ) -> Result<TerminologyMap> {
263        let prompt = build_terminology_prompt(
264            &request.target_language,
265            request.source_language.as_deref(),
266            cue_texts,
267            request.glossary_text.as_deref(),
268            request.context.as_deref(),
269        );
270        let messages = vec![
271            json!({"role": "system", "content": TERMINOLOGY_SYSTEM_MESSAGE}),
272            json!({"role": "user", "content": prompt}),
273        ];
274        let response = self.ai_provider.chat_completion(messages).await?;
275        parse_terminology_response(&response)
276    }
277
278    async fn retry_missing_translations(
279        &self,
280        cue_ids: &[String],
281        protected_cues: &[ProtectedCueText],
282        missing_indices: &[usize],
283        request: &TranslationRequest,
284        terminology: &TerminologyMap,
285    ) -> Result<(BTreeMap<String, String>, usize)> {
286        let mut retry_cues = Vec::with_capacity(missing_indices.len());
287        let mut retry_ids = Vec::with_capacity(missing_indices.len());
288        for &idx in missing_indices {
289            retry_cues.push((
290                cue_ids[idx].clone(),
291                protected_cues[idx].prompt_text.clone(),
292            ));
293            retry_ids.push(cue_ids[idx].clone());
294        }
295
296        self.translate_batch_with_unknown_retry(&retry_cues, &retry_ids, request, terminology)
297            .await
298    }
299
300    async fn translate_batch_with_unknown_retry(
301        &self,
302        batch_cues: &[(String, String)],
303        batch_ids: &[String],
304        request: &TranslationRequest,
305        terminology: &TerminologyMap,
306    ) -> Result<(BTreeMap<String, String>, usize)> {
307        match self
308            .translate_batch_once(batch_cues, batch_ids, request, terminology)
309            .await
310        {
311            Ok(map) => Ok((map, 1)),
312            Err(err) if is_unknown_cue_id_error(&err) => {
313                // Retry notice on the progress stream — the reporter
314                // decides whether it reaches the terminal.
315                self.reporter().progress(&crate::core::report::ProgressEvent::Message(
316                    "⚠ Translation response contained an unknown cue ID; discarding the batch response and retrying once.",
317                ));
318                match self
319                    .translate_batch_once(batch_cues, batch_ids, request, terminology)
320                    .await
321                {
322                    Ok(map) => Ok((map, 2)),
323                    Err(retry_err) if is_unknown_cue_id_error(&retry_err) => {
324                        Err(SubXError::ai_service(format!(
325                            "Translation response still contained an unknown cue ID after retry; failing this file: {retry_err}"
326                        )))
327                    }
328                    Err(retry_err) => Err(retry_err),
329                }
330            }
331            Err(err) => Err(err),
332        }
333    }
334
335    async fn translate_batch_once(
336        &self,
337        batch_cues: &[(String, String)],
338        batch_ids: &[String],
339        request: &TranslationRequest,
340        terminology: &TerminologyMap,
341    ) -> Result<BTreeMap<String, String>> {
342        let prompt = build_translation_prompt(
343            &request.target_language,
344            request.source_language.as_deref(),
345            terminology,
346            request.glossary_text.as_deref(),
347            request.context.as_deref(),
348            batch_cues,
349        );
350        let messages = vec![
351            json!({"role": "system", "content": TRANSLATION_SYSTEM_MESSAGE}),
352            json!({"role": "user", "content": prompt}),
353        ];
354        let response = self.ai_provider.chat_completion(messages).await?;
355        Ok(parse_translation_response_partial(&response, batch_ids)?
356            .into_iter()
357            .collect())
358    }
359}
360
361/// Parse a UTF-8 glossary text file into [`GlossaryEntry`] values.
362///
363/// The expected format is one mapping per line in the form `source = target`
364/// or `source -> target`. Empty lines and lines starting with `#` are
365/// ignored. Lines that do not contain a recognized separator are skipped
366/// silently so free-form prose context can coexist with structured entries.
367pub fn parse_glossary_text(text: &str) -> Vec<GlossaryEntry> {
368    let mut out = Vec::new();
369    for raw_line in text.lines() {
370        let line = raw_line.trim();
371        if line.is_empty() || line.starts_with('#') {
372            continue;
373        }
374        let separator = if line.contains("->") {
375            "->"
376        } else if line.contains('=') {
377            "="
378        } else {
379            continue;
380        };
381        let mut parts = line.splitn(2, separator);
382        let source = parts.next().map(str::trim).unwrap_or("").to_string();
383        let target = parts.next().map(str::trim).unwrap_or("").to_string();
384        if source.is_empty() || target.is_empty() {
385            continue;
386        }
387        out.push(GlossaryEntry { source, target });
388    }
389    out
390}
391
392fn chunk_ranges(total: usize, batch_size: usize) -> Vec<Vec<usize>> {
393    let mut chunks = Vec::new();
394    let mut start = 0;
395    while start < total {
396        let end = (start + batch_size).min(total);
397        chunks.push((start..end).collect());
398        start = end;
399    }
400    chunks
401}
402
403fn missing_translation_indices(
404    cue_ids: &[String],
405    translations: &BTreeMap<String, String>,
406) -> Vec<usize> {
407    cue_ids
408        .iter()
409        .enumerate()
410        .filter_map(|(idx, id)| (!translations.contains_key(id)).then_some(idx))
411        .collect()
412}
413
414fn format_translation_progress(processed_cues: usize, total_cues: usize) -> String {
415    format!("📊 Translation Progress:\n   Processed cues: {processed_cues}/{total_cues}")
416}
417
418#[derive(Debug, Clone)]
419struct ProtectedCueText {
420    prompt_text: String,
421    visible_text: String,
422    markers: Vec<(String, String)>,
423}
424
425fn protect_inline_formatting(text: &str, cue_index: usize) -> ProtectedCueText {
426    let mut prompt_text = String::new();
427    let mut visible_text = String::new();
428    let mut markers = Vec::new();
429    let mut offset = 0usize;
430
431    while offset < text.len() {
432        let remaining = &text[offset..];
433
434        if let Some(end_offset) = html_like_tag_end(remaining) {
435            let token = &text[offset..offset + end_offset];
436            push_format_marker(cue_index, token, &mut prompt_text, &mut markers);
437            offset += end_offset;
438            continue;
439        }
440
441        if let Some(end_offset) = ass_override_tag_end(remaining) {
442            let token = &text[offset..offset + end_offset];
443            push_format_marker(cue_index, token, &mut prompt_text, &mut markers);
444            offset += end_offset;
445            continue;
446        }
447
448        let ch = remaining
449            .chars()
450            .next()
451            .expect("offset is always inside a non-empty string slice");
452        prompt_text.push(ch);
453        visible_text.push(ch);
454        offset += ch.len_utf8();
455    }
456
457    ProtectedCueText {
458        prompt_text,
459        visible_text,
460        markers,
461    }
462}
463
464fn html_like_tag_end(text: &str) -> Option<usize> {
465    if !text.starts_with('<') {
466        return None;
467    }
468    let end = text.find('>')? + 1;
469    (end > 2).then_some(end)
470}
471
472fn ass_override_tag_end(text: &str) -> Option<usize> {
473    if !text.starts_with('{') {
474        return None;
475    }
476    let end = text.find('}')? + 1;
477    let token = &text[..end];
478    token.contains('\\').then_some(end)
479}
480
481fn push_format_marker(
482    cue_index: usize,
483    token: &str,
484    prompt_text: &mut String,
485    markers: &mut Vec<(String, String)>,
486) {
487    let placeholder = format!("__SUBX_FMT_{}_{}__", cue_index, markers.len());
488    prompt_text.push_str(&placeholder);
489    markers.push((placeholder, token.to_string()));
490}
491
492fn restore_inline_formatting(translated: &str, protected: &ProtectedCueText) -> Result<String> {
493    let mut restored = translated.to_string();
494    for (placeholder, token) in &protected.markers {
495        let count = restored.matches(placeholder).count();
496        if count != 1 {
497            return Err(SubXError::ai_service(format!(
498                "Translation response must preserve formatting placeholder {placeholder} exactly once"
499            )));
500        }
501        restored = restored.replace(placeholder, token);
502    }
503    Ok(restored)
504}
505
506#[cfg(test)]
507mod tests {
508    use super::*;
509    use async_trait::async_trait;
510    use std::sync::Mutex;
511    use std::time::Duration;
512
513    use crate::core::formats::{Subtitle, SubtitleEntry, SubtitleFormatType, SubtitleMetadata};
514    use crate::services::ai::{
515        AIProvider, AnalysisRequest, ConfidenceScore, MatchResult, VerificationRequest,
516    };
517
518    struct ScriptedAI {
519        responses: Mutex<Vec<String>>,
520    }
521
522    impl ScriptedAI {
523        fn new(responses: Vec<&str>) -> Arc<Self> {
524            Arc::new(Self {
525                responses: Mutex::new(responses.into_iter().map(|s| s.to_string()).collect()),
526            })
527        }
528    }
529
530    #[async_trait]
531    impl AIProvider for ScriptedAI {
532        async fn analyze_content(&self, _r: AnalysisRequest) -> Result<MatchResult> {
533            unreachable!()
534        }
535
536        async fn verify_match(&self, _r: VerificationRequest) -> Result<ConfidenceScore> {
537            unreachable!()
538        }
539
540        async fn chat_completion(&self, _messages: Vec<serde_json::Value>) -> Result<String> {
541            let mut responses = self.responses.lock().unwrap();
542            if responses.is_empty() {
543                return Err(SubXError::ai_service("no scripted response left"));
544            }
545            Ok(responses.remove(0))
546        }
547    }
548
549    fn sample_subtitle() -> Subtitle {
550        let metadata = SubtitleMetadata::new(SubtitleFormatType::Srt);
551        let mut sub = Subtitle::new(SubtitleFormatType::Srt, metadata);
552        sub.entries.push(SubtitleEntry::new(
553            1,
554            Duration::from_secs(1),
555            Duration::from_secs(2),
556            "Hello Alice".to_string(),
557        ));
558        sub.entries.push(SubtitleEntry::new(
559            2,
560            Duration::from_secs(3),
561            Duration::from_secs(4),
562            "Goodbye Alice".to_string(),
563        ));
564        sub
565    }
566
567    #[tokio::test]
568    async fn translation_engine_translates_in_order() {
569        let term_resp = r#"{"terms":[{"source":"Alice","target":"愛麗絲"}]}"#;
570        // Single batch with batch_size=10
571        let cues_resp = r#"{"translations":[{"id":"__ID0__","text":"哈囉 愛麗絲"},{"id":"__ID1__","text":"再見 愛麗絲"}]}"#;
572        let provider = ScriptedAI::new(vec![term_resp, cues_resp]);
573
574        // We patch responses lazily: capture cue ids after engine runs.
575        // Easier: use an interceptor that rewrites placeholder ids.
576        struct PlaceholderAI {
577            inner: Arc<ScriptedAI>,
578            captured_ids: Mutex<Vec<String>>,
579        }
580        #[async_trait]
581        impl AIProvider for PlaceholderAI {
582            async fn analyze_content(&self, _r: AnalysisRequest) -> Result<MatchResult> {
583                unreachable!()
584            }
585            async fn verify_match(&self, _r: VerificationRequest) -> Result<ConfidenceScore> {
586                unreachable!()
587            }
588            async fn chat_completion(&self, messages: Vec<serde_json::Value>) -> Result<String> {
589                // Capture cue ids from the last user prompt and patch the
590                // scripted response accordingly.
591                let prompt = messages
592                    .last()
593                    .and_then(|m| m.get("content"))
594                    .and_then(|c| c.as_str())
595                    .unwrap_or("")
596                    .to_string();
597                let mut response = self.inner.chat_completion(messages).await?;
598                if response.contains("__ID0__") {
599                    let ids: Vec<String> = prompt
600                        .lines()
601                        .filter_map(|l| l.trim().strip_prefix("- id: "))
602                        .map(|s| s.trim().to_string())
603                        .collect();
604                    let mut captured = self.captured_ids.lock().unwrap();
605                    *captured = ids.clone();
606                    for (i, id) in ids.iter().enumerate() {
607                        response = response.replace(&format!("__ID{}__", i), id);
608                    }
609                }
610                Ok(response)
611            }
612        }
613
614        let provider: Arc<dyn AIProvider> = Arc::new(PlaceholderAI {
615            inner: provider,
616            captured_ids: Mutex::new(Vec::new()),
617        });
618        let engine = TranslationEngine::new(provider, 10).unwrap();
619        let request = TranslationRequest {
620            target_language: "zh-TW".to_string(),
621            source_language: Some("en".to_string()),
622            glossary_text: None,
623            context: None,
624            glossary_entries: vec![],
625        };
626        let result = engine
627            .translate_subtitle(sample_subtitle(), &request)
628            .await
629            .unwrap();
630        assert_eq!(result.subtitle.entries.len(), 2);
631        assert_eq!(result.subtitle.entries[0].text, "哈囉 愛麗絲");
632        assert_eq!(result.subtitle.entries[1].text, "再見 愛麗絲");
633        assert_eq!(result.outcome.translated_cue_count, 2);
634        assert_eq!(result.outcome.batch_count, 1);
635        assert_eq!(
636            result.outcome.effective_terminology.get("Alice").unwrap(),
637            "愛麗絲"
638        );
639        // Timing preserved.
640        assert_eq!(
641            result.subtitle.entries[0].start_time,
642            Duration::from_secs(1)
643        );
644        assert_eq!(result.subtitle.entries[1].end_time, Duration::from_secs(4));
645    }
646
647    #[tokio::test]
648    async fn empty_subtitle_returns_empty_outcome() {
649        let provider: Arc<dyn AIProvider> = ScriptedAI::new(vec![]);
650        let engine = TranslationEngine::new(provider, 5).unwrap();
651        let metadata = SubtitleMetadata::new(SubtitleFormatType::Srt);
652        let sub = Subtitle::new(SubtitleFormatType::Srt, metadata);
653        let request = TranslationRequest {
654            target_language: "zh-TW".to_string(),
655            source_language: None,
656            glossary_text: None,
657            context: None,
658            glossary_entries: vec![],
659        };
660        let result = engine.translate_subtitle(sub, &request).await.unwrap();
661        assert_eq!(result.outcome.translated_cue_count, 0);
662        assert_eq!(result.outcome.batch_count, 0);
663    }
664
665    #[test]
666    fn batch_size_zero_is_rejected() {
667        let provider: Arc<dyn AIProvider> = ScriptedAI::new(vec![]);
668        let err = TranslationEngine::new(provider, 0).unwrap_err();
669        assert!(err.to_string().contains("batch size"));
670    }
671
672    #[test]
673    fn parse_glossary_text_handles_multiple_separators() {
674        let text = "# comment\nAlice = 艾莉絲\nBob -> 鮑伯\n\n";
675        let entries = parse_glossary_text(text);
676        assert_eq!(entries.len(), 2);
677        assert_eq!(entries[0].source, "Alice");
678        assert_eq!(entries[0].target, "艾莉絲");
679        assert_eq!(entries[1].source, "Bob");
680        assert_eq!(entries[1].target, "鮑伯");
681    }
682
683    #[test]
684    fn protect_and_restore_inline_formatting_tokens() {
685        let protected = protect_inline_formatting(r#"<i>{\b1}Hello{\b0}</i>"#, 3);
686        assert_eq!(
687            protected.prompt_text,
688            "__SUBX_FMT_3_0____SUBX_FMT_3_1__Hello__SUBX_FMT_3_2____SUBX_FMT_3_3__"
689        );
690        assert_eq!(protected.visible_text, "Hello");
691
692        let translated = "__SUBX_FMT_3_0____SUBX_FMT_3_1__你好__SUBX_FMT_3_2____SUBX_FMT_3_3__";
693        let restored = restore_inline_formatting(translated, &protected).unwrap();
694        assert_eq!(restored, r#"<i>{\b1}你好{\b0}</i>"#);
695    }
696
697    #[test]
698    fn translation_progress_message_includes_processed_and_total_cues() {
699        assert_eq!(
700            format_translation_progress(42, 100),
701            "📊 Translation Progress:\n   Processed cues: 42/100"
702        );
703    }
704}