package subsync import ( "errors" "math/rand" "strings" "testing" "time" ) // A film's worth of dialogue: irregularly spaced, varied line lengths, long gaps where // nothing is said. Regular cues would make every shift correlate with every other and // prove nothing about the search. func dialogue(count int, seed int64) []Cue { rng := rand.New(rand.NewSource(seed)) cues := make([]Cue, 0, count) at := 12 * time.Second for range count { length := time.Duration(900+rng.Intn(2600)) * time.Millisecond cues = append(cues, Cue{Start: at, End: at + length, Text: "line"}) gap := time.Duration(400+rng.Intn(4000)) * time.Millisecond if rng.Intn(11) == 0 { gap += time.Duration(6+rng.Intn(25)) * time.Second // a scene with no dialogue } at += length + gap } return cues } func TestAlignRecoversAKnownOffset(t *testing.T) { reference := dialogue(400, 7) for _, offset := range []time.Duration{ -42 * time.Second, -3500 * time.Millisecond, -700 * time.Millisecond, 2 * time.Second, 11500 * time.Millisecond, 37 * time.Second, } { t.Run(offset.String(), func(t *testing.T) { // The broken track is the reference pushed the wrong way, so the correction // that fixes it is the opposite of what was applied. broken := Shift(reference, -offset, 1) got, err := Align(broken, reference, DefaultOptions()) if err != nil { t.Fatalf("Align: %v", err) } if got.Scale != 1 { t.Fatalf("scale = %v, want 1 for a pure displacement", got.Scale) } if diff := (got.Offset - offset).Abs(); diff > 100*time.Millisecond { t.Fatalf("offset = %v, want %v (out by %v)", got.Offset, offset, diff) } }) } } // The case a plain offset cannot fix: a track authored against one transfer and played // against another runs further out as the film goes on. func TestAlignRecoversAFrameRateStretch(t *testing.T) { reference := dialogue(500, 11) scale := 25.0 / 24 broken := Shift(reference, 0, 1/scale) got, err := Align(broken, reference, DefaultOptions()) if err != nil { t.Fatalf("Align: %v", err) } if got.Scale != scale { t.Fatalf("scale = %v, want %v", got.Scale, scale) } // The real test is not the reported numbers but whether applying them lands the last // cue where it belongs — a stretch that is right at the start and wrong at the end is // exactly the fault being repaired. fixed := Shift(broken, got.Offset, got.Scale) drift := (fixed[len(fixed)-1].Start - reference[len(reference)-1].Start).Abs() if drift > 200*time.Millisecond { t.Fatalf("last cue is out by %v after correction", drift) } } // Timing that survives a translation. Different languages break lines differently and // their cues do not start on the same frame, so the alignment has to hold when the two // tracks agree only roughly. func TestAlignSurvivesATranslatedReference(t *testing.T) { reference := dialogue(400, 3) rng := rand.New(rand.NewSource(99)) translated := make([]Cue, 0, len(reference)) for i, cue := range reference { if i%9 == 0 { continue // a line the other track merged into its neighbour } jitter := time.Duration(rng.Intn(500)-250) * time.Millisecond translated = append(translated, Cue{ Start: cue.Start + jitter, End: cue.End + jitter + time.Duration(rng.Intn(600))*time.Millisecond, Text: "ligne", }) } offset := -8 * time.Second got, err := Align(Shift(translated, -offset, 1), reference, DefaultOptions()) if err != nil { t.Fatalf("Align: %v", err) } if diff := (got.Offset - offset).Abs(); diff > 300*time.Millisecond { t.Fatalf("offset = %v, want %v", got.Offset, offset) } } // Most of this package's job is producing no answer. A wrong correction is worse than // none: the viewer is told it worked and has no way to know the file is now further out. func TestAlignRefusesRatherThanGuess(t *testing.T) { reference := dialogue(400, 5) t.Run("a different film", func(t *testing.T) { _, err := Align(dialogue(400, 6), reference, DefaultOptions()) var refusal *ErrNoAlignment if !errors.As(err, &refusal) { t.Fatalf("err = %v, want a refusal", err) } if refusal.Reason == "" { t.Fatal("a refusal must carry something a viewer can read") } }) t.Run("too few lines to judge", func(t *testing.T) { _, err := Align(dialogue(6, 1), reference, DefaultOptions()) var refusal *ErrNoAlignment if !errors.As(err, &refusal) { t.Fatalf("err = %v, want a refusal", err) } }) // Evenly spaced cues fit their own reference at every multiple of the spacing, so a // dozen shifts score alike and none of them is the answer. This is the case the margin // test exists for — the score alone is a perfect 1.0 and would sail through. t.Run("timing that fits equally well in several places", func(t *testing.T) { metronome := func(offset time.Duration) []Cue { cues := make([]Cue, 0, 60) for i := range 60 { at := offset + time.Duration(i)*4*time.Second cues = append(cues, Cue{Start: at, End: at + 2*time.Second, Text: "line"}) } return cues } _, err := Align(metronome(9*time.Second), metronome(0), DefaultOptions()) var refusal *ErrNoAlignment if !errors.As(err, &refusal) { t.Fatalf("err = %v, want a refusal", err) } if refusal.Score < 0.9 { t.Fatalf("score = %.2f — this case must be refused on margin, not on score", refusal.Score) } }) } // A sparse track is not by itself a bad one. Taking every seventeenth line of a correct // subtitle leaves something that still lines up in exactly one place, and refusing it // would cost the fix for a forced or hearing-impaired track that is merely displaced. // What disqualifies a sparse track is being the *reference*, which Reference handles. func TestAlignAcceptsASparseButUnambiguousTrack(t *testing.T) { reference := dialogue(400, 5) sparse := []Cue{} for i, cue := range reference { if i%17 == 0 { sparse = append(sparse, cue) } } got, err := Align(Shift(sparse, 6*time.Second, 1), reference, DefaultOptions()) if err != nil { t.Fatalf("Align: %v", err) } if diff := (got.Offset + 6*time.Second).Abs(); diff > 100*time.Millisecond { t.Fatalf("offset = %v, want -6s", got.Offset) } } // A track already in sync must come back as no correction rather than a small nudge that // makes the file different for no reason. func TestAlignLeavesACorrectTrackAlone(t *testing.T) { reference := dialogue(300, 21) got, err := Align(reference, reference, DefaultOptions()) if err != nil { t.Fatalf("Align: %v", err) } if got.Correction() { t.Fatalf("a matching track reported a correction of %v", got) } } func TestShiftClampsRatherThanDroppingTheOpening(t *testing.T) { cues := []Cue{ {Start: time.Second, End: 3 * time.Second, Text: "first"}, {Start: 10 * time.Second, End: 12 * time.Second, Text: "second"}, } got := Shift(cues, -30*time.Second, 1) if len(got) != 2 { t.Fatalf("cue count = %d, want 2 — no line may be lost", len(got)) } if got[0].Start < 0 || got[0].End < 0 { t.Fatalf("negative timing survived: %+v", got[0]) } } // Which track to measure against is the one choice this package cannot check, so the rules // that make a track unusable as a yardstick are pinned. func TestReferencePrefersTheFullestUnforcedTrack(t *testing.T) { candidates := [][]Cue{ dialogue(30, 1), // 0: the broken one dialogue(900, 2), // 1: forced, so unusable however long dialogue(400, 3), // 2: the answer dialogue(5, 4), // 3: too short to judge anything by } forced := []bool{false, true, false, false} got, ok := Reference(candidates, 0, forced, 20) if !ok || got != 2 { t.Fatalf("reference = %d, ok = %v, want index 2", got, ok) } if _, ok := Reference(candidates[:1], 0, nil, 20); ok { t.Fatal("a title whose only track is the broken one has no reference") } } func TestParseReadsBothFormats(t *testing.T) { srt := "1\n00:00:12,500 --> 00:00:14,900\nHello there\n\n" + "2\n00:01:02,000 --> 00:01:04,250\nSecond line\nover two rows\n" vtt := "WEBVTT\n\n00:00:12.500 --> 00:00:14.900 align:start position:50%\nHello there\n\n" + "01:02.000 --> 01:04.250\nSecond line\nover two rows\n" for name, data := range map[string]string{"srt": srt, "vtt": vtt} { t.Run(name, func(t *testing.T) { cues, err := Parse([]byte(data)) if err != nil { t.Fatalf("Parse: %v", err) } if len(cues) != 2 { t.Fatalf("cue count = %d, want 2", len(cues)) } if cues[0].Start != 12500*time.Millisecond || cues[0].End != 14900*time.Millisecond { t.Fatalf("first cue = %+v", cues[0]) } if cues[1].Start != 62*time.Second { t.Fatalf("second cue start = %v, want 1m2s", cues[1].Start) } if cues[1].Text != "Second line\nover two rows" { t.Fatalf("second cue text = %q", cues[1].Text) } }) } } func TestParseHandlesFilesPeopleActuallyHave(t *testing.T) { t.Run("byte order mark and CRLF", func(t *testing.T) { data := "\ufeff1\r\n00:00:01,000 --> 00:00:02,000\r\nHi\r\n" cues, err := Parse([]byte(data)) if err != nil || len(cues) != 1 || cues[0].Text != "Hi" { t.Fatalf("cues = %+v, err = %v", cues, err) } }) t.Run("no blank line between cues", func(t *testing.T) { data := "1\n00:00:01,000 --> 00:00:02,000\nOne\n2\n00:00:03,000 --> 00:00:04,000\nTwo\n" cues, err := Parse([]byte(data)) if err != nil { t.Fatalf("Parse: %v", err) } if len(cues) != 2 || cues[1].Start != 3*time.Second { t.Fatalf("cues = %+v", cues) } }) t.Run("nothing that looks like a subtitle", func(t *testing.T) { if _, err := Parse([]byte("this is not a subtitle at all")); !errors.Is(err, ErrNoCues) { t.Fatalf("err = %v, want ErrNoCues", err) } }) } func TestFormatSRTRoundTrips(t *testing.T) { cues := []Cue{ {Start: 3661500 * time.Millisecond, End: 3663000 * time.Millisecond, Text: "Late in the film"}, {Start: 12 * time.Second, End: 14 * time.Second, Text: "Two\nrows"}, } out := FormatSRT(Normalise(cues)) if !strings.Contains(string(out), "01:01:01,500 --> 01:01:03,000") { t.Fatalf("timestamps not written as SubRip:\n%s", out) } back, err := Parse(out) if err != nil { t.Fatalf("Parse: %v", err) } if len(back) != 2 || back[0].Start != 12*time.Second || back[1].Text != "Late in the film" { t.Fatalf("round trip changed the cues: %+v", back) } } // The search runs while somebody is looking at a menu over their film, so its cost is part // of whether the feature is usable at all. func BenchmarkAlignFeatureLength(b *testing.B) { reference := dialogue(1400, 1) broken := Shift(reference, -9*time.Second, 1) opts := DefaultOptions() b.ResetTimer() for range b.N { if _, err := Align(broken, reference, opts); err != nil { b.Fatal(err) } } }