package ass import ( "os" "path/filepath" "strings" "testing" "sub-cli/internal/model" ) func TestParse(t *testing.T) { // Create temporary test file content := `[Script Info] ScriptType: v4.00+ Title: Test ASS File PlayResX: 640 PlayResY: 480 [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 Style: Bold,Arial,20,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,1,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,,This is the first subtitle line. Dialogue: 0,0:00:05.00,0:00:08.00,Bold,,0,0,0,,This is the second subtitle line with bold style. Comment: 0,0:00:09.00,0:00:12.00,Default,,0,0,0,,This is a comment. ` tempDir := t.TempDir() testFile := filepath.Join(tempDir, "test.ass") if err := os.WriteFile(testFile, []byte(content), 0644); err != nil { t.Fatalf("Failed to create test file: %v", err) } // Test parsing assFile, err := Parse(testFile) if err != nil { t.Fatalf("Parse failed: %v", err) } // Verify results // Script info if assFile.ScriptInfo["Title"] != "Test ASS File" { t.Errorf("Title mismatch: expected 'Test ASS File', got '%s'", assFile.ScriptInfo["Title"]) } if assFile.ScriptInfo["ScriptType"] != "v4.00+" { t.Errorf("Script type mismatch: expected 'v4.00+', got '%s'", assFile.ScriptInfo["ScriptType"]) } // Styles if len(assFile.Styles) != 3 { t.Errorf("Expected 3 styles, got %d", len(assFile.Styles)) } else { // Find Bold style var boldStyle *model.ASSStyle for i, style := range assFile.Styles { if style.Name == "Bold" { boldStyle = &assFile.Styles[i] break } } if boldStyle == nil { t.Errorf("Bold style not found") } else { boldValue, exists := boldStyle.Properties["Bold"] if !exists || boldValue != "1" { t.Errorf("Bold style Bold property mismatch: expected '1', got '%s'", boldValue) } } } // Events if len(assFile.Events) != 3 { t.Errorf("Expected 3 events, got %d", len(assFile.Events)) } else { // Check first dialogue line if assFile.Events[0].Type != "Dialogue" { t.Errorf("First event type mismatch: expected 'Dialogue', got '%s'", assFile.Events[0].Type) } if assFile.Events[0].StartTime.Seconds != 1 || assFile.Events[0].StartTime.Milliseconds != 0 { t.Errorf("First event start time mismatch: expected 00:00:01.00, got %d:%02d:%02d.%03d", assFile.Events[0].StartTime.Hours, assFile.Events[0].StartTime.Minutes, assFile.Events[0].StartTime.Seconds, assFile.Events[0].StartTime.Milliseconds) } if assFile.Events[0].Text != "This is the first subtitle line." { t.Errorf("First event text mismatch: expected 'This is the first subtitle line.', got '%s'", assFile.Events[0].Text) } // Check second dialogue line (bold style) if assFile.Events[1].Style != "Bold" { t.Errorf("Second event style mismatch: expected 'Bold', got '%s'", assFile.Events[1].Style) } // Check comment line if assFile.Events[2].Type != "Comment" { t.Errorf("Third event type mismatch: expected 'Comment', got '%s'", assFile.Events[2].Type) } } } func TestGenerate(t *testing.T) { // Create test ASS file structure assFile := model.NewASSFile() assFile.ScriptInfo["Title"] = "Generation Test" // Add a custom style boldStyle := model.ASSStyle{ Name: "Bold", Properties: map[string]string{ "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": "Bold,Arial,20,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,1,0,0,0,100,100,0,0,1,2,2,2,10,10,10,1", "Bold": "1", }, } assFile.Styles = append(assFile.Styles, boldStyle) // Add two dialogue events event1 := model.NewASSEvent() event1.StartTime = model.Timestamp{Seconds: 1} event1.EndTime = model.Timestamp{Seconds: 4} event1.Text = "This is the first line." event2 := model.NewASSEvent() event2.StartTime = model.Timestamp{Seconds: 5} event2.EndTime = model.Timestamp{Seconds: 8} event2.Style = "Bold" event2.Text = "This is the second line with bold style." assFile.Events = append(assFile.Events, event1, event2) // Generate ASS file tempDir := t.TempDir() outputFile := filepath.Join(tempDir, "output.ass") err := Generate(assFile, outputFile) if err != nil { t.Fatalf("Generation failed: %v", err) } // Verify generated content content, err := os.ReadFile(outputFile) if err != nil { t.Fatalf("Failed to read output file: %v", err) } contentStr := string(content) // Check script info if !strings.Contains(contentStr, "Title: Generation Test") { t.Errorf("Output file should contain title 'Title: Generation Test'") } // Check styles if !strings.Contains(contentStr, "Style: Bold,Arial,20") { t.Errorf("Output file should contain Bold style") } // Check dialogue lines if !strings.Contains(contentStr, "Dialogue: 0,0:00:01.00,0:00:04.00,Default") { t.Errorf("Output file should contain first dialogue line") } if !strings.Contains(contentStr, "This is the first line.") { t.Errorf("Output file should contain first line text") } if !strings.Contains(contentStr, "Dialogue: 0,0:00:05.00,0:00:08.00,Bold") { t.Errorf("Output file should contain second dialogue line") } if !strings.Contains(contentStr, "This is the second line with bold style.") { t.Errorf("Output file should contain second line text") } } func TestFormat(t *testing.T) { // Create test file (intentionally with mixed formatting) content := `[Script Info] ScriptType:v4.00+ Title: Formatting Test [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:0:1.0,0:0:4.0,Default,,0,0,0,,Text before formatting. ` tempDir := t.TempDir() testFile := filepath.Join(tempDir, "format_test.ass") if err := os.WriteFile(testFile, []byte(content), 0644); err != nil { t.Fatalf("Failed to create test file: %v", err) } // Test formatting err := Format(testFile) if err != nil { t.Fatalf("Formatting failed: %v", err) } // Verify formatted file formattedContent, err := os.ReadFile(testFile) if err != nil { t.Fatalf("Failed to read formatted file: %v", err) } formattedStr := string(formattedContent) // Check formatting if !strings.Contains(formattedStr, "ScriptType: v4.00+") { t.Errorf("Formatted file should contain standardized ScriptType line") } if !strings.Contains(formattedStr, "Title: Formatting Test") { t.Errorf("Formatted file should contain standardized Title line") } // Check timestamp formatting if !strings.Contains(formattedStr, "0:00:01.00,0:00:04.00") { t.Errorf("Formatted file should contain standardized timestamp format (0:00:01.00,0:00:04.00)") } } func TestConvertToSubtitle(t *testing.T) { // Create test file content := `[Script Info] ScriptType: v4.00+ Title: Conversion Test [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 Style: Bold,Arial,20,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,1,0,0,0,100,100,0,0,1,2,2,2,10,10,10,1 Style: Italic,Arial,20,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,1,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,,Normal text. Dialogue: 0,0:00:05.00,0:00:08.00,Bold,,0,0,0,,Bold text. Dialogue: 0,0:00:09.00,0:00:12.00,Italic,,0,0,0,,Italic text. ` tempDir := t.TempDir() testFile := filepath.Join(tempDir, "convert_test.ass") if err := os.WriteFile(testFile, []byte(content), 0644); err != nil { t.Fatalf("Failed to create test file: %v", err) } // Convert to subtitle subtitle, err := ConvertToSubtitle(testFile) if err != nil { t.Fatalf("Conversion failed: %v", err) } // Verify results if subtitle.Format != "ass" { t.Errorf("Expected format 'ass', got '%s'", subtitle.Format) } if subtitle.Metadata["Title"] != "Conversion Test" { t.Errorf("Expected title 'Conversion Test', got '%s'", subtitle.Metadata["Title"]) } if len(subtitle.Entries) != 3 { t.Errorf("Expected 3 entries, got %d", len(subtitle.Entries)) } else { // Check first entry if subtitle.Entries[0].Text != "Normal text." { t.Errorf("First entry text mismatch: expected 'Normal text.', got '%s'", subtitle.Entries[0].Text) } // Check second entry (bold) if subtitle.Entries[1].Text != "Bold text." { t.Errorf("Second entry text mismatch: expected 'Bold text.', got '%s'", subtitle.Entries[1].Text) } bold, ok := subtitle.Entries[1].Styles["bold"] if !ok || bold != "true" { t.Errorf("Second entry should have bold=true style") } // Check third entry (italic) if subtitle.Entries[2].Text != "Italic text." { t.Errorf("Third entry text mismatch: expected 'Italic text.', got '%s'", subtitle.Entries[2].Text) } italic, ok := subtitle.Entries[2].Styles["italic"] if !ok || italic != "true" { t.Errorf("Third entry should have italic=true style") } } } func TestConvertFromSubtitle(t *testing.T) { // Create test subtitle subtitle := model.NewSubtitle() subtitle.Format = "ass" subtitle.Title = "Conversion from Subtitle Test" // Create a normal entry entry1 := model.NewSubtitleEntry() entry1.Index = 1 entry1.StartTime = model.Timestamp{Seconds: 1} entry1.EndTime = model.Timestamp{Seconds: 4} entry1.Text = "Normal text." // Create a bold entry entry2 := model.NewSubtitleEntry() entry2.Index = 2 entry2.StartTime = model.Timestamp{Seconds: 5} entry2.EndTime = model.Timestamp{Seconds: 8} entry2.Text = "Bold text." entry2.Styles["bold"] = "true" // Create an italic entry entry3 := model.NewSubtitleEntry() entry3.Index = 3 entry3.StartTime = model.Timestamp{Seconds: 9} entry3.EndTime = model.Timestamp{Seconds: 12} entry3.Text = "Italic text." entry3.Styles["italic"] = "true" subtitle.Entries = append(subtitle.Entries, entry1, entry2, entry3) // Convert from subtitle tempDir := t.TempDir() outputFile := filepath.Join(tempDir, "convert_from_subtitle.ass") err := ConvertFromSubtitle(subtitle, outputFile) if err != nil { t.Fatalf("Conversion failed: %v", err) } // Verify converted ASS file assFile, err := Parse(outputFile) if err != nil { t.Fatalf("Failed to parse converted file: %v", err) } // Check script info if assFile.ScriptInfo["Title"] != "Conversion from Subtitle Test" { t.Errorf("Expected title 'Conversion from Subtitle Test', got '%s'", assFile.ScriptInfo["Title"]) } // Check events if len(assFile.Events) != 3 { t.Errorf("Expected 3 events, got %d", len(assFile.Events)) } else { // Check first dialogue line if assFile.Events[0].Text != "Normal text." { t.Errorf("First event text mismatch: expected 'Normal text.', got '%s'", assFile.Events[0].Text) } // Check second dialogue line (bold) if assFile.Events[1].Text != "Bold text." { t.Errorf("Second event text mismatch: expected 'Bold text.', got '%s'", assFile.Events[1].Text) } if assFile.Events[1].Style != "Bold" { t.Errorf("Second event should use Bold style, got '%s'", assFile.Events[1].Style) } // Check third dialogue line (italic) if assFile.Events[2].Text != "Italic text." { t.Errorf("Third event text mismatch: expected 'Italic text.', got '%s'", assFile.Events[2].Text) } if assFile.Events[2].Style != "Italic" { t.Errorf("Third event should use Italic style, got '%s'", assFile.Events[2].Style) } } // Check styles styleNames := make(map[string]bool) for _, style := range assFile.Styles { styleNames[style.Name] = true } if !styleNames["Bold"] { t.Errorf("Should contain Bold style") } if !styleNames["Italic"] { t.Errorf("Should contain Italic style") } } func TestParse_EdgeCases(t *testing.T) { // Test empty file tempDir := t.TempDir() emptyFile := filepath.Join(tempDir, "empty.ass") if err := os.WriteFile(emptyFile, []byte{}, 0644); err != nil { t.Fatalf("Failed to create empty test file: %v", err) } assFile, err := Parse(emptyFile) if err != nil { t.Fatalf("Failed to parse empty file: %v", err) } if len(assFile.Events) != 0 { t.Errorf("Empty file should have 0 events, got %d", len(assFile.Events)) } // Test file missing required sections malformedContent := `[Script Info] Title: Missing Sections Test ` malformedFile := filepath.Join(tempDir, "malformed.ass") if err := os.WriteFile(malformedFile, []byte(malformedContent), 0644); err != nil { t.Fatalf("Failed to create malformed file: %v", err) } assFile, err = Parse(malformedFile) if err != nil { t.Fatalf("Failed to parse malformed file: %v", err) } if assFile.ScriptInfo["Title"] != "Missing Sections Test" { t.Errorf("Should correctly parse the title") } if len(assFile.Events) != 0 { t.Errorf("File missing Events section should have 0 events") } } func TestParse_FileError(t *testing.T) { // Test non-existent file _, err := Parse("/nonexistent/file.ass") if err == nil { t.Error("Parsing non-existent file should return an error") } } func TestGenerate_FileError(t *testing.T) { // Test invalid path assFile := model.NewASSFile() err := Generate(assFile, "/nonexistent/directory/file.ass") if err == nil { t.Error("Generating to invalid path should return an error") } } func TestConvertToSubtitle_FileError(t *testing.T) { // Test non-existent file _, err := ConvertToSubtitle("/nonexistent/file.ass") if err == nil { t.Error("Converting non-existent file should return an error") } } func TestConvertFromSubtitle_FileError(t *testing.T) { // Test invalid path subtitle := model.NewSubtitle() err := ConvertFromSubtitle(subtitle, "/nonexistent/directory/file.ass") if err == nil { t.Error("Converting to invalid path should return an error") } } func TestParseASSTimestamp(t *testing.T) { testCases := []struct { name string input string expected model.Timestamp }{ { name: "Standard format", input: "0:00:01.00", expected: model.Timestamp{Hours: 0, Minutes: 0, Seconds: 1, Milliseconds: 0}, }, { name: "With centiseconds", input: "0:00:01.50", expected: model.Timestamp{Hours: 0, Minutes: 0, Seconds: 1, Milliseconds: 500}, }, { name: "Complete hours, minutes, seconds", input: "1:02:03.45", expected: model.Timestamp{Hours: 1, Minutes: 2, Seconds: 3, Milliseconds: 450}, }, { name: "Invalid format", input: "invalid", expected: model.Timestamp{}, }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { result := parseASSTimestamp(tc.input) if result != tc.expected { t.Errorf("For input '%s', expected %+v, got %+v", tc.input, tc.expected, result) } }) } } func TestFormatASSTimestamp(t *testing.T) { testCases := []struct { name string input model.Timestamp expected string }{ { name: "Zero timestamp", input: model.Timestamp{}, expected: "0:00:00.00", }, { name: "Simple seconds", input: model.Timestamp{Hours: 0, Minutes: 0, Seconds: 1, Milliseconds: 0}, expected: "0:00:01.00", }, { name: "With milliseconds", input: model.Timestamp{Hours: 0, Minutes: 0, Seconds: 1, Milliseconds: 500}, expected: "0:00:01.50", }, { name: "Complete timestamp", input: model.Timestamp{Hours: 1, Minutes: 2, Seconds: 3, Milliseconds: 450}, expected: "1:02:03.45", }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { result := formatASSTimestamp(tc.input) if result != tc.expected { t.Errorf("For timestamp %+v, expected '%s', got '%s'", tc.input, tc.expected, result) } }) } }