use super::*;
use crate::audio::whisper::result::{
TranscriptionResult, TranscriptionSegment, TranscriptionTimings, WordTiming,
};
fn result_with_segment(words: Vec<WordTiming>) -> TranscriptionResult {
let mut segment = TranscriptionSegment::new();
segment
.set_start(0.0)
.set_end(2.0)
.set_text(" Hello world")
.set_words(words);
let mut result = TranscriptionResult::new("", Vec::new(), "", TranscriptionTimings::new());
result.set_segments(vec![segment]).set_text(" Hello world");
result
}
#[test]
fn format_time_matches_swift_markers_and_truncation() {
assert_eq!(format_time(0.0, true, ','), "00:00:00,000");
assert_eq!(format_time(2.5, true, ','), "00:00:02,500");
assert_eq!(format_time(2.5, false, '.'), "00:02.500");
assert_eq!(format_time(3661.25, false, '.'), "01:01:01.250"); assert_eq!(format_time(1.9995, true, ','), "00:00:01,999");
}
#[test]
fn srt_uses_segment_blocks_without_words() {
let srt = srt_content(&result_with_segment(vec![]));
assert_eq!(srt, "1\n00:00:00,000 --> 00:00:02,000\n Hello world\n\n");
}
#[test]
fn srt_emits_one_block_per_word_and_increments_indices() {
let words = vec![
WordTiming::new(" Hello", vec![1], 0.0, 0.5, 0.9),
WordTiming::new(" world", vec![2], 0.5, 1.0, 0.9),
];
let srt = srt_content(&result_with_segment(words));
assert_eq!(
srt,
"1\n00:00:00,000 --> 00:00:00,500\n Hello\n\n\
2\n00:00:00,500 --> 00:00:01,000\n world\n\n"
);
}
#[test]
fn vtt_has_header_and_dot_markers_without_indices() {
let vtt = vtt_content(&result_with_segment(vec![]));
assert_eq!(vtt, "WEBVTT\n\n00:00.000 --> 00:02.000\n Hello world\n\n");
}
#[test]
fn writers_emit_files_with_the_right_extension() {
let dir = tempfile::tempdir().unwrap();
let result = result_with_segment(vec![]);
let path = SrtWriter::new(dir.path()).write(&result, "out").unwrap();
assert!(path.ends_with("out.srt"));
assert_eq!(
std::fs::read_to_string(&path).unwrap(),
srt_content(&result)
);
let path = VttWriter::new(dir.path()).write(&result, "out").unwrap();
assert!(path.ends_with("out.vtt"));
let err = SrtWriter::new("/nonexistent/dir")
.write(&result, "out")
.unwrap_err();
assert!(matches!(err, WriteError::Write(_)));
}
#[cfg(feature = "serde")]
#[test]
fn json_round_trips_through_serde() {
let dir = tempfile::tempdir().unwrap();
let result = result_with_segment(vec![WordTiming::new(" Hello", vec![1], 0.0, 0.5, 0.9)]);
let path = JsonWriter::new(dir.path()).write(&result, "out").unwrap();
assert!(path.ends_with("out.json"));
let parsed: TranscriptionResult =
serde_json::from_str(&std::fs::read_to_string(path).unwrap()).unwrap();
assert_eq!(parsed, result);
}
#[test]
fn writers_replace_existing_files_without_leaving_staging_artifacts() {
let dir = tempfile::tempdir().unwrap();
let writer = SrtWriter::new(dir.path());
let first = result_with_segment(vec![]);
let path = writer.write(&first, "again").unwrap();
let before = std::fs::read_to_string(&path).unwrap();
writer.write(&first, "again").unwrap();
assert_eq!(std::fs::read_to_string(&path).unwrap(), before);
let leftovers: Vec<_> = std::fs::read_dir(dir.path())
.unwrap()
.filter_map(Result::ok)
.filter(|e| e.path().extension().is_some_and(|x| x == "tmp"))
.collect();
assert!(leftovers.is_empty(), "staging file leaked: {leftovers:?}");
}
#[test]
fn staging_never_clobbers_existing_files_and_concurrent_writes_stay_whole() {
let dir = tempfile::tempdir().unwrap();
let writer = SrtWriter::new(dir.path());
let planted = dir
.path()
.join(format!("whole.srt.{}.0.tmp", std::process::id()));
std::fs::write(&planted, "sentinel").unwrap();
writer.write(&result_with_segment(vec![]), "whole").unwrap();
assert_eq!(
std::fs::read_to_string(&planted).unwrap(),
"sentinel",
"pre-existing staging-shaped file clobbered"
);
std::fs::remove_file(&planted).unwrap();
let a = {
let mut segment = TranscriptionSegment::new();
segment
.set_start(0.0)
.set_end(1.0)
.set_text("AAAA".repeat(256));
TranscriptionResult::new("", vec![segment], "en", TranscriptionTimings::new())
};
let b = {
let mut segment = TranscriptionSegment::new();
segment
.set_start(0.0)
.set_end(1.0)
.set_text("BBBB".repeat(256));
TranscriptionResult::new("", vec![segment], "en", TranscriptionTimings::new())
};
let full_a = srt_content(&a);
let full_b = srt_content(&b);
std::thread::scope(|scope| {
let writer_a = SrtWriter::new(dir.path());
let writer_b = SrtWriter::new(dir.path());
let ta = scope.spawn(move || {
for _ in 0..50 {
writer_a.write(&a, "contended").unwrap();
}
});
let tb = scope.spawn(move || {
for _ in 0..50 {
writer_b.write(&b, "contended").unwrap();
}
});
ta.join().unwrap();
tb.join().unwrap();
});
let observed = std::fs::read_to_string(dir.path().join("contended.srt")).unwrap();
assert!(
observed == full_a || observed == full_b,
"destination holds a mixture: len {}",
observed.len()
);
let leftovers: Vec<_> = std::fs::read_dir(dir.path())
.unwrap()
.filter_map(Result::ok)
.filter(|e| e.path().extension().is_some_and(|x| x == "tmp"))
.collect();
assert!(leftovers.is_empty(), "staging leaked: {leftovers:?}");
}
#[test]
fn write_error_is_transparent_over_its_payload_and_keeps_the_chain_at_depth_one() {
let e = WriteError::Write(Write::new(
PathBuf::from("/tmp/a b/out.srt"),
std::io::Error::new(std::io::ErrorKind::PermissionDenied, "denied"),
));
assert_eq!(
e.to_string(),
"failed to write result file `/tmp/a b/out.srt`: denied"
);
let mut depth = 0;
let mut cur: Option<&(dyn std::error::Error + 'static)> = std::error::Error::source(&e);
while let Some(c) = cur {
depth += 1;
cur = std::error::Error::source(c);
}
assert_eq!(depth, 1, "the source chain must stay at depth 1");
}
fn escape_bed() -> (tempfile::TempDir, PathBuf) {
let root = tempfile::tempdir().unwrap();
let out = root.path().join("out");
std::fs::create_dir(&out).unwrap();
(root, out)
}
fn tree(root: &Path) -> Vec<PathBuf> {
let mut found = Vec::new();
let mut work = vec![root.to_path_buf()];
while let Some(dir) = work.pop() {
for entry in std::fs::read_dir(&dir).unwrap().flatten() {
let path = entry.path();
found.push(path.strip_prefix(root).unwrap().to_path_buf());
if entry.file_type().unwrap().is_dir() {
work.push(path);
}
}
}
found.sort();
found
}
fn every_writer(dir: &Path) -> Vec<(&'static str, Box<dyn ResultWriter>)> {
#[allow(unused_mut)]
let mut writers: Vec<(&'static str, Box<dyn ResultWriter>)> = vec![
("srt", Box::new(SrtWriter::new(dir))),
("vtt", Box::new(VttWriter::new(dir))),
];
#[cfg(feature = "serde")]
writers.push(("json", Box::new(JsonWriter::new(dir))));
writers
}
#[test]
fn writers_refuse_a_file_stem_that_is_not_one_path_component() {
let result = result_with_segment(vec![]);
let (bed, _) = escape_bed();
let absolute_inside_the_bed = bed.path().join("absolute").display().to_string();
for stem in [
"../escape",
"sub/dir",
"/abs",
&absolute_inside_the_bed,
".",
"..",
"",
"back\\slash",
"nul\0byte",
] {
let (root, out) = escape_bed();
std::fs::create_dir(out.join("sub")).unwrap();
let before = tree(root.path());
for (ext, writer) in every_writer(&out) {
let err = writer.write(&result, stem).unwrap_err();
match err {
WriteError::FileStem(ref payload) => {
assert_eq!(payload.stem(), stem, "{ext} reported the wrong stem");
assert!(!payload.reason().is_empty(), "{ext} reported no reason");
}
other => panic!("{ext} accepted stem {stem:?}: {other:?}"),
}
}
assert_eq!(
tree(root.path()),
before,
"a refused stem {stem:?} still wrote something"
);
}
assert_eq!(tree(bed.path()), vec![PathBuf::from("out")]);
}
#[test]
fn writers_keep_writing_plain_stems() {
let result = result_with_segment(vec![]);
for stem in ["talk", "a.b", "文件", "a b"] {
let (root, out) = escape_bed();
for (ext, writer) in every_writer(&out) {
let path = writer.write(&result, stem).unwrap();
assert_eq!(path, out.join(format!("{stem}.{ext}")), "{ext} {stem:?}");
let expected = match ext {
"srt" => srt_content(&result),
"vtt" => vtt_content(&result),
#[cfg(feature = "serde")]
"json" => json_content(&result).unwrap(),
other => unreachable!("unknown writer {other}"),
};
assert_eq!(
std::fs::read_to_string(&path).unwrap(),
expected,
"{ext} {stem:?}"
);
}
let mut expected = vec![PathBuf::from("out")];
expected.extend(
every_writer(&out)
.iter()
.map(|(ext, _)| PathBuf::from("out").join(format!("{stem}.{ext}"))),
);
expected.sort();
assert_eq!(tree(root.path()), expected, "stem {stem:?}");
}
}