Files
memby/server/internal/subsync/subsync_test.go
T

315 lines
10 KiB
Go
Raw Normal View History

2026-08-09 08:25:50 +12:00
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)
}
}
}