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) } }) } }