sub-cli/internal/sync/sync_test.go

1241 lines
37 KiB
Go

package sync
import (
"os"
"path/filepath"
"strings"
"testing"
"sub-cli/internal/model"
)
func TestSyncLyrics(t *testing.T) {
// Create temporary test directory
tempDir := t.TempDir()
// Test cases for different format combinations
testCases := []struct {
name string
sourceContent string
sourceExt string
targetContent string
targetExt string
expectedError bool
validateOutput func(t *testing.T, filePath string)
}{
{
name: "LRC to LRC sync",
sourceContent: `[ti:Source LRC]
[ar:Test Artist]
[00:01.00]This is line one.
[00:05.00]This is line two.
[00:09.50]This is line three.
`,
sourceExt: "lrc",
targetContent: `[ti:Target LRC]
[ar:Different Artist]
[00:10.00]This is line one with different timing.
[00:20.00]This is line two with different timing.
[00:30.00]This is line three with different timing.
`,
targetExt: "lrc",
expectedError: false,
validateOutput: func(t *testing.T, filePath string) {
content, err := os.ReadFile(filePath)
if err != nil {
t.Fatalf("Failed to read output file: %v", err)
}
contentStr := string(content)
// Should contain target title but source timings
if !strings.Contains(contentStr, "[ti:Target LRC]") {
t.Errorf("Output should preserve target title, got: %s", contentStr)
}
if !strings.Contains(contentStr, "[ar:Different Artist]") {
t.Errorf("Output should preserve target artist, got: %s", contentStr)
}
// Should have source timings
if !strings.Contains(contentStr, "[00:01.000]") {
t.Errorf("Output should have source timing [00:01.000], got: %s", contentStr)
}
// Should have target content
if !strings.Contains(contentStr, "This is line one with different timing.") {
t.Errorf("Output should preserve target content, got: %s", contentStr)
}
},
},
{
name: "SRT to SRT sync",
sourceContent: `1
00:00:01,000 --> 00:00:04,000
This is line one.
2
00:00:05,000 --> 00:00:08,000
This is line two.
3
00:00:09,000 --> 00:00:12,000
This is line three.
`,
sourceExt: "srt",
targetContent: `1
00:01:00,000 --> 00:01:03,000
This is target line one.
2
00:01:05,000 --> 00:01:08,000
This is target line two.
3
00:01:10,000 --> 00:01:13,000
This is target line three.
`,
targetExt: "srt",
expectedError: false,
validateOutput: func(t *testing.T, filePath string) {
content, err := os.ReadFile(filePath)
if err != nil {
t.Fatalf("Failed to read output file: %v", err)
}
contentStr := string(content)
// Should have source timings but target content
if !strings.Contains(contentStr, "00:00:01,000 -->") {
t.Errorf("Output should have source timing 00:00:01,000, got: %s", contentStr)
}
// Check target content is preserved
if !strings.Contains(contentStr, "This is target line one.") {
t.Errorf("Output should preserve target content, got: %s", contentStr)
}
// Check identifiers are sequential
if !strings.Contains(contentStr, "1\n00:00:01,000") {
t.Errorf("Output should have sequential identifiers starting with 1, got: %s", contentStr)
}
},
},
{
name: "VTT to VTT sync",
sourceContent: `WEBVTT
1
00:00:01.000 --> 00:00:04.000
This is line one.
2
00:00:05.000 --> 00:00:08.000
This is line two.
3
00:00:09.000 --> 00:00:12.000
This is line three.
`,
sourceExt: "vtt",
targetContent: `WEBVTT - Target Title
1
00:01:00.000 --> 00:01:03.000 align:start position:10%
This is target line one.
2
00:01:05.000 --> 00:01:08.000 align:middle
This is target line two.
3
00:01:10.000 --> 00:01:13.000
This is target line three.
`,
targetExt: "vtt",
expectedError: false,
validateOutput: func(t *testing.T, filePath string) {
content, err := os.ReadFile(filePath)
if err != nil {
t.Fatalf("Failed to read output file: %v", err)
}
contentStr := string(content)
// Should preserve VTT title
if !strings.Contains(contentStr, "WEBVTT - Target Title") {
t.Errorf("Output should preserve target title, got: %s", contentStr)
}
// Should have source timings
if !strings.Contains(contentStr, "00:00:01.000 -->") {
t.Errorf("Output should have source timing 00:00:01.000, got: %s", contentStr)
}
// Should preserve styling - don't check exact order, just presence of attributes
if !strings.Contains(contentStr, "align:start") || !strings.Contains(contentStr, "position:10%") {
t.Errorf("Output should preserve both cue settings (align:start and position:10%%), got: %s", contentStr)
}
// Should preserve target content
if !strings.Contains(contentStr, "This is target line one.") {
t.Errorf("Output should preserve target content, got: %s", contentStr)
}
},
},
{
name: "ASS to ASS sync",
sourceContent: `[Script Info]
ScriptType: v4.00+
PlayResX: 640
PlayResY: 480
Title: Source ASS
[V4+ Styles]
Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
Style: Default,Arial,20,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,2,2,10,10,10,1
[Events]
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
Dialogue: 0,0:00:01.00,0:00:04.00,Default,,0,0,0,,Source line one.
Dialogue: 0,0:00:05.00,0:00:08.00,Default,,0,0,0,,Source line two.
Dialogue: 0,0:00:09.00,0:00:12.00,Default,,0,0,0,,Source line three.
`,
sourceExt: "ass",
targetContent: `[Script Info]
ScriptType: v4.00+
PlayResX: 640
PlayResY: 480
Title: Target ASS
[V4+ Styles]
Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
Style: Default,Arial,20,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,2,2,10,10,10,1
[Events]
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
Dialogue: 0,0:01:00.00,0:01:03.00,Default,,0,0,0,,Target line one.
Dialogue: 0,0:01:05.00,0:01:08.00,Default,,0,0,0,,Target line two.
Dialogue: 0,0:01:10.00,0:01:13.00,Default,,0,0,0,,Target line three.
`,
targetExt: "ass",
expectedError: false,
validateOutput: func(t *testing.T, filePath string) {
content, err := os.ReadFile(filePath)
if err != nil {
t.Fatalf("Failed to read output file: %v", err)
}
contentStr := string(content)
// Should preserve script info from target
if !strings.Contains(contentStr, "Title: Target ASS") {
t.Errorf("Output should preserve target title, got: %s", contentStr)
}
// Should have source timings but target content
if !strings.Contains(contentStr, "0:00:01.00,0:00:04.00") {
t.Errorf("Output should have source timing 0:00:01.00, got: %s", contentStr)
}
// Check target content is preserved
if !strings.Contains(contentStr, "Target line one.") {
t.Errorf("Output should preserve target content, got: %s", contentStr)
}
},
},
}
// Run test cases
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
// Create source file
sourceFile := filepath.Join(tempDir, "source."+tc.sourceExt)
if err := os.WriteFile(sourceFile, []byte(tc.sourceContent), 0644); err != nil {
t.Fatalf("Failed to create source file: %v", err)
}
// Create target file
targetFile := filepath.Join(tempDir, "target."+tc.targetExt)
if err := os.WriteFile(targetFile, []byte(tc.targetContent), 0644); err != nil {
t.Fatalf("Failed to create target file: %v", err)
}
// Call SyncLyrics
err := SyncLyrics(sourceFile, targetFile)
// Check error
if tc.expectedError && err == nil {
t.Errorf("Expected error but got none")
}
if !tc.expectedError && err != nil {
t.Errorf("Expected no error but got: %v", err)
}
// If no error expected and validation function provided, validate output
if !tc.expectedError && tc.validateOutput != nil {
// Make sure file exists
if _, err := os.Stat(targetFile); os.IsNotExist(err) {
t.Fatalf("Target file was not created: %v", err)
}
tc.validateOutput(t, targetFile)
}
})
}
// Test unsupported format
t.Run("Unsupported format", func(t *testing.T) {
sourceFile := filepath.Join(tempDir, "source.unknown")
targetFile := filepath.Join(tempDir, "target.unknown")
// Create source and target files
sourceContent := "Some content in unknown format"
if err := os.WriteFile(sourceFile, []byte(sourceContent), 0644); err != nil {
t.Fatalf("Failed to create source file: %v", err)
}
targetContent := "Some target content in unknown format"
if err := os.WriteFile(targetFile, []byte(targetContent), 0644); err != nil {
t.Fatalf("Failed to create target file: %v", err)
}
// Call SyncLyrics, expect error
if err := SyncLyrics(sourceFile, targetFile); err == nil {
t.Errorf("Expected error for unsupported format, but got none")
}
})
}
func TestSyncASSTimeline(t *testing.T) {
t.Run("Equal number of events", func(t *testing.T) {
// Create source ASS file
source := model.ASSFile{
ScriptInfo: map[string]string{
"Title": "Source ASS",
"ScriptType": "v4.00+",
},
Styles: []model.ASSStyle{
{
Name: "Default",
Properties: map[string]string{
"Bold": "0",
},
},
},
Events: []model.ASSEvent{
{
Type: "Dialogue",
Layer: 0,
StartTime: model.Timestamp{Seconds: 1},
EndTime: model.Timestamp{Seconds: 4},
Style: "Default",
Text: "Source line one.",
},
{
Type: "Dialogue",
Layer: 0,
StartTime: model.Timestamp{Seconds: 5},
EndTime: model.Timestamp{Seconds: 8},
Style: "Default",
Text: "Source line two.",
},
},
}
// Create target ASS file
target := model.ASSFile{
ScriptInfo: map[string]string{
"Title": "Target ASS",
"ScriptType": "v4.00+",
},
Styles: []model.ASSStyle{
{
Name: "Default",
Properties: map[string]string{
"Bold": "0",
},
},
},
Events: []model.ASSEvent{
{
Type: "Dialogue",
Layer: 0,
StartTime: model.Timestamp{Minutes: 1, Seconds: 0},
EndTime: model.Timestamp{Minutes: 1, Seconds: 3},
Style: "Default",
Text: "Target line one.",
},
{
Type: "Dialogue",
Layer: 0,
StartTime: model.Timestamp{Minutes: 1, Seconds: 5},
EndTime: model.Timestamp{Minutes: 1, Seconds: 8},
Style: "Default",
Text: "Target line two.",
},
},
}
// Sync the timelines
result := syncASSTimeline(source, target)
// Check that the result has the correct number of events
if len(result.Events) != 2 {
t.Errorf("Expected 2 events, got %d", len(result.Events))
}
// Check that the script info was preserved from the target
if result.ScriptInfo["Title"] != "Target ASS" {
t.Errorf("Expected title 'Target ASS', got '%s'", result.ScriptInfo["Title"])
}
// Check that the first event has the source timing but target text
if result.Events[0].StartTime.Seconds != 1 {
t.Errorf("Expected start time 1 second, got %d", result.Events[0].StartTime.Seconds)
}
if result.Events[0].Text != "Target line one." {
t.Errorf("Expected text 'Target line one.', got '%s'", result.Events[0].Text)
}
// Check that the second event has the source timing but target text
if result.Events[1].StartTime.Seconds != 5 {
t.Errorf("Expected start time 5 seconds, got %d", result.Events[1].StartTime.Seconds)
}
if result.Events[1].Text != "Target line two." {
t.Errorf("Expected text 'Target line two.', got '%s'", result.Events[1].Text)
}
})
t.Run("Different number of events", func(t *testing.T) {
// Create source ASS file with 3 events
source := model.ASSFile{
ScriptInfo: map[string]string{
"Title": "Source ASS",
"ScriptType": "v4.00+",
},
Events: []model.ASSEvent{
{
Type: "Dialogue",
Layer: 0,
StartTime: model.Timestamp{Seconds: 1},
EndTime: model.Timestamp{Seconds: 4},
Text: "Source line one.",
},
{
Type: "Dialogue",
Layer: 0,
StartTime: model.Timestamp{Seconds: 5},
EndTime: model.Timestamp{Seconds: 8},
Text: "Source line two.",
},
{
Type: "Dialogue",
Layer: 0,
StartTime: model.Timestamp{Seconds: 9},
EndTime: model.Timestamp{Seconds: 12},
Text: "Source line three.",
},
},
}
// Create target ASS file with 2 events
target := model.ASSFile{
ScriptInfo: map[string]string{
"Title": "Target ASS",
"ScriptType": "v4.00+",
},
Events: []model.ASSEvent{
{
Type: "Dialogue",
Layer: 0,
StartTime: model.Timestamp{Minutes: 1, Seconds: 0},
EndTime: model.Timestamp{Minutes: 1, Seconds: 3},
Text: "Target line one.",
},
{
Type: "Dialogue",
Layer: 0,
StartTime: model.Timestamp{Minutes: 1, Seconds: 5},
EndTime: model.Timestamp{Minutes: 1, Seconds: 8},
Text: "Target line two.",
},
},
}
// Sync the timelines
result := syncASSTimeline(source, target)
// Check that the result has the correct number of events
if len(result.Events) != 2 {
t.Errorf("Expected 2 events, got %d", len(result.Events))
}
// Timeline should be scaled
if result.Events[0].StartTime.Seconds != 1 {
t.Errorf("Expected first event start time 1 second, got %d", result.Events[0].StartTime.Seconds)
}
// With 3 source events and 2 target events, the second event should get timing from the third source event
if result.Events[1].StartTime.Seconds != 9 {
t.Errorf("Expected second event start time 9 seconds, got %d", result.Events[1].StartTime.Seconds)
}
})
t.Run("Empty events", func(t *testing.T) {
// Create source and target with empty events
source := model.ASSFile{
ScriptInfo: map[string]string{"Title": "Source ASS"},
Events: []model.ASSEvent{},
}
target := model.ASSFile{
ScriptInfo: map[string]string{"Title": "Target ASS"},
Events: []model.ASSEvent{},
}
// Sync the timelines
result := syncASSTimeline(source, target)
// Check that the result has empty events
if len(result.Events) != 0 {
t.Errorf("Expected 0 events, got %d", len(result.Events))
}
// Check that the script info was preserved
if result.ScriptInfo["Title"] != "Target ASS" {
t.Errorf("Expected title 'Target ASS', got '%s'", result.ScriptInfo["Title"])
}
})
t.Run("Source has events, target is empty", func(t *testing.T) {
// Create source with events
source := model.ASSFile{
ScriptInfo: map[string]string{"Title": "Source ASS"},
Events: []model.ASSEvent{
{
Type: "Dialogue",
StartTime: model.Timestamp{Seconds: 1},
EndTime: model.Timestamp{Seconds: 4},
Text: "Source line.",
},
},
}
// Create target with no events
target := model.ASSFile{
ScriptInfo: map[string]string{"Title": "Target ASS"},
Events: []model.ASSEvent{},
}
// Sync the timelines
result := syncASSTimeline(source, target)
// Result should have no events
if len(result.Events) != 0 {
t.Errorf("Expected 0 events, got %d", len(result.Events))
}
})
}
func TestSyncASSFiles(t *testing.T) {
tempDir := t.TempDir()
t.Run("Sync ASS files", func(t *testing.T) {
// Create source ASS file
sourceFile := filepath.Join(tempDir, "source.ass")
sourceContent := `[Script Info]
ScriptType: v4.00+
PlayResX: 640
PlayResY: 480
Title: Source ASS
[V4+ Styles]
Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
Style: Default,Arial,20,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,2,2,10,10,10,1
[Events]
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
Dialogue: 0,0:00:01.00,0:00:04.00,Default,,0,0,0,,Source line one.
Dialogue: 0,0:00:05.00,0:00:08.00,Default,,0,0,0,,Source line two.
Dialogue: 0,0:00:09.00,0:00:12.00,Default,,0,0,0,,Source line three.
`
if err := os.WriteFile(sourceFile, []byte(sourceContent), 0644); err != nil {
t.Fatalf("Failed to create source file: %v", err)
}
// Create target ASS file
targetFile := filepath.Join(tempDir, "target.ass")
targetContent := `[Script Info]
ScriptType: v4.00+
PlayResX: 640
PlayResY: 480
Title: Target ASS
[V4+ Styles]
Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
Style: Default,Arial,20,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,2,2,10,10,10,1
[Events]
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
Dialogue: 0,0:01:00.00,0:01:03.00,Default,,0,0,0,,Target line one.
Dialogue: 0,0:01:05.00,0:01:08.00,Default,,0,0,0,,Target line two.
Dialogue: 0,0:01:10.00,0:01:13.00,Default,,0,0,0,,Target line three.
`
if err := os.WriteFile(targetFile, []byte(targetContent), 0644); err != nil {
t.Fatalf("Failed to create target file: %v", err)
}
// Sync the files
err := syncASSFiles(sourceFile, targetFile)
if err != nil {
t.Errorf("Expected no error, got: %v", err)
}
// Check that the target file exists
if _, err := os.Stat(targetFile); os.IsNotExist(err) {
t.Errorf("Target file no longer exists: %v", err)
}
// Check the contents of the target file
outputContent, err := os.ReadFile(targetFile)
if err != nil {
t.Fatalf("Failed to read target file: %v", err)
}
outputContentStr := string(outputContent)
// Should preserve script info from target
if !strings.Contains(outputContentStr, "Title: Target ASS") {
t.Errorf("Output should preserve target title, got: %s", outputContentStr)
}
// Should have source timings but target content
if !strings.Contains(outputContentStr, "0:00:01.00,0:00:04.00") {
t.Errorf("Output should have source timing 0:00:01.00, got: %s", outputContentStr)
}
// Should have target content
if !strings.Contains(outputContentStr, "Target line one.") {
t.Errorf("Output should preserve target content, got: %s", outputContentStr)
}
})
}
func TestSyncVTTTimeline(t *testing.T) {
testCases := []struct {
name string
source model.Subtitle
target model.Subtitle
verify func(t *testing.T, result model.Subtitle)
}{
{
name: "Equal entry count",
source: func() model.Subtitle {
sub := model.NewSubtitle()
sub.Format = "vtt"
sub.Title = "Source Title"
sub.Entries = []model.SubtitleEntry{
{
Index: 1,
StartTime: model.Timestamp{Seconds: 1},
EndTime: model.Timestamp{Seconds: 4},
Text: "Source line one.",
},
{
Index: 2,
StartTime: model.Timestamp{Seconds: 5},
EndTime: model.Timestamp{Seconds: 8},
Text: "Source line two.",
},
}
return sub
}(),
target: func() model.Subtitle {
sub := model.NewSubtitle()
sub.Format = "vtt"
sub.Title = "Target Title"
sub.Metadata = map[string]string{"WEBVTT": "Some Styles"}
sub.Entries = []model.SubtitleEntry{
{
Index: 1,
StartTime: model.Timestamp{Minutes: 1},
EndTime: model.Timestamp{Minutes: 1, Seconds: 3},
Text: "Target line one.",
Styles: map[string]string{"align": "start", "position": "10%"},
},
{
Index: 2,
StartTime: model.Timestamp{Minutes: 1, Seconds: 5},
EndTime: model.Timestamp{Minutes: 1, Seconds: 8},
Text: "Target line two.",
Styles: map[string]string{"align": "middle"},
},
}
return sub
}(),
verify: func(t *testing.T, result model.Subtitle) {
if result.Format != "vtt" {
t.Errorf("Expected format 'vtt', got '%s'", result.Format)
}
if result.Title != "Target Title" {
t.Errorf("Expected title 'Target Title', got '%s'", result.Title)
}
if len(result.Metadata) == 0 || result.Metadata["WEBVTT"] != "Some Styles" {
t.Errorf("Expected to preserve metadata, got %v", result.Metadata)
}
if len(result.Entries) != 2 {
t.Errorf("Expected 2 entries, got %d", len(result.Entries))
return
}
// Check that first entry has source timing
if result.Entries[0].StartTime.Seconds != 1 || result.Entries[0].EndTime.Seconds != 4 {
t.Errorf("First entry timing incorrect, got start: %+v, end: %+v",
result.Entries[0].StartTime, result.Entries[0].EndTime)
}
// Check that styles are preserved
if result.Entries[0].Styles["align"] != "start" || result.Entries[0].Styles["position"] != "10%" {
t.Errorf("Expected to preserve styles, got %v", result.Entries[0].Styles)
}
// Check text is preserved
if result.Entries[0].Text != "Target line one." {
t.Errorf("Expected target text, got '%s'", result.Entries[0].Text)
}
// Check indexes are sequential
if result.Entries[0].Index != 1 || result.Entries[1].Index != 2 {
t.Errorf("Expected sequential indexes 1, 2, got %d, %d",
result.Entries[0].Index, result.Entries[1].Index)
}
},
},
{
name: "Different entry count - more source entries",
source: func() model.Subtitle {
sub := model.NewSubtitle()
sub.Format = "vtt"
sub.Entries = []model.SubtitleEntry{
{
Index: 1,
StartTime: model.Timestamp{Seconds: 1},
EndTime: model.Timestamp{Seconds: 4},
Text: "Source line one.",
},
{
Index: 2,
StartTime: model.Timestamp{Seconds: 5},
EndTime: model.Timestamp{Seconds: 8},
Text: "Source line two.",
},
{
Index: 3,
StartTime: model.Timestamp{Seconds: 9},
EndTime: model.Timestamp{Seconds: 12},
Text: "Source line three.",
},
}
return sub
}(),
target: func() model.Subtitle {
sub := model.NewSubtitle()
sub.Format = "vtt"
sub.Entries = []model.SubtitleEntry{
{
Index: 1,
StartTime: model.Timestamp{Minutes: 1},
EndTime: model.Timestamp{Minutes: 1, Seconds: 3},
Text: "Target line one.",
},
{
Index: 2,
StartTime: model.Timestamp{Minutes: 1, Seconds: 5},
EndTime: model.Timestamp{Minutes: 1, Seconds: 8},
Text: "Target line two.",
},
}
return sub
}(),
verify: func(t *testing.T, result model.Subtitle) {
if len(result.Entries) != 2 {
t.Errorf("Expected 2 entries, got %d", len(result.Entries))
return
}
// Check scaling - first entry should get timing from first source
if result.Entries[0].StartTime.Seconds != 1 {
t.Errorf("First entry start time incorrect, got %+v", result.Entries[0].StartTime)
}
// Second entry should have timing from last source entry due to scaling
if result.Entries[1].StartTime.Seconds != 9 {
t.Errorf("Second entry start time incorrect, expected scaled timing, got %+v",
result.Entries[1].StartTime)
}
// Check target text preserved
if result.Entries[0].Text != "Target line one." || result.Entries[1].Text != "Target line two." {
t.Errorf("Expected target text to be preserved")
}
},
},
{
name: "Empty source entries",
source: func() model.Subtitle {
sub := model.NewSubtitle()
sub.Format = "vtt"
sub.Entries = []model.SubtitleEntry{}
return sub
}(),
target: func() model.Subtitle {
sub := model.NewSubtitle()
sub.Format = "vtt"
sub.Title = "Target Title"
sub.Entries = []model.SubtitleEntry{
{
Index: 1,
StartTime: model.Timestamp{Minutes: 1},
EndTime: model.Timestamp{Minutes: 1, Seconds: 3},
Text: "Target line one.",
Styles: map[string]string{"align": "start"},
},
}
return sub
}(),
verify: func(t *testing.T, result model.Subtitle) {
if len(result.Entries) != 1 {
t.Errorf("Expected 1 entry, got %d", len(result.Entries))
return
}
// With empty source, target timing should be preserved
if result.Entries[0].StartTime.Minutes != 1 || result.Entries[0].EndTime.Minutes != 1 {
t.Errorf("Empty source should preserve target timing, got start: %+v, end: %+v",
result.Entries[0].StartTime, result.Entries[0].EndTime)
}
// Check target styles preserved
if _, hasAlign := result.Entries[0].Styles["align"]; !hasAlign || result.Entries[0].Styles["align"] != "start" {
t.Errorf("Expected target styles to be preserved, got %v", result.Entries[0].Styles)
}
// Check title is preserved
if result.Title != "Target Title" {
t.Errorf("Expected target title to be preserved")
}
},
},
{
name: "Empty target entries",
source: func() model.Subtitle {
sub := model.NewSubtitle()
sub.Format = "vtt"
sub.Entries = []model.SubtitleEntry{
{
Index: 1,
StartTime: model.Timestamp{Seconds: 1},
EndTime: model.Timestamp{Seconds: 4},
Text: "Source line one.",
},
}
return sub
}(),
target: func() model.Subtitle {
sub := model.NewSubtitle()
sub.Format = "vtt"
sub.Title = "Target Title"
sub.Entries = []model.SubtitleEntry{}
return sub
}(),
verify: func(t *testing.T, result model.Subtitle) {
if len(result.Entries) != 0 {
t.Errorf("Expected 0 entries, got %d", len(result.Entries))
return
}
// Should keep target metadata
if result.Title != "Target Title" {
t.Errorf("Expected target title to be preserved")
}
},
},
{
name: "Different entry count - more target entries",
source: func() model.Subtitle {
sub := model.NewSubtitle()
sub.Format = "vtt"
sub.Entries = []model.SubtitleEntry{
{
Index: 1,
StartTime: model.Timestamp{Seconds: 1},
EndTime: model.Timestamp{Seconds: 4},
Text: "Source line one.",
},
}
return sub
}(),
target: func() model.Subtitle {
sub := model.NewSubtitle()
sub.Format = "vtt"
sub.Entries = []model.SubtitleEntry{
{
Index: 1,
StartTime: model.Timestamp{Minutes: 1},
EndTime: model.Timestamp{Minutes: 1, Seconds: 3},
Text: "Target line one.",
},
{
Index: 2,
StartTime: model.Timestamp{Minutes: 1, Seconds: 5},
EndTime: model.Timestamp{Minutes: 1, Seconds: 8},
Text: "Target line two.",
},
{
Index: 3,
StartTime: model.Timestamp{Minutes: 1, Seconds: 10},
EndTime: model.Timestamp{Minutes: 1, Seconds: 13},
Text: "Target line three.",
},
}
return sub
}(),
verify: func(t *testing.T, result model.Subtitle) {
if len(result.Entries) != 3 {
t.Errorf("Expected 3 entries, got %d", len(result.Entries))
return
}
// Check that first entry has source timing
if result.Entries[0].StartTime.Seconds != 1 {
t.Errorf("First entry start time incorrect, got %+v", result.Entries[0].StartTime)
}
// The other entries should be scaled from the source
// With only one source entry, all target entries should get the same start time
if result.Entries[1].StartTime.Seconds != 1 || result.Entries[2].StartTime.Seconds != 1 {
t.Errorf("All entries should have same timing with only one source entry, got: %+v, %+v",
result.Entries[1].StartTime, result.Entries[2].StartTime)
}
// Check indexes are sequential
if result.Entries[0].Index != 1 || result.Entries[1].Index != 2 || result.Entries[2].Index != 3 {
t.Errorf("Expected sequential indexes 1, 2, 3, got %d, %d, %d",
result.Entries[0].Index, result.Entries[1].Index, result.Entries[2].Index)
}
},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
result := syncVTTTimeline(tc.source, tc.target)
if tc.verify != nil {
tc.verify(t, result)
}
})
}
}
func TestSyncSRTTimeline(t *testing.T) {
testCases := []struct {
name string
sourceEntries []model.SRTEntry
targetEntries []model.SRTEntry
verify func(t *testing.T, result []model.SRTEntry)
}{
{
name: "Equal entry count",
sourceEntries: []model.SRTEntry{
{
Number: 1,
StartTime: model.Timestamp{Seconds: 1},
EndTime: model.Timestamp{Seconds: 4},
Content: "Source line one.",
},
{
Number: 2,
StartTime: model.Timestamp{Seconds: 5},
EndTime: model.Timestamp{Seconds: 8},
Content: "Source line two.",
},
},
targetEntries: []model.SRTEntry{
{
Number: 1,
StartTime: model.Timestamp{Minutes: 1},
EndTime: model.Timestamp{Minutes: 1, Seconds: 3},
Content: "Target line one.",
},
{
Number: 2,
StartTime: model.Timestamp{Minutes: 1, Seconds: 5},
EndTime: model.Timestamp{Minutes: 1, Seconds: 8},
Content: "Target line two.",
},
},
verify: func(t *testing.T, result []model.SRTEntry) {
if len(result) != 2 {
t.Errorf("Expected 2 entries, got %d", len(result))
return
}
// Check first entry
if result[0].StartTime.Seconds != 1 || result[0].EndTime.Seconds != 4 {
t.Errorf("First entry timing incorrect, got start: %+v, end: %+v",
result[0].StartTime, result[0].EndTime)
}
if result[0].Content != "Target line one." {
t.Errorf("Expected content 'Target line one.', got '%s'", result[0].Content)
}
if result[0].Number != 1 {
t.Errorf("Expected entry number 1, got %d", result[0].Number)
}
// Check second entry
if result[1].StartTime.Seconds != 5 || result[1].EndTime.Seconds != 8 {
t.Errorf("Second entry timing incorrect, got start: %+v, end: %+v",
result[1].StartTime, result[1].EndTime)
}
if result[1].Content != "Target line two." {
t.Errorf("Expected content 'Target line two.', got '%s'", result[1].Content)
}
if result[1].Number != 2 {
t.Errorf("Expected entry number 2, got %d", result[1].Number)
}
},
},
{
name: "Different entry count - more source entries",
sourceEntries: []model.SRTEntry{
{
Number: 1,
StartTime: model.Timestamp{Seconds: 1},
EndTime: model.Timestamp{Seconds: 4},
Content: "Source line one.",
},
{
Number: 2,
StartTime: model.Timestamp{Seconds: 5},
EndTime: model.Timestamp{Seconds: 8},
Content: "Source line two.",
},
{
Number: 3,
StartTime: model.Timestamp{Seconds: 9},
EndTime: model.Timestamp{Seconds: 12},
Content: "Source line three.",
},
},
targetEntries: []model.SRTEntry{
{
Number: 1,
StartTime: model.Timestamp{Minutes: 1},
EndTime: model.Timestamp{Minutes: 1, Seconds: 3},
Content: "Target line one.",
},
{
Number: 2,
StartTime: model.Timestamp{Minutes: 1, Seconds: 5},
EndTime: model.Timestamp{Minutes: 1, Seconds: 8},
Content: "Target line two.",
},
},
verify: func(t *testing.T, result []model.SRTEntry) {
if len(result) != 2 {
t.Errorf("Expected 2 entries, got %d", len(result))
return
}
// First entry should have timing from first source entry
if result[0].StartTime.Seconds != 1 {
t.Errorf("First entry start time incorrect, got %+v", result[0].StartTime)
}
// Second entry should have scaling from source entry 3 (at index 2)
if result[1].StartTime.Seconds != 9 {
t.Errorf("Second entry start time incorrect, got %+v", result[1].StartTime)
}
// Check content content preserved
if result[0].Content != "Target line one." || result[1].Content != "Target line two." {
t.Errorf("Expected target content to be preserved")
}
// Check numbering
if result[0].Number != 1 || result[1].Number != 2 {
t.Errorf("Expected sequential numbering 1, 2, got %d, %d",
result[0].Number, result[1].Number)
}
},
},
{
name: "Empty source entries",
sourceEntries: []model.SRTEntry{},
targetEntries: []model.SRTEntry{
{
Number: 1,
StartTime: model.Timestamp{Minutes: 1},
EndTime: model.Timestamp{Minutes: 1, Seconds: 3},
Content: "Target line one.",
},
},
verify: func(t *testing.T, result []model.SRTEntry) {
if len(result) != 1 {
t.Errorf("Expected 1 entry, got %d", len(result))
return
}
// With empty source, target timing should be preserved
if result[0].StartTime.Minutes != 1 || result[0].EndTime.Minutes != 1 ||
result[0].EndTime.Seconds != 3 {
t.Errorf("Expected target timing to be preserved with empty source")
}
// Check content is preserved
if result[0].Content != "Target line one." {
t.Errorf("Expected target content to be preserved")
}
},
},
{
name: "Empty target entries",
sourceEntries: []model.SRTEntry{
{
Number: 1,
StartTime: model.Timestamp{Seconds: 1},
EndTime: model.Timestamp{Seconds: 4},
Content: "Source line one.",
},
},
targetEntries: []model.SRTEntry{},
verify: func(t *testing.T, result []model.SRTEntry) {
if len(result) != 0 {
t.Errorf("Expected 0 entries, got %d", len(result))
}
},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
result := syncSRTTimeline(tc.sourceEntries, tc.targetEntries)
if tc.verify != nil {
tc.verify(t, result)
}
})
}
}
func TestCalculateDuration(t *testing.T) {
testCases := []struct {
name string
start model.Timestamp
end model.Timestamp
expected model.Timestamp
}{
{
name: "Simple duration",
start: model.Timestamp{Minutes: 1, Seconds: 30},
end: model.Timestamp{Minutes: 3, Seconds: 10},
expected: model.Timestamp{Minutes: 1, Seconds: 40},
},
{
name: "Duration with hours",
start: model.Timestamp{Hours: 1, Minutes: 20},
end: model.Timestamp{Hours: 2, Minutes: 10},
expected: model.Timestamp{Hours: 0, Minutes: 50},
},
{
name: "Duration with milliseconds",
start: model.Timestamp{Seconds: 10, Milliseconds: 500},
end: model.Timestamp{Seconds: 20, Milliseconds: 800},
expected: model.Timestamp{Seconds: 10, Milliseconds: 300},
},
{
name: "End before start (should return zero)",
start: model.Timestamp{Minutes: 5},
end: model.Timestamp{Minutes: 3},
expected: model.Timestamp{Hours: 0, Minutes: 0, Seconds: 0, Milliseconds: 0},
},
{
name: "Complex duration with carry",
start: model.Timestamp{Hours: 1, Minutes: 45, Seconds: 30, Milliseconds: 500},
end: model.Timestamp{Hours: 3, Minutes: 20, Seconds: 15, Milliseconds: 800},
expected: model.Timestamp{Hours: 1, Minutes: 34, Seconds: 45, Milliseconds: 300},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
result := calculateDuration(tc.start, tc.end)
if result.Hours != tc.expected.Hours ||
result.Minutes != tc.expected.Minutes ||
result.Seconds != tc.expected.Seconds ||
result.Milliseconds != tc.expected.Milliseconds {
t.Errorf("Expected %+v, got %+v", tc.expected, result)
}
})
}
}
func TestAddDuration(t *testing.T) {
testCases := []struct {
name string
start model.Timestamp
duration model.Timestamp
expected model.Timestamp
}{
{
name: "Simple addition",
start: model.Timestamp{Minutes: 1, Seconds: 30},
duration: model.Timestamp{Minutes: 2, Seconds: 15},
expected: model.Timestamp{Minutes: 3, Seconds: 45},
},
{
name: "Addition with carry",
start: model.Timestamp{Minutes: 58, Seconds: 45},
duration: model.Timestamp{Minutes: 4, Seconds: 30},
expected: model.Timestamp{Hours: 1, Minutes: 3, Seconds: 15},
},
{
name: "Addition with milliseconds",
start: model.Timestamp{Seconds: 10, Milliseconds: 500},
duration: model.Timestamp{Seconds: 5, Milliseconds: 800},
expected: model.Timestamp{Seconds: 16, Milliseconds: 300},
},
{
name: "Zero duration",
start: model.Timestamp{Minutes: 5, Seconds: 30},
duration: model.Timestamp{},
expected: model.Timestamp{Minutes: 5, Seconds: 30},
},
{
name: "Complex addition with multiple carries",
start: model.Timestamp{Hours: 1, Minutes: 59, Seconds: 59, Milliseconds: 900},
duration: model.Timestamp{Hours: 2, Minutes: 10, Seconds: 15, Milliseconds: 200},
expected: model.Timestamp{Hours: 4, Minutes: 10, Seconds: 15, Milliseconds: 100},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
result := addDuration(tc.start, tc.duration)
if result.Hours != tc.expected.Hours ||
result.Minutes != tc.expected.Minutes ||
result.Seconds != tc.expected.Seconds ||
result.Milliseconds != tc.expected.Milliseconds {
t.Errorf("Expected %+v, got %+v", tc.expected, result)
}
})
}
}