feat: add tests
This commit is contained in:
parent
44c7e9bee5
commit
bb87f058f0
17 changed files with 4436 additions and 80 deletions
249
internal/converter/converter_test.go
Normal file
249
internal/converter/converter_test.go
Normal file
|
@ -0,0 +1,249 @@
|
|||
package converter
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestConvert(t *testing.T) {
|
||||
// Setup test cases
|
||||
testCases := []struct {
|
||||
name string
|
||||
sourceContent string
|
||||
sourceExt string
|
||||
targetExt string
|
||||
expectedError bool
|
||||
validateOutput func(t *testing.T, filePath string)
|
||||
}{
|
||||
{
|
||||
name: "SRT to VTT",
|
||||
sourceContent: `1
|
||||
00:00:01,000 --> 00:00:04,000
|
||||
This is a test subtitle.
|
||||
|
||||
2
|
||||
00:00:05,000 --> 00:00:08,000
|
||||
This is another test subtitle.
|
||||
`,
|
||||
sourceExt: "srt",
|
||||
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)
|
||||
}
|
||||
|
||||
// Check content
|
||||
contentStr := string(content)
|
||||
if !strings.Contains(contentStr, "WEBVTT") {
|
||||
t.Errorf("Expected output to contain WEBVTT header, got: %s", contentStr)
|
||||
}
|
||||
if !strings.Contains(contentStr, "00:00:01.000 --> 00:00:04.000") {
|
||||
t.Errorf("Expected output to contain correct timestamp, got: %s", contentStr)
|
||||
}
|
||||
if !strings.Contains(contentStr, "This is a test subtitle.") {
|
||||
t.Errorf("Expected output to contain subtitle text, got: %s", contentStr)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "LRC to SRT",
|
||||
sourceContent: `[ti:Test Title]
|
||||
[ar:Test Artist]
|
||||
|
||||
[00:01.00]This is a test lyric.
|
||||
[00:05.00]This is another test lyric.
|
||||
`,
|
||||
sourceExt: "lrc",
|
||||
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)
|
||||
}
|
||||
|
||||
// Check content
|
||||
contentStr := string(content)
|
||||
if !strings.Contains(contentStr, "00:00:01,000 --> ") {
|
||||
t.Errorf("Expected output to contain correct SRT timestamp, got: %s", contentStr)
|
||||
}
|
||||
if !strings.Contains(contentStr, "This is a test lyric.") {
|
||||
t.Errorf("Expected output to contain lyric text, got: %s", contentStr)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "VTT to LRC",
|
||||
sourceContent: `WEBVTT
|
||||
|
||||
1
|
||||
00:00:01.000 --> 00:00:04.000
|
||||
This is a test subtitle.
|
||||
|
||||
2
|
||||
00:00:05.000 --> 00:00:08.000
|
||||
This is another test subtitle.
|
||||
`,
|
||||
sourceExt: "vtt",
|
||||
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)
|
||||
}
|
||||
|
||||
// Check content
|
||||
contentStr := string(content)
|
||||
if !strings.Contains(contentStr, "[00:01.000]") {
|
||||
t.Errorf("Expected output to contain correct LRC timestamp, got: %s", contentStr)
|
||||
}
|
||||
if !strings.Contains(contentStr, "This is a test subtitle.") {
|
||||
t.Errorf("Expected output to contain subtitle text, got: %s", contentStr)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "SRT to TXT",
|
||||
sourceContent: `1
|
||||
00:00:01,000 --> 00:00:04,000
|
||||
This is a test subtitle.
|
||||
|
||||
2
|
||||
00:00:05,000 --> 00:00:08,000
|
||||
This is another test subtitle.
|
||||
`,
|
||||
sourceExt: "srt",
|
||||
targetExt: "txt",
|
||||
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)
|
||||
}
|
||||
|
||||
// Check content
|
||||
contentStr := string(content)
|
||||
if strings.Contains(contentStr, "00:00:01") {
|
||||
t.Errorf("TXT should not contain timestamps, got: %s", contentStr)
|
||||
}
|
||||
if !strings.Contains(contentStr, "This is a test subtitle.") {
|
||||
t.Errorf("Expected output to contain subtitle text, got: %s", contentStr)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "TXT to SRT",
|
||||
sourceContent: "This is a test line.",
|
||||
sourceExt: "txt",
|
||||
targetExt: "srt",
|
||||
expectedError: true,
|
||||
validateOutput: nil, // No validation needed as we expect an error
|
||||
},
|
||||
{
|
||||
name: "Invalid source format",
|
||||
sourceContent: "Random content",
|
||||
sourceExt: "xyz",
|
||||
targetExt: "srt",
|
||||
expectedError: true,
|
||||
validateOutput: nil, // No validation needed as we expect an error
|
||||
},
|
||||
{
|
||||
name: "Invalid target format",
|
||||
sourceContent: `1
|
||||
00:00:01,000 --> 00:00:04,000
|
||||
This is a test subtitle.
|
||||
`,
|
||||
sourceExt: "srt",
|
||||
targetExt: "xyz",
|
||||
expectedError: true,
|
||||
validateOutput: nil, // No validation needed as we expect an error
|
||||
},
|
||||
}
|
||||
|
||||
// Run test cases
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
// Create temporary directory
|
||||
tempDir := t.TempDir()
|
||||
|
||||
// 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 path
|
||||
targetFile := filepath.Join(tempDir, "target."+tc.targetExt)
|
||||
|
||||
// Call Convert
|
||||
err := Convert(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 {
|
||||
tc.validateOutput(t, targetFile)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvert_NonExistentFile(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
sourceFile := filepath.Join(tempDir, "nonexistent.srt")
|
||||
targetFile := filepath.Join(tempDir, "target.vtt")
|
||||
|
||||
err := Convert(sourceFile, targetFile)
|
||||
if err == nil {
|
||||
t.Errorf("Expected error when source file doesn't exist, but got none")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvert_ReadOnlyTarget(t *testing.T) {
|
||||
// This test might not be applicable on all platforms
|
||||
// Skip it if running on a platform where permissions can't be enforced
|
||||
if os.Getenv("SKIP_PERMISSION_TESTS") != "" {
|
||||
t.Skip("Skipping permission test")
|
||||
}
|
||||
|
||||
// Create temporary directory
|
||||
tempDir := t.TempDir()
|
||||
|
||||
// Create source file
|
||||
sourceContent := `1
|
||||
00:00:01,000 --> 00:00:04,000
|
||||
This is a test subtitle.
|
||||
`
|
||||
sourceFile := filepath.Join(tempDir, "source.srt")
|
||||
if err := os.WriteFile(sourceFile, []byte(sourceContent), 0644); err != nil {
|
||||
t.Fatalf("Failed to create source file: %v", err)
|
||||
}
|
||||
|
||||
// Create read-only directory
|
||||
readOnlyDir := filepath.Join(tempDir, "readonly")
|
||||
if err := os.Mkdir(readOnlyDir, 0500); err != nil {
|
||||
t.Fatalf("Failed to create read-only directory: %v", err)
|
||||
}
|
||||
|
||||
// Target in read-only directory
|
||||
targetFile := filepath.Join(readOnlyDir, "target.vtt")
|
||||
|
||||
// Call Convert
|
||||
err := Convert(sourceFile, targetFile)
|
||||
|
||||
// We expect an error due to permissions
|
||||
if err == nil {
|
||||
t.Errorf("Expected error when target is in read-only directory, but got none")
|
||||
}
|
||||
}
|
518
internal/format/lrc/lrc_test.go
Normal file
518
internal/format/lrc/lrc_test.go
Normal file
|
@ -0,0 +1,518 @@
|
|||
package lrc
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"sub-cli/internal/model"
|
||||
)
|
||||
|
||||
func TestParse(t *testing.T) {
|
||||
// Create a temporary test file
|
||||
content := `[ti:Test LRC File]
|
||||
[ar:Test Artist]
|
||||
[al:Test Album]
|
||||
[by:Test Creator]
|
||||
|
||||
[00:01.00]This is the first line.
|
||||
[00:05.00]This is the second line.
|
||||
[00:09.50]This is the third line.
|
||||
`
|
||||
tempDir := t.TempDir()
|
||||
testFile := filepath.Join(tempDir, "test.lrc")
|
||||
if err := os.WriteFile(testFile, []byte(content), 0644); err != nil {
|
||||
t.Fatalf("Failed to create test file: %v", err)
|
||||
}
|
||||
|
||||
// Test parsing
|
||||
lyrics, err := Parse(testFile)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify results
|
||||
if len(lyrics.Timeline) != 3 {
|
||||
t.Errorf("Expected 3 timeline entries, got %d", len(lyrics.Timeline))
|
||||
}
|
||||
|
||||
if len(lyrics.Content) != 3 {
|
||||
t.Errorf("Expected 3 content entries, got %d", len(lyrics.Content))
|
||||
}
|
||||
|
||||
// Check metadata
|
||||
if lyrics.Metadata["ti"] != "Test LRC File" {
|
||||
t.Errorf("Expected title 'Test LRC File', got '%s'", lyrics.Metadata["ti"])
|
||||
}
|
||||
if lyrics.Metadata["ar"] != "Test Artist" {
|
||||
t.Errorf("Expected artist 'Test Artist', got '%s'", lyrics.Metadata["ar"])
|
||||
}
|
||||
if lyrics.Metadata["al"] != "Test Album" {
|
||||
t.Errorf("Expected album 'Test Album', got '%s'", lyrics.Metadata["al"])
|
||||
}
|
||||
if lyrics.Metadata["by"] != "Test Creator" {
|
||||
t.Errorf("Expected creator 'Test Creator', got '%s'", lyrics.Metadata["by"])
|
||||
}
|
||||
|
||||
// Check first timeline entry
|
||||
if lyrics.Timeline[0].Hours != 0 || lyrics.Timeline[0].Minutes != 0 ||
|
||||
lyrics.Timeline[0].Seconds != 1 || lyrics.Timeline[0].Milliseconds != 0 {
|
||||
t.Errorf("First entry time: expected 00:01.00, got %+v", lyrics.Timeline[0])
|
||||
}
|
||||
|
||||
// Check third timeline entry
|
||||
if lyrics.Timeline[2].Hours != 0 || lyrics.Timeline[2].Minutes != 0 ||
|
||||
lyrics.Timeline[2].Seconds != 9 || lyrics.Timeline[2].Milliseconds != 500 {
|
||||
t.Errorf("Third entry time: expected 00:09.50, got %+v", lyrics.Timeline[2])
|
||||
}
|
||||
|
||||
// Check content
|
||||
if lyrics.Content[0] != "This is the first line." {
|
||||
t.Errorf("First entry content: expected 'This is the first line.', got '%s'", lyrics.Content[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerate(t *testing.T) {
|
||||
// Create test lyrics
|
||||
lyrics := model.Lyrics{
|
||||
Metadata: map[string]string{
|
||||
"ti": "Test LRC File",
|
||||
"ar": "Test Artist",
|
||||
},
|
||||
Timeline: []model.Timestamp{
|
||||
{Hours: 0, Minutes: 0, Seconds: 1, Milliseconds: 0},
|
||||
{Hours: 0, Minutes: 0, Seconds: 5, Milliseconds: 0},
|
||||
},
|
||||
Content: []string{
|
||||
"This is the first line.",
|
||||
"This is the second line.",
|
||||
},
|
||||
}
|
||||
|
||||
// Generate LRC file
|
||||
tempDir := t.TempDir()
|
||||
outputFile := filepath.Join(tempDir, "output.lrc")
|
||||
err := Generate(lyrics, outputFile)
|
||||
if err != nil {
|
||||
t.Fatalf("Generate failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify generated content
|
||||
content, err := os.ReadFile(outputFile)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read output file: %v", err)
|
||||
}
|
||||
|
||||
// Check content
|
||||
lines := strings.Split(string(content), "\n")
|
||||
if len(lines) < 4 {
|
||||
t.Fatalf("Expected at least 4 lines, got %d", len(lines))
|
||||
}
|
||||
|
||||
hasTitleLine := false
|
||||
hasFirstTimeline := false
|
||||
|
||||
for _, line := range lines {
|
||||
if line == "[ti:Test LRC File]" {
|
||||
hasTitleLine = true
|
||||
}
|
||||
if line == "[00:01.000]This is the first line." {
|
||||
hasFirstTimeline = true
|
||||
}
|
||||
}
|
||||
|
||||
if !hasTitleLine {
|
||||
t.Errorf("Expected title line '[ti:Test LRC File]' not found")
|
||||
}
|
||||
|
||||
if !hasFirstTimeline {
|
||||
t.Errorf("Expected timeline line '[00:01.000]This is the first line.' not found")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertToSubtitle(t *testing.T) {
|
||||
// Create a temporary test file
|
||||
content := `[ti:Test LRC File]
|
||||
[ar:Test Artist]
|
||||
|
||||
[00:01.00]This is the first line.
|
||||
[00:05.00]This is the second line.
|
||||
`
|
||||
tempDir := t.TempDir()
|
||||
testFile := filepath.Join(tempDir, "test.lrc")
|
||||
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("ConvertToSubtitle failed: %v", err)
|
||||
}
|
||||
|
||||
// Check result
|
||||
if subtitle.Format != "lrc" {
|
||||
t.Errorf("Expected format 'lrc', got '%s'", subtitle.Format)
|
||||
}
|
||||
|
||||
if subtitle.Title != "Test LRC File" {
|
||||
t.Errorf("Expected title 'Test LRC File', got '%s'", subtitle.Title)
|
||||
}
|
||||
|
||||
if len(subtitle.Entries) != 2 {
|
||||
t.Errorf("Expected 2 entries, got %d", len(subtitle.Entries))
|
||||
}
|
||||
|
||||
// Check first entry
|
||||
if subtitle.Entries[0].Text != "This is the first line." {
|
||||
t.Errorf("Expected first entry text 'This is the first line.', got '%s'", subtitle.Entries[0].Text)
|
||||
}
|
||||
|
||||
// Check metadata
|
||||
if subtitle.Metadata["ar"] != "Test Artist" {
|
||||
t.Errorf("Expected metadata 'ar' to be 'Test Artist', got '%s'", subtitle.Metadata["ar"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertFromSubtitle(t *testing.T) {
|
||||
// Create test subtitle
|
||||
subtitle := model.NewSubtitle()
|
||||
subtitle.Format = "lrc"
|
||||
subtitle.Title = "Test LRC File"
|
||||
subtitle.Metadata["ar"] = "Test Artist"
|
||||
|
||||
entry1 := model.NewSubtitleEntry()
|
||||
entry1.StartTime = model.Timestamp{Hours: 0, Minutes: 0, Seconds: 1, Milliseconds: 0}
|
||||
entry1.EndTime = model.Timestamp{Hours: 0, Minutes: 0, Seconds: 4, Milliseconds: 0}
|
||||
entry1.Text = "This is the first line."
|
||||
|
||||
entry2 := model.NewSubtitleEntry()
|
||||
entry2.StartTime = model.Timestamp{Hours: 0, Minutes: 0, Seconds: 5, Milliseconds: 0}
|
||||
entry2.EndTime = model.Timestamp{Hours: 0, Minutes: 0, Seconds: 8, Milliseconds: 0}
|
||||
entry2.Text = "This is the second line."
|
||||
|
||||
subtitle.Entries = append(subtitle.Entries, entry1, entry2)
|
||||
|
||||
// Convert from subtitle to LRC
|
||||
tempDir := t.TempDir()
|
||||
outputFile := filepath.Join(tempDir, "output.lrc")
|
||||
err := ConvertFromSubtitle(subtitle, outputFile)
|
||||
if err != nil {
|
||||
t.Fatalf("ConvertFromSubtitle failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify by parsing back
|
||||
lyrics, err := Parse(outputFile)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to parse output file: %v", err)
|
||||
}
|
||||
|
||||
if len(lyrics.Timeline) != 2 {
|
||||
t.Errorf("Expected 2 entries, got %d", len(lyrics.Timeline))
|
||||
}
|
||||
|
||||
if lyrics.Content[0] != "This is the first line." {
|
||||
t.Errorf("Expected first entry content 'This is the first line.', got '%s'", lyrics.Content[0])
|
||||
}
|
||||
|
||||
if lyrics.Metadata["ti"] != "Test LRC File" {
|
||||
t.Errorf("Expected title metadata 'Test LRC File', got '%s'", lyrics.Metadata["ti"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormat(t *testing.T) {
|
||||
// Create test LRC file with inconsistent timestamp formatting
|
||||
content := `[ti:Test LRC File]
|
||||
[ar:Test Artist]
|
||||
|
||||
[0:1.0]This is the first line.
|
||||
[0:5]This is the second line.
|
||||
`
|
||||
tempDir := t.TempDir()
|
||||
testFile := filepath.Join(tempDir, "test.lrc")
|
||||
if err := os.WriteFile(testFile, []byte(content), 0644); err != nil {
|
||||
t.Fatalf("Failed to create test file: %v", err)
|
||||
}
|
||||
|
||||
// Format the file
|
||||
err := Format(testFile)
|
||||
if err != nil {
|
||||
t.Fatalf("Format failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify by parsing back
|
||||
lyrics, err := Parse(testFile)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to parse formatted file: %v", err)
|
||||
}
|
||||
|
||||
// Check that timestamps are formatted correctly
|
||||
if lyrics.Timeline[0].Seconds != 1 || lyrics.Timeline[0].Milliseconds != 0 {
|
||||
t.Errorf("Expected first timestamp to be 00:01.000, got %+v", lyrics.Timeline[0])
|
||||
}
|
||||
|
||||
// Verify metadata is preserved
|
||||
if lyrics.Metadata["ti"] != "Test LRC File" {
|
||||
t.Errorf("Expected title 'Test LRC File', got '%s'", lyrics.Metadata["ti"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTimestamp(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
input string
|
||||
expected model.Timestamp
|
||||
hasError bool
|
||||
}{
|
||||
{
|
||||
name: "Simple minute and second",
|
||||
input: "01:30",
|
||||
expected: model.Timestamp{Hours: 0, Minutes: 1, Seconds: 30, Milliseconds: 0},
|
||||
hasError: false,
|
||||
},
|
||||
{
|
||||
name: "With milliseconds (1 digit)",
|
||||
input: "01:30.5",
|
||||
expected: model.Timestamp{Hours: 0, Minutes: 1, Seconds: 30, Milliseconds: 500},
|
||||
hasError: false,
|
||||
},
|
||||
{
|
||||
name: "With milliseconds (2 digits)",
|
||||
input: "01:30.75",
|
||||
expected: model.Timestamp{Hours: 0, Minutes: 1, Seconds: 30, Milliseconds: 750},
|
||||
hasError: false,
|
||||
},
|
||||
{
|
||||
name: "With milliseconds (3 digits)",
|
||||
input: "01:30.123",
|
||||
expected: model.Timestamp{Hours: 0, Minutes: 1, Seconds: 30, Milliseconds: 123},
|
||||
hasError: false,
|
||||
},
|
||||
{
|
||||
name: "With hours, minutes, seconds",
|
||||
input: "01:30:45",
|
||||
expected: model.Timestamp{Hours: 1, Minutes: 30, Seconds: 45, Milliseconds: 0},
|
||||
hasError: false,
|
||||
},
|
||||
{
|
||||
name: "With hours, minutes, seconds and milliseconds",
|
||||
input: "01:30:45.5",
|
||||
expected: model.Timestamp{Hours: 1, Minutes: 30, Seconds: 45, Milliseconds: 500},
|
||||
hasError: false,
|
||||
},
|
||||
{
|
||||
name: "Invalid format (single number)",
|
||||
input: "123",
|
||||
expected: model.Timestamp{},
|
||||
hasError: true,
|
||||
},
|
||||
{
|
||||
name: "Invalid format (too many parts)",
|
||||
input: "01:30:45:67",
|
||||
expected: model.Timestamp{},
|
||||
hasError: true,
|
||||
},
|
||||
{
|
||||
name: "Invalid minute (not a number)",
|
||||
input: "aa:30",
|
||||
expected: model.Timestamp{},
|
||||
hasError: true,
|
||||
},
|
||||
{
|
||||
name: "Invalid second (not a number)",
|
||||
input: "01:bb",
|
||||
expected: model.Timestamp{},
|
||||
hasError: true,
|
||||
},
|
||||
{
|
||||
name: "Invalid millisecond (not a number)",
|
||||
input: "01:30.cc",
|
||||
expected: model.Timestamp{},
|
||||
hasError: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
result, err := ParseTimestamp(tc.input)
|
||||
|
||||
if tc.hasError && err == nil {
|
||||
t.Errorf("Expected error for input '%s', but got none", tc.input)
|
||||
}
|
||||
|
||||
if !tc.hasError && err != nil {
|
||||
t.Errorf("Unexpected error for input '%s': %v", tc.input, err)
|
||||
}
|
||||
|
||||
if !tc.hasError && result != tc.expected {
|
||||
t.Errorf("For input '%s', expected %+v, got %+v", tc.input, tc.expected, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParse_FileErrors(t *testing.T) {
|
||||
// Test with non-existent file
|
||||
_, err := Parse("/nonexistent/file.lrc")
|
||||
if err == nil {
|
||||
t.Error("Expected error when parsing non-existent file, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParse_EdgeCases(t *testing.T) {
|
||||
// Test with empty file
|
||||
tempDir := t.TempDir()
|
||||
emptyFile := filepath.Join(tempDir, "empty.lrc")
|
||||
if err := os.WriteFile(emptyFile, []byte{}, 0644); err != nil {
|
||||
t.Fatalf("Failed to create empty test file: %v", err)
|
||||
}
|
||||
|
||||
lyrics, err := Parse(emptyFile)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse failed on empty file: %v", err)
|
||||
}
|
||||
|
||||
if len(lyrics.Timeline) != 0 || len(lyrics.Content) != 0 {
|
||||
t.Errorf("Expected empty lyrics for empty file, got %d timeline entries and %d content entries",
|
||||
len(lyrics.Timeline), len(lyrics.Content))
|
||||
}
|
||||
|
||||
// Test with invalid timestamps
|
||||
invalidFile := filepath.Join(tempDir, "invalid.lrc")
|
||||
content := `[ti:Test LRC File]
|
||||
[ar:Test Artist]
|
||||
|
||||
[invalidtime]This should be ignored.
|
||||
[00:01.00]This is a valid line.
|
||||
`
|
||||
if err := os.WriteFile(invalidFile, []byte(content), 0644); err != nil {
|
||||
t.Fatalf("Failed to create invalid test file: %v", err)
|
||||
}
|
||||
|
||||
lyrics, err = Parse(invalidFile)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse failed on file with invalid timestamps: %v", err)
|
||||
}
|
||||
|
||||
if len(lyrics.Timeline) != 1 || len(lyrics.Content) != 1 {
|
||||
t.Errorf("Expected 1 valid timeline entry, got %d timeline entries and %d content entries",
|
||||
len(lyrics.Timeline), len(lyrics.Content))
|
||||
}
|
||||
|
||||
// Test with timestamp-only lines (no content)
|
||||
timestampOnlyFile := filepath.Join(tempDir, "timestamp_only.lrc")
|
||||
content = `[ti:Test LRC File]
|
||||
[ar:Test Artist]
|
||||
|
||||
[00:01.00]
|
||||
[00:05.00]This has content.
|
||||
`
|
||||
if err := os.WriteFile(timestampOnlyFile, []byte(content), 0644); err != nil {
|
||||
t.Fatalf("Failed to create timestamp-only test file: %v", err)
|
||||
}
|
||||
|
||||
lyrics, err = Parse(timestampOnlyFile)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse failed on file with timestamp-only lines: %v", err)
|
||||
}
|
||||
|
||||
if len(lyrics.Timeline) != 1 || len(lyrics.Content) != 1 {
|
||||
t.Errorf("Expected 1 valid entry (ignoring empty content), got %d timeline entries and %d content entries",
|
||||
len(lyrics.Timeline), len(lyrics.Content))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerate_FileError(t *testing.T) {
|
||||
// Create test lyrics
|
||||
lyrics := model.Lyrics{
|
||||
Metadata: map[string]string{
|
||||
"ti": "Test LRC File",
|
||||
},
|
||||
Timeline: []model.Timestamp{
|
||||
{Hours: 0, Minutes: 0, Seconds: 1, Milliseconds: 0},
|
||||
},
|
||||
Content: []string{
|
||||
"This is a test line.",
|
||||
},
|
||||
}
|
||||
|
||||
// Test with invalid path
|
||||
err := Generate(lyrics, "/nonexistent/directory/file.lrc")
|
||||
if err == nil {
|
||||
t.Error("Expected error when generating to invalid path, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormat_FileError(t *testing.T) {
|
||||
// Test with non-existent file
|
||||
err := Format("/nonexistent/file.lrc")
|
||||
if err == nil {
|
||||
t.Error("Expected error when formatting non-existent file, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertToSubtitle_FileError(t *testing.T) {
|
||||
// Test with non-existent file
|
||||
_, err := ConvertToSubtitle("/nonexistent/file.lrc")
|
||||
if err == nil {
|
||||
t.Error("Expected error when converting non-existent file, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertToSubtitle_EdgeCases(t *testing.T) {
|
||||
// Test with empty lyrics (no content/timeline)
|
||||
tempDir := t.TempDir()
|
||||
emptyLyricsFile := filepath.Join(tempDir, "empty_lyrics.lrc")
|
||||
content := `[ti:Test LRC File]
|
||||
[ar:Test Artist]
|
||||
`
|
||||
if err := os.WriteFile(emptyLyricsFile, []byte(content), 0644); err != nil {
|
||||
t.Fatalf("Failed to create empty lyrics test file: %v", err)
|
||||
}
|
||||
|
||||
subtitle, err := ConvertToSubtitle(emptyLyricsFile)
|
||||
if err != nil {
|
||||
t.Fatalf("ConvertToSubtitle failed on empty lyrics: %v", err)
|
||||
}
|
||||
|
||||
if len(subtitle.Entries) != 0 {
|
||||
t.Errorf("Expected 0 entries for empty lyrics, got %d", len(subtitle.Entries))
|
||||
}
|
||||
|
||||
if subtitle.Title != "Test LRC File" {
|
||||
t.Errorf("Expected title 'Test LRC File', got '%s'", subtitle.Title)
|
||||
}
|
||||
|
||||
// Test with more content than timeline entries
|
||||
moreContentFile := filepath.Join(tempDir, "more_content.lrc")
|
||||
content = `[ti:Test LRC File]
|
||||
|
||||
[00:01.00]This has a timestamp.
|
||||
This doesn't have a timestamp but is content.
|
||||
`
|
||||
if err := os.WriteFile(moreContentFile, []byte(content), 0644); err != nil {
|
||||
t.Fatalf("Failed to create more content test file: %v", err)
|
||||
}
|
||||
|
||||
subtitle, err = ConvertToSubtitle(moreContentFile)
|
||||
if err != nil {
|
||||
t.Fatalf("ConvertToSubtitle failed on file with more content than timestamps: %v", err)
|
||||
}
|
||||
|
||||
if len(subtitle.Entries) != 1 {
|
||||
t.Errorf("Expected 1 entry (only the one with timestamp), got %d", len(subtitle.Entries))
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertFromSubtitle_FileError(t *testing.T) {
|
||||
// Create simple subtitle
|
||||
subtitle := model.NewSubtitle()
|
||||
subtitle.Entries = append(subtitle.Entries, model.NewSubtitleEntry())
|
||||
|
||||
// Test with invalid path
|
||||
err := ConvertFromSubtitle(subtitle, "/nonexistent/directory/file.lrc")
|
||||
if err == nil {
|
||||
t.Error("Expected error when converting to invalid path, got nil")
|
||||
}
|
||||
}
|
646
internal/format/srt/srt_test.go
Normal file
646
internal/format/srt/srt_test.go
Normal file
|
@ -0,0 +1,646 @@
|
|||
package srt
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"sub-cli/internal/model"
|
||||
)
|
||||
|
||||
func TestParse(t *testing.T) {
|
||||
// Create a temporary test file
|
||||
content := `1
|
||||
00:00:01,000 --> 00:00:04,000
|
||||
This is the first line.
|
||||
|
||||
2
|
||||
00:00:05,000 --> 00:00:08,000
|
||||
This is the second line.
|
||||
|
||||
3
|
||||
00:00:09,500 --> 00:00:12,800
|
||||
This is the third line
|
||||
with a line break.
|
||||
`
|
||||
tempDir := t.TempDir()
|
||||
testFile := filepath.Join(tempDir, "test.srt")
|
||||
if err := os.WriteFile(testFile, []byte(content), 0644); err != nil {
|
||||
t.Fatalf("Failed to create test file: %v", err)
|
||||
}
|
||||
|
||||
// Test parsing
|
||||
entries, err := Parse(testFile)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify results
|
||||
if len(entries) != 3 {
|
||||
t.Errorf("Expected 3 entries, got %d", len(entries))
|
||||
}
|
||||
|
||||
// Check first entry
|
||||
if entries[0].Number != 1 {
|
||||
t.Errorf("First entry number: expected 1, got %d", entries[0].Number)
|
||||
}
|
||||
if entries[0].StartTime.Hours != 0 || entries[0].StartTime.Minutes != 0 ||
|
||||
entries[0].StartTime.Seconds != 1 || entries[0].StartTime.Milliseconds != 0 {
|
||||
t.Errorf("First entry start time: expected 00:00:01,000, got %+v", entries[0].StartTime)
|
||||
}
|
||||
if entries[0].EndTime.Hours != 0 || entries[0].EndTime.Minutes != 0 ||
|
||||
entries[0].EndTime.Seconds != 4 || entries[0].EndTime.Milliseconds != 0 {
|
||||
t.Errorf("First entry end time: expected 00:00:04,000, got %+v", entries[0].EndTime)
|
||||
}
|
||||
if entries[0].Content != "This is the first line." {
|
||||
t.Errorf("First entry content: expected 'This is the first line.', got '%s'", entries[0].Content)
|
||||
}
|
||||
|
||||
// Check third entry
|
||||
if entries[2].Number != 3 {
|
||||
t.Errorf("Third entry number: expected 3, got %d", entries[2].Number)
|
||||
}
|
||||
expectedContent := "This is the third line\nwith a line break."
|
||||
if entries[2].Content != expectedContent {
|
||||
t.Errorf("Third entry content: expected '%s', got '%s'", expectedContent, entries[2].Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerate(t *testing.T) {
|
||||
// Create test entries
|
||||
entries := []model.SRTEntry{
|
||||
{
|
||||
Number: 1,
|
||||
StartTime: model.Timestamp{Hours: 0, Minutes: 0, Seconds: 1, Milliseconds: 0},
|
||||
EndTime: model.Timestamp{Hours: 0, Minutes: 0, Seconds: 4, Milliseconds: 0},
|
||||
Content: "This is the first line.",
|
||||
},
|
||||
{
|
||||
Number: 2,
|
||||
StartTime: model.Timestamp{Hours: 0, Minutes: 0, Seconds: 5, Milliseconds: 0},
|
||||
EndTime: model.Timestamp{Hours: 0, Minutes: 0, Seconds: 8, Milliseconds: 0},
|
||||
Content: "This is the second line.",
|
||||
},
|
||||
}
|
||||
|
||||
// Generate SRT file
|
||||
tempDir := t.TempDir()
|
||||
outputFile := filepath.Join(tempDir, "output.srt")
|
||||
err := Generate(entries, outputFile)
|
||||
if err != nil {
|
||||
t.Fatalf("Generate failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify generated content
|
||||
content, err := os.ReadFile(outputFile)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read output file: %v", err)
|
||||
}
|
||||
|
||||
// Check content
|
||||
lines := strings.Split(string(content), "\n")
|
||||
if len(lines) < 6 {
|
||||
t.Fatalf("Expected at least 6 lines, got %d", len(lines))
|
||||
}
|
||||
|
||||
if lines[0] != "1" {
|
||||
t.Errorf("Expected first line to be '1', got '%s'", lines[0])
|
||||
}
|
||||
|
||||
if lines[1] != "00:00:01,000 --> 00:00:04,000" {
|
||||
t.Errorf("Expected second line to be time range, got '%s'", lines[1])
|
||||
}
|
||||
|
||||
if lines[2] != "This is the first line." {
|
||||
t.Errorf("Expected third line to be content, got '%s'", lines[2])
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertToSubtitle(t *testing.T) {
|
||||
// Create a temporary test file
|
||||
content := `1
|
||||
00:00:01,000 --> 00:00:04,000
|
||||
This is the first line.
|
||||
|
||||
2
|
||||
00:00:05,000 --> 00:00:08,000
|
||||
This is the second line.
|
||||
`
|
||||
tempDir := t.TempDir()
|
||||
testFile := filepath.Join(tempDir, "test.srt")
|
||||
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("ConvertToSubtitle failed: %v", err)
|
||||
}
|
||||
|
||||
// Check result
|
||||
if subtitle.Format != "srt" {
|
||||
t.Errorf("Expected format 'srt', got '%s'", subtitle.Format)
|
||||
}
|
||||
|
||||
if len(subtitle.Entries) != 2 {
|
||||
t.Errorf("Expected 2 entries, got %d", len(subtitle.Entries))
|
||||
}
|
||||
|
||||
// Check first entry
|
||||
if subtitle.Entries[0].Text != "This is the first line." {
|
||||
t.Errorf("Expected first entry text 'This is the first line.', got '%s'", subtitle.Entries[0].Text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertFromSubtitle(t *testing.T) {
|
||||
// Create test subtitle
|
||||
subtitle := model.NewSubtitle()
|
||||
subtitle.Format = "srt"
|
||||
|
||||
entry1 := model.NewSubtitleEntry()
|
||||
entry1.Index = 1
|
||||
entry1.StartTime = model.Timestamp{Hours: 0, Minutes: 0, Seconds: 1, Milliseconds: 0}
|
||||
entry1.EndTime = model.Timestamp{Hours: 0, Minutes: 0, Seconds: 4, Milliseconds: 0}
|
||||
entry1.Text = "This is the first line."
|
||||
|
||||
entry2 := model.NewSubtitleEntry()
|
||||
entry2.Index = 2
|
||||
entry2.StartTime = model.Timestamp{Hours: 0, Minutes: 0, Seconds: 5, Milliseconds: 0}
|
||||
entry2.EndTime = model.Timestamp{Hours: 0, Minutes: 0, Seconds: 8, Milliseconds: 0}
|
||||
entry2.Text = "This is the second line."
|
||||
|
||||
subtitle.Entries = append(subtitle.Entries, entry1, entry2)
|
||||
|
||||
// Convert from subtitle to SRT
|
||||
tempDir := t.TempDir()
|
||||
outputFile := filepath.Join(tempDir, "output.srt")
|
||||
err := ConvertFromSubtitle(subtitle, outputFile)
|
||||
if err != nil {
|
||||
t.Fatalf("ConvertFromSubtitle failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify by parsing back
|
||||
entries, err := Parse(outputFile)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to parse output file: %v", err)
|
||||
}
|
||||
|
||||
if len(entries) != 2 {
|
||||
t.Errorf("Expected 2 entries, got %d", len(entries))
|
||||
}
|
||||
|
||||
if entries[0].Content != "This is the first line." {
|
||||
t.Errorf("Expected first entry content 'This is the first line.', got '%s'", entries[0].Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormat(t *testing.T) {
|
||||
// Create test file with non-sequential numbers
|
||||
content := `2
|
||||
00:00:01,000 --> 00:00:04,000
|
||||
This is the first line.
|
||||
|
||||
5
|
||||
00:00:05,000 --> 00:00:08,000
|
||||
This is the second line.
|
||||
`
|
||||
tempDir := t.TempDir()
|
||||
testFile := filepath.Join(tempDir, "test.srt")
|
||||
if err := os.WriteFile(testFile, []byte(content), 0644); err != nil {
|
||||
t.Fatalf("Failed to create test file: %v", err)
|
||||
}
|
||||
|
||||
// Format the file
|
||||
err := Format(testFile)
|
||||
if err != nil {
|
||||
t.Fatalf("Format failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify by parsing back
|
||||
entries, err := Parse(testFile)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to parse formatted file: %v", err)
|
||||
}
|
||||
|
||||
// Check that numbers are sequential
|
||||
if entries[0].Number != 1 {
|
||||
t.Errorf("Expected first entry number to be 1, got %d", entries[0].Number)
|
||||
}
|
||||
if entries[1].Number != 2 {
|
||||
t.Errorf("Expected second entry number to be 2, got %d", entries[1].Number)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSRTTimestamp(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
input string
|
||||
expected model.Timestamp
|
||||
}{
|
||||
{
|
||||
name: "Standard format",
|
||||
input: "00:00:01,000",
|
||||
expected: model.Timestamp{Hours: 0, Minutes: 0, Seconds: 1, Milliseconds: 0},
|
||||
},
|
||||
{
|
||||
name: "With milliseconds",
|
||||
input: "00:00:01,500",
|
||||
expected: model.Timestamp{Hours: 0, Minutes: 0, Seconds: 1, Milliseconds: 500},
|
||||
},
|
||||
{
|
||||
name: "Full hours, minutes, seconds",
|
||||
input: "01:02:03,456",
|
||||
expected: model.Timestamp{Hours: 1, Minutes: 2, Seconds: 3, Milliseconds: 456},
|
||||
},
|
||||
{
|
||||
name: "With dot instead of comma",
|
||||
input: "00:00:01.000", // Should auto-convert . to ,
|
||||
expected: model.Timestamp{Hours: 0, Minutes: 0, Seconds: 1, Milliseconds: 0},
|
||||
},
|
||||
{
|
||||
name: "Invalid format",
|
||||
input: "invalid",
|
||||
expected: model.Timestamp{}, // Should return zero timestamp
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
result := parseSRTTimestamp(tc.input)
|
||||
if result != tc.expected {
|
||||
t.Errorf("For input '%s', expected %+v, got %+v", tc.input, tc.expected, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatSRTTimestamp(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
input model.Timestamp
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "Zero timestamp",
|
||||
input: model.Timestamp{},
|
||||
expected: "00:00:00,000",
|
||||
},
|
||||
{
|
||||
name: "Simple seconds",
|
||||
input: model.Timestamp{Hours: 0, Minutes: 0, Seconds: 1, Milliseconds: 0},
|
||||
expected: "00:00:01,000",
|
||||
},
|
||||
{
|
||||
name: "With milliseconds",
|
||||
input: model.Timestamp{Hours: 0, Minutes: 0, Seconds: 1, Milliseconds: 500},
|
||||
expected: "00:00:01,500",
|
||||
},
|
||||
{
|
||||
name: "Full timestamp",
|
||||
input: model.Timestamp{Hours: 1, Minutes: 2, Seconds: 3, Milliseconds: 456},
|
||||
expected: "01:02:03,456",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
result := formatSRTTimestamp(tc.input)
|
||||
if result != tc.expected {
|
||||
t.Errorf("For timestamp %+v, expected '%s', got '%s'", tc.input, tc.expected, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsEntryTimeStampUnset(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
entry model.SRTEntry
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "Unset timestamp",
|
||||
entry: model.SRTEntry{Number: 1},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "Set timestamp",
|
||||
entry: model.SRTEntry{
|
||||
Number: 1,
|
||||
StartTime: model.Timestamp{Hours: 0, Minutes: 0, Seconds: 1, Milliseconds: 0},
|
||||
},
|
||||
expected: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
result := isEntryTimeStampUnset(tc.entry)
|
||||
if result != tc.expected {
|
||||
t.Errorf("For entry %+v, expected isEntryTimeStampUnset to be %v, got %v", tc.entry, tc.expected, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertToLyrics(t *testing.T) {
|
||||
// Create test entries
|
||||
entries := []model.SRTEntry{
|
||||
{
|
||||
Number: 1,
|
||||
StartTime: model.Timestamp{Hours: 0, Minutes: 0, Seconds: 1, Milliseconds: 0},
|
||||
EndTime: model.Timestamp{Hours: 0, Minutes: 0, Seconds: 4, Milliseconds: 0},
|
||||
Content: "This is the first line.",
|
||||
},
|
||||
{
|
||||
Number: 2,
|
||||
StartTime: model.Timestamp{Hours: 0, Minutes: 0, Seconds: 5, Milliseconds: 0},
|
||||
EndTime: model.Timestamp{Hours: 0, Minutes: 0, Seconds: 8, Milliseconds: 0},
|
||||
Content: "This is the second line.",
|
||||
},
|
||||
}
|
||||
|
||||
// Convert to lyrics
|
||||
lyrics := ConvertToLyrics(entries)
|
||||
|
||||
// Check result
|
||||
if len(lyrics.Timeline) != 2 {
|
||||
t.Errorf("Expected 2 timeline entries, got %d", len(lyrics.Timeline))
|
||||
}
|
||||
if len(lyrics.Content) != 2 {
|
||||
t.Errorf("Expected 2 content entries, got %d", len(lyrics.Content))
|
||||
}
|
||||
|
||||
// Check timeline entries
|
||||
if lyrics.Timeline[0] != entries[0].StartTime {
|
||||
t.Errorf("First timeline entry: expected %+v, got %+v", entries[0].StartTime, lyrics.Timeline[0])
|
||||
}
|
||||
if lyrics.Timeline[1] != entries[1].StartTime {
|
||||
t.Errorf("Second timeline entry: expected %+v, got %+v", entries[1].StartTime, lyrics.Timeline[1])
|
||||
}
|
||||
|
||||
// Check content entries
|
||||
if lyrics.Content[0] != entries[0].Content {
|
||||
t.Errorf("First content entry: expected '%s', got '%s'", entries[0].Content, lyrics.Content[0])
|
||||
}
|
||||
if lyrics.Content[1] != entries[1].Content {
|
||||
t.Errorf("Second content entry: expected '%s', got '%s'", entries[1].Content, lyrics.Content[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestParse_EdgeCases(t *testing.T) {
|
||||
// Test with empty file
|
||||
tempDir := t.TempDir()
|
||||
emptyFile := filepath.Join(tempDir, "empty.srt")
|
||||
if err := os.WriteFile(emptyFile, []byte{}, 0644); err != nil {
|
||||
t.Fatalf("Failed to create empty test file: %v", err)
|
||||
}
|
||||
|
||||
entries, err := Parse(emptyFile)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse failed on empty file: %v", err)
|
||||
}
|
||||
|
||||
if len(entries) != 0 {
|
||||
t.Errorf("Expected 0 entries for empty file, got %d", len(entries))
|
||||
}
|
||||
|
||||
// Test with malformed file (missing timestamp line)
|
||||
malformedFile := filepath.Join(tempDir, "malformed.srt")
|
||||
content := `1
|
||||
This is missing a timestamp line.
|
||||
|
||||
2
|
||||
00:00:05,000 --> 00:00:08,000
|
||||
This is valid.
|
||||
`
|
||||
if err := os.WriteFile(malformedFile, []byte(content), 0644); err != nil {
|
||||
t.Fatalf("Failed to create malformed test file: %v", err)
|
||||
}
|
||||
|
||||
entries, err = Parse(malformedFile)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse failed on malformed file: %v", err)
|
||||
}
|
||||
|
||||
// SRT解析器更宽容,可能会解析出两个条目
|
||||
if len(entries) != 2 {
|
||||
t.Errorf("Expected 2 entries, got %d", len(entries))
|
||||
}
|
||||
|
||||
// Test with incomplete last entry
|
||||
incompleteFile := filepath.Join(tempDir, "incomplete.srt")
|
||||
content = `1
|
||||
00:00:01,000 --> 00:00:04,000
|
||||
This is complete.
|
||||
|
||||
2
|
||||
00:00:05,000 --> 00:00:08,000
|
||||
`
|
||||
if err := os.WriteFile(incompleteFile, []byte(content), 0644); err != nil {
|
||||
t.Fatalf("Failed to create incomplete test file: %v", err)
|
||||
}
|
||||
|
||||
entries, err = Parse(incompleteFile)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse failed on incomplete file: %v", err)
|
||||
}
|
||||
|
||||
// Should have one complete entry, the incomplete one is discarded due to empty content
|
||||
if len(entries) != 1 {
|
||||
t.Errorf("Expected 1 entry (only the completed one), got %d", len(entries))
|
||||
}
|
||||
}
|
||||
|
||||
func TestParse_FileError(t *testing.T) {
|
||||
// Test with non-existent file
|
||||
_, err := Parse("/nonexistent/file.srt")
|
||||
if err == nil {
|
||||
t.Error("Expected error when parsing non-existent file, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerate_FileError(t *testing.T) {
|
||||
// Create test entries
|
||||
entries := []model.SRTEntry{
|
||||
{
|
||||
Number: 1,
|
||||
StartTime: model.Timestamp{Hours: 0, Minutes: 0, Seconds: 1, Milliseconds: 0},
|
||||
EndTime: model.Timestamp{Hours: 0, Minutes: 0, Seconds: 4, Milliseconds: 0},
|
||||
Content: "This is a test line.",
|
||||
},
|
||||
}
|
||||
|
||||
// Test with invalid path
|
||||
err := Generate(entries, "/nonexistent/directory/file.srt")
|
||||
if err == nil {
|
||||
t.Error("Expected error when generating to invalid path, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormat_FileError(t *testing.T) {
|
||||
// Test with non-existent file
|
||||
err := Format("/nonexistent/file.srt")
|
||||
if err == nil {
|
||||
t.Error("Expected error when formatting non-existent file, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertToSubtitle_WithHTMLTags(t *testing.T) {
|
||||
// Create a temporary test file with HTML tags
|
||||
content := `1
|
||||
00:00:01,000 --> 00:00:04,000
|
||||
<i>This is in italic.</i>
|
||||
|
||||
2
|
||||
00:00:05,000 --> 00:00:08,000
|
||||
<b>This is in bold.</b>
|
||||
|
||||
3
|
||||
00:00:09,000 --> 00:00:12,000
|
||||
<u>This is underlined.</u>
|
||||
`
|
||||
tempDir := t.TempDir()
|
||||
testFile := filepath.Join(tempDir, "styles.srt")
|
||||
if err := os.WriteFile(testFile, []byte(content), 0644); err != nil {
|
||||
t.Fatalf("Failed to create test file with HTML tags: %v", err)
|
||||
}
|
||||
|
||||
// Convert to subtitle
|
||||
subtitle, err := ConvertToSubtitle(testFile)
|
||||
if err != nil {
|
||||
t.Fatalf("ConvertToSubtitle failed: %v", err)
|
||||
}
|
||||
|
||||
// Check if HTML tags were detected
|
||||
if value, ok := subtitle.Entries[0].FormatData["has_html_tags"]; !ok || value != true {
|
||||
t.Errorf("Expected FormatData to contain has_html_tags=true for entry with italic")
|
||||
}
|
||||
if value, ok := subtitle.Entries[0].Styles["italic"]; !ok || value != "true" {
|
||||
t.Errorf("Expected Styles to contain italic=true for entry with <i> tag")
|
||||
}
|
||||
|
||||
if value, ok := subtitle.Entries[1].Styles["bold"]; !ok || value != "true" {
|
||||
t.Errorf("Expected Styles to contain bold=true for entry with <b> tag")
|
||||
}
|
||||
|
||||
if value, ok := subtitle.Entries[2].Styles["underline"]; !ok || value != "true" {
|
||||
t.Errorf("Expected Styles to contain underline=true for entry with <u> tag")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertToSubtitle_FileError(t *testing.T) {
|
||||
// Test with non-existent file
|
||||
_, err := ConvertToSubtitle("/nonexistent/file.srt")
|
||||
if err == nil {
|
||||
t.Error("Expected error when converting non-existent file, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertFromSubtitle_WithStyling(t *testing.T) {
|
||||
// Create a subtitle with style attributes
|
||||
subtitle := model.NewSubtitle()
|
||||
subtitle.Format = "srt"
|
||||
|
||||
// Create an entry with italics
|
||||
entry1 := model.NewSubtitleEntry()
|
||||
entry1.Index = 1
|
||||
entry1.StartTime = model.Timestamp{Hours: 0, Minutes: 0, Seconds: 1, Milliseconds: 0}
|
||||
entry1.EndTime = model.Timestamp{Hours: 0, Minutes: 0, Seconds: 4, Milliseconds: 0}
|
||||
entry1.Text = "This should be italic."
|
||||
entry1.Styles["italic"] = "true"
|
||||
|
||||
// Create an entry with bold
|
||||
entry2 := model.NewSubtitleEntry()
|
||||
entry2.Index = 2
|
||||
entry2.StartTime = model.Timestamp{Hours: 0, Minutes: 0, Seconds: 5, Milliseconds: 0}
|
||||
entry2.EndTime = model.Timestamp{Hours: 0, Minutes: 0, Seconds: 8, Milliseconds: 0}
|
||||
entry2.Text = "This should be bold."
|
||||
entry2.Styles["bold"] = "true"
|
||||
|
||||
// Create an entry with underline
|
||||
entry3 := model.NewSubtitleEntry()
|
||||
entry3.Index = 3
|
||||
entry3.StartTime = model.Timestamp{Hours: 0, Minutes: 0, Seconds: 9, Milliseconds: 0}
|
||||
entry3.EndTime = model.Timestamp{Hours: 0, Minutes: 0, Seconds: 12, Milliseconds: 0}
|
||||
entry3.Text = "This should be underlined."
|
||||
entry3.Styles["underline"] = "true"
|
||||
|
||||
subtitle.Entries = append(subtitle.Entries, entry1, entry2, entry3)
|
||||
|
||||
// Convert from subtitle to SRT
|
||||
tempDir := t.TempDir()
|
||||
outputFile := filepath.Join(tempDir, "styled.srt")
|
||||
err := ConvertFromSubtitle(subtitle, outputFile)
|
||||
if err != nil {
|
||||
t.Fatalf("ConvertFromSubtitle failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify by reading the file directly
|
||||
content, err := os.ReadFile(outputFile)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read output file: %v", err)
|
||||
}
|
||||
|
||||
// Check that HTML tags were applied
|
||||
contentStr := string(content)
|
||||
if !strings.Contains(contentStr, "<i>This should be italic.</i>") {
|
||||
t.Errorf("Expected italic HTML tags to be applied")
|
||||
}
|
||||
if !strings.Contains(contentStr, "<b>This should be bold.</b>") {
|
||||
t.Errorf("Expected bold HTML tags to be applied")
|
||||
}
|
||||
if !strings.Contains(contentStr, "<u>This should be underlined.</u>") {
|
||||
t.Errorf("Expected underline HTML tags to be applied")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertFromSubtitle_FileError(t *testing.T) {
|
||||
// Create simple subtitle
|
||||
subtitle := model.NewSubtitle()
|
||||
subtitle.Entries = append(subtitle.Entries, model.NewSubtitleEntry())
|
||||
|
||||
// Test with invalid path
|
||||
err := ConvertFromSubtitle(subtitle, "/nonexistent/directory/file.srt")
|
||||
if err == nil {
|
||||
t.Error("Expected error when converting to invalid path, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertFromSubtitle_WithExistingHTMLTags(t *testing.T) {
|
||||
// Create a subtitle with text that already contains HTML tags
|
||||
subtitle := model.NewSubtitle()
|
||||
subtitle.Format = "srt"
|
||||
|
||||
// Create an entry with existing italic tags but also style attribute
|
||||
entry := model.NewSubtitleEntry()
|
||||
entry.Index = 1
|
||||
entry.StartTime = model.Timestamp{Hours: 0, Minutes: 0, Seconds: 1, Milliseconds: 0}
|
||||
entry.EndTime = model.Timestamp{Hours: 0, Minutes: 0, Seconds: 4, Milliseconds: 0}
|
||||
entry.Text = "<i>Already italic text.</i>"
|
||||
entry.Styles["italic"] = "true" // Should not double-wrap with <i> tags
|
||||
|
||||
subtitle.Entries = append(subtitle.Entries, entry)
|
||||
|
||||
// Convert from subtitle to SRT
|
||||
tempDir := t.TempDir()
|
||||
outputFile := filepath.Join(tempDir, "existing_tags.srt")
|
||||
err := ConvertFromSubtitle(subtitle, outputFile)
|
||||
if err != nil {
|
||||
t.Fatalf("ConvertFromSubtitle failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify by reading the file directly
|
||||
content, err := os.ReadFile(outputFile)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read output file: %v", err)
|
||||
}
|
||||
|
||||
// Should not have double tags
|
||||
contentStr := string(content)
|
||||
if strings.Contains(contentStr, "<i><i>") {
|
||||
t.Errorf("Expected no duplicate italic tags, but found them")
|
||||
}
|
||||
}
|
145
internal/format/txt/txt_test.go
Normal file
145
internal/format/txt/txt_test.go
Normal file
|
@ -0,0 +1,145 @@
|
|||
package txt
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"sub-cli/internal/model"
|
||||
)
|
||||
|
||||
func TestGenerateFromSubtitle(t *testing.T) {
|
||||
// Create test subtitle
|
||||
subtitle := model.NewSubtitle()
|
||||
|
||||
entry1 := model.NewSubtitleEntry()
|
||||
entry1.Text = "This is the first line."
|
||||
|
||||
entry2 := model.NewSubtitleEntry()
|
||||
entry2.Text = "This is the second line."
|
||||
|
||||
entry3 := model.NewSubtitleEntry()
|
||||
entry3.Text = "This is the third line\nwith a line break."
|
||||
|
||||
subtitle.Entries = append(subtitle.Entries, entry1, entry2, entry3)
|
||||
|
||||
// Generate TXT file
|
||||
tempDir := t.TempDir()
|
||||
outputFile := filepath.Join(tempDir, "output.txt")
|
||||
err := GenerateFromSubtitle(subtitle, outputFile)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateFromSubtitle failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify generated content
|
||||
content, err := os.ReadFile(outputFile)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read output file: %v", err)
|
||||
}
|
||||
|
||||
// Check content
|
||||
lines := strings.Split(string(content), "\n")
|
||||
if len(lines) < 4 { // 3 entries with one having a line break
|
||||
t.Fatalf("Expected at least 4 lines, got %d", len(lines))
|
||||
}
|
||||
|
||||
if lines[0] != "This is the first line." {
|
||||
t.Errorf("Expected first line to be 'This is the first line.', got '%s'", lines[0])
|
||||
}
|
||||
|
||||
if lines[1] != "This is the second line." {
|
||||
t.Errorf("Expected second line to be 'This is the second line.', got '%s'", lines[1])
|
||||
}
|
||||
|
||||
if lines[2] != "This is the third line" {
|
||||
t.Errorf("Expected third line to be 'This is the third line', got '%s'", lines[2])
|
||||
}
|
||||
|
||||
if lines[3] != "with a line break." {
|
||||
t.Errorf("Expected fourth line to be 'with a line break.', got '%s'", lines[3])
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateFromSubtitle_EmptySubtitle(t *testing.T) {
|
||||
// Create empty subtitle
|
||||
subtitle := model.NewSubtitle()
|
||||
|
||||
// Generate TXT file
|
||||
tempDir := t.TempDir()
|
||||
outputFile := filepath.Join(tempDir, "empty.txt")
|
||||
err := GenerateFromSubtitle(subtitle, outputFile)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateFromSubtitle failed with empty subtitle: %v", err)
|
||||
}
|
||||
|
||||
// Verify generated content
|
||||
content, err := os.ReadFile(outputFile)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read output file: %v", err)
|
||||
}
|
||||
|
||||
// Check content is empty
|
||||
if len(content) != 0 {
|
||||
t.Errorf("Expected empty file, got content: %s", string(content))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateFromSubtitle_WithTitle(t *testing.T) {
|
||||
// Create subtitle with title
|
||||
subtitle := model.NewSubtitle()
|
||||
subtitle.Title = "My Test Title"
|
||||
|
||||
entry1 := model.NewSubtitleEntry()
|
||||
entry1.Text = "This is a test line."
|
||||
subtitle.Entries = append(subtitle.Entries, entry1)
|
||||
|
||||
// Generate TXT file
|
||||
tempDir := t.TempDir()
|
||||
outputFile := filepath.Join(tempDir, "titled.txt")
|
||||
err := GenerateFromSubtitle(subtitle, outputFile)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateFromSubtitle failed with titled subtitle: %v", err)
|
||||
}
|
||||
|
||||
// Verify generated content
|
||||
content, err := os.ReadFile(outputFile)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read output file: %v", err)
|
||||
}
|
||||
|
||||
// Check content has title and proper formatting
|
||||
lines := strings.Split(string(content), "\n")
|
||||
if len(lines) < 3 { // Title + blank line + content
|
||||
t.Fatalf("Expected at least 3 lines, got %d", len(lines))
|
||||
}
|
||||
|
||||
if lines[0] != "My Test Title" {
|
||||
t.Errorf("Expected first line to be title, got '%s'", lines[0])
|
||||
}
|
||||
|
||||
if lines[1] != "" {
|
||||
t.Errorf("Expected second line to be blank, got '%s'", lines[1])
|
||||
}
|
||||
|
||||
if lines[2] != "This is a test line." {
|
||||
t.Errorf("Expected third line to be content, got '%s'", lines[2])
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateFromSubtitle_FileError(t *testing.T) {
|
||||
// Create test subtitle
|
||||
subtitle := model.NewSubtitle()
|
||||
entry1 := model.NewSubtitleEntry()
|
||||
entry1.Text = "Test line"
|
||||
subtitle.Entries = append(subtitle.Entries, entry1)
|
||||
|
||||
// Test with invalid file path
|
||||
invalidPath := "/nonexistent/directory/file.txt"
|
||||
err := GenerateFromSubtitle(subtitle, invalidPath)
|
||||
|
||||
// Verify error is returned
|
||||
if err == nil {
|
||||
t.Errorf("Expected error for invalid file path, got nil")
|
||||
}
|
||||
}
|
|
@ -21,6 +21,11 @@ const (
|
|||
func Parse(filePath string) (model.Subtitle, error) {
|
||||
subtitle := model.NewSubtitle()
|
||||
subtitle.Format = "vtt"
|
||||
|
||||
// Ensure maps are initialized
|
||||
if subtitle.Styles == nil {
|
||||
subtitle.Styles = make(map[string]string)
|
||||
}
|
||||
|
||||
file, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
|
@ -29,15 +34,15 @@ func Parse(filePath string) (model.Subtitle, error) {
|
|||
defer file.Close()
|
||||
|
||||
scanner := bufio.NewScanner(file)
|
||||
|
||||
// Check header
|
||||
|
||||
// First line must be WEBVTT
|
||||
if !scanner.Scan() {
|
||||
return subtitle, fmt.Errorf("empty VTT file")
|
||||
}
|
||||
|
||||
header := strings.TrimSpace(scanner.Text())
|
||||
|
||||
header := scanner.Text()
|
||||
if !strings.HasPrefix(header, VTTHeader) {
|
||||
return subtitle, fmt.Errorf("invalid VTT file: missing WEBVTT header")
|
||||
return subtitle, fmt.Errorf("invalid VTT file, missing WEBVTT header")
|
||||
}
|
||||
|
||||
// Get metadata from header
|
||||
|
@ -52,24 +57,13 @@ func Parse(filePath string) (model.Subtitle, error) {
|
|||
var styleBuffer strings.Builder
|
||||
var cueTextBuffer strings.Builder
|
||||
|
||||
lineNum := 1
|
||||
lineNum := 0
|
||||
prevLine := ""
|
||||
|
||||
for scanner.Scan() {
|
||||
lineNum++
|
||||
line := scanner.Text()
|
||||
|
||||
// Skip empty lines
|
||||
if strings.TrimSpace(line) == "" {
|
||||
if inCue {
|
||||
// End of a cue
|
||||
currentEntry.Text = cueTextBuffer.String()
|
||||
subtitle.Entries = append(subtitle.Entries, currentEntry)
|
||||
currentEntry = model.NewSubtitleEntry()
|
||||
cueTextBuffer.Reset()
|
||||
inCue = false
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Check for style blocks
|
||||
if strings.HasPrefix(line, "STYLE") {
|
||||
inStyle = true
|
||||
|
@ -77,7 +71,7 @@ func Parse(filePath string) (model.Subtitle, error) {
|
|||
}
|
||||
|
||||
if inStyle {
|
||||
if line == "" {
|
||||
if strings.TrimSpace(line) == "" {
|
||||
inStyle = false
|
||||
subtitle.Styles["css"] = styleBuffer.String()
|
||||
styleBuffer.Reset()
|
||||
|
@ -88,6 +82,19 @@ func Parse(filePath string) (model.Subtitle, error) {
|
|||
continue
|
||||
}
|
||||
|
||||
// Skip empty lines, but handle end of cue
|
||||
if strings.TrimSpace(line) == "" {
|
||||
if inCue && cueTextBuffer.Len() > 0 {
|
||||
// End of a cue
|
||||
currentEntry.Text = strings.TrimSpace(cueTextBuffer.String())
|
||||
subtitle.Entries = append(subtitle.Entries, currentEntry)
|
||||
inCue = false
|
||||
cueTextBuffer.Reset()
|
||||
currentEntry = model.SubtitleEntry{} // Reset to zero value
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Check for NOTE comments
|
||||
if strings.HasPrefix(line, "NOTE") {
|
||||
comment := strings.TrimSpace(strings.TrimPrefix(line, "NOTE"))
|
||||
|
@ -97,42 +104,44 @@ func Parse(filePath string) (model.Subtitle, error) {
|
|||
|
||||
// Check for REGION definitions
|
||||
if strings.HasPrefix(line, "REGION") {
|
||||
parts := strings.Split(strings.TrimPrefix(line, "REGION"), ":")
|
||||
if len(parts) >= 2 {
|
||||
regionID := strings.TrimSpace(parts[0])
|
||||
region := model.NewSubtitleRegion(regionID)
|
||||
|
||||
settings := strings.Split(parts[1], " ")
|
||||
for _, setting := range settings {
|
||||
keyValue := strings.Split(setting, "=")
|
||||
if len(keyValue) == 2 {
|
||||
region.Settings[strings.TrimSpace(keyValue[0])] = strings.TrimSpace(keyValue[1])
|
||||
}
|
||||
}
|
||||
|
||||
subtitle.Regions = append(subtitle.Regions, region)
|
||||
}
|
||||
// Process region definitions if needed
|
||||
continue
|
||||
}
|
||||
|
||||
// Check for timestamp lines
|
||||
if strings.Contains(line, "-->") {
|
||||
// Check for cue timing line
|
||||
if strings.Contains(line, " --> ") {
|
||||
inCue = true
|
||||
|
||||
// If we already have a populated currentEntry, save it
|
||||
if currentEntry.Text != "" {
|
||||
subtitle.Entries = append(subtitle.Entries, currentEntry)
|
||||
cueTextBuffer.Reset()
|
||||
}
|
||||
|
||||
// Start a new entry
|
||||
currentEntry = model.NewSubtitleEntry()
|
||||
|
||||
// Use the previous line as cue identifier if it's a number
|
||||
if prevLine != "" && !inCue {
|
||||
if index, err := strconv.Atoi(strings.TrimSpace(prevLine)); err == nil {
|
||||
currentEntry.Index = index
|
||||
}
|
||||
}
|
||||
|
||||
// Parse timestamps
|
||||
timestamps := strings.Split(line, "-->")
|
||||
timestamps := strings.Split(line, " --> ")
|
||||
if len(timestamps) != 2 {
|
||||
return subtitle, fmt.Errorf("invalid timestamp format at line %d: %s", lineNum, line)
|
||||
}
|
||||
|
||||
startTimeStr := strings.TrimSpace(timestamps[0])
|
||||
|
||||
endTimeAndSettings := strings.TrimSpace(timestamps[1])
|
||||
|
||||
// Extract cue settings if any
|
||||
endTimeStr := endTimeAndSettings
|
||||
settings := ""
|
||||
|
||||
// Check for cue settings after end timestamp
|
||||
if spaceIndex := strings.IndexByte(endTimeAndSettings, ' '); spaceIndex != -1 {
|
||||
if spaceIndex := strings.IndexByte(endTimeAndSettings, ' '); spaceIndex > 0 {
|
||||
endTimeStr = endTimeAndSettings[:spaceIndex]
|
||||
settings = endTimeAndSettings[spaceIndex+1:]
|
||||
}
|
||||
|
@ -141,6 +150,10 @@ func Parse(filePath string) (model.Subtitle, error) {
|
|||
currentEntry.StartTime = parseVTTTimestamp(startTimeStr)
|
||||
currentEntry.EndTime = parseVTTTimestamp(endTimeStr)
|
||||
|
||||
// Initialize the styles map
|
||||
currentEntry.Styles = make(map[string]string)
|
||||
currentEntry.FormatData = make(map[string]interface{})
|
||||
|
||||
// Parse cue settings
|
||||
if settings != "" {
|
||||
settingPairs := strings.Split(settings, " ")
|
||||
|
@ -165,42 +178,46 @@ func Parse(filePath string) (model.Subtitle, error) {
|
|||
continue
|
||||
}
|
||||
|
||||
// Check if we have identifier before timestamp
|
||||
if !inCue && currentEntry.Index == 0 && !strings.Contains(line, "-->") {
|
||||
// This might be a cue identifier
|
||||
if _, err := strconv.Atoi(line); err == nil {
|
||||
// It's likely a numeric identifier
|
||||
num, _ := strconv.Atoi(line)
|
||||
currentEntry.Index = num
|
||||
} else {
|
||||
// It's a string identifier, store it in metadata
|
||||
currentEntry.Metadata["identifier"] = line
|
||||
currentEntry.Index = len(subtitle.Entries) + 1
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// If we're in a cue, add this line to the text
|
||||
// If we're in a cue, add the line to the text buffer
|
||||
if inCue {
|
||||
if cueTextBuffer.Len() > 0 {
|
||||
cueTextBuffer.WriteString("\n")
|
||||
}
|
||||
cueTextBuffer.WriteString(line)
|
||||
}
|
||||
|
||||
prevLine = line
|
||||
}
|
||||
|
||||
// Don't forget the last entry
|
||||
if inCue && cueTextBuffer.Len() > 0 {
|
||||
currentEntry.Text = cueTextBuffer.String()
|
||||
currentEntry.Text = strings.TrimSpace(cueTextBuffer.String())
|
||||
subtitle.Entries = append(subtitle.Entries, currentEntry)
|
||||
}
|
||||
|
||||
// Process cue text to extract styling
|
||||
processVTTCueTextStyling(&subtitle)
|
||||
|
||||
|
||||
// Ensure all entries have sequential indices if they don't already
|
||||
for i := range subtitle.Entries {
|
||||
if subtitle.Entries[i].Index == 0 {
|
||||
subtitle.Entries[i].Index = i + 1
|
||||
}
|
||||
|
||||
// Ensure styles map is initialized for all entries
|
||||
if subtitle.Entries[i].Styles == nil {
|
||||
subtitle.Entries[i].Styles = make(map[string]string)
|
||||
}
|
||||
|
||||
// Ensure formatData map is initialized for all entries
|
||||
if subtitle.Entries[i].FormatData == nil {
|
||||
subtitle.Entries[i].FormatData = make(map[string]interface{})
|
||||
}
|
||||
}
|
||||
|
||||
if err := scanner.Err(); err != nil {
|
||||
return subtitle, fmt.Errorf("error reading VTT file: %w", err)
|
||||
}
|
||||
|
||||
// Process cue text to extract styling
|
||||
processVTTCueTextStyling(&subtitle)
|
||||
|
||||
return subtitle, nil
|
||||
}
|
||||
|
|
507
internal/format/vtt/vtt_test.go
Normal file
507
internal/format/vtt/vtt_test.go
Normal file
|
@ -0,0 +1,507 @@
|
|||
package vtt
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"sub-cli/internal/model"
|
||||
)
|
||||
|
||||
func TestParse(t *testing.T) {
|
||||
// Create a temporary test file
|
||||
content := `WEBVTT
|
||||
|
||||
1
|
||||
00:00:01.000 --> 00:00:04.000
|
||||
This is the first line.
|
||||
|
||||
2
|
||||
00:00:05.000 --> 00:00:08.000
|
||||
This is the second line.
|
||||
|
||||
3
|
||||
00:00:09.500 --> 00:00:12.800
|
||||
This is the third line
|
||||
with a line break.
|
||||
`
|
||||
tempDir := t.TempDir()
|
||||
testFile := filepath.Join(tempDir, "test.vtt")
|
||||
if err := os.WriteFile(testFile, []byte(content), 0644); err != nil {
|
||||
t.Fatalf("Failed to create test file: %v", err)
|
||||
}
|
||||
|
||||
// Test parsing
|
||||
subtitle, err := Parse(testFile)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify results
|
||||
if subtitle.Format != "vtt" {
|
||||
t.Errorf("Expected format 'vtt', got '%s'", subtitle.Format)
|
||||
}
|
||||
|
||||
if len(subtitle.Entries) != 3 {
|
||||
t.Errorf("Expected 3 entries, got %d", len(subtitle.Entries))
|
||||
}
|
||||
|
||||
// Check first entry
|
||||
if subtitle.Entries[0].Index != 1 {
|
||||
t.Errorf("First entry index: expected 1, got %d", subtitle.Entries[0].Index)
|
||||
}
|
||||
if subtitle.Entries[0].StartTime.Hours != 0 || subtitle.Entries[0].StartTime.Minutes != 0 ||
|
||||
subtitle.Entries[0].StartTime.Seconds != 1 || subtitle.Entries[0].StartTime.Milliseconds != 0 {
|
||||
t.Errorf("First entry start time: expected 00:00:01.000, got %+v", subtitle.Entries[0].StartTime)
|
||||
}
|
||||
if subtitle.Entries[0].EndTime.Hours != 0 || subtitle.Entries[0].EndTime.Minutes != 0 ||
|
||||
subtitle.Entries[0].EndTime.Seconds != 4 || subtitle.Entries[0].EndTime.Milliseconds != 0 {
|
||||
t.Errorf("First entry end time: expected 00:00:04.000, got %+v", subtitle.Entries[0].EndTime)
|
||||
}
|
||||
if subtitle.Entries[0].Text != "This is the first line." {
|
||||
t.Errorf("First entry text: expected 'This is the first line.', got '%s'", subtitle.Entries[0].Text)
|
||||
}
|
||||
|
||||
// Check third entry with line break
|
||||
if subtitle.Entries[2].Index != 3 {
|
||||
t.Errorf("Third entry index: expected 3, got %d", subtitle.Entries[2].Index)
|
||||
}
|
||||
expectedText := "This is the third line\nwith a line break."
|
||||
if subtitle.Entries[2].Text != expectedText {
|
||||
t.Errorf("Third entry text: expected '%s', got '%s'", expectedText, subtitle.Entries[2].Text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParse_WithHeader(t *testing.T) {
|
||||
// Create a temporary test file with title
|
||||
content := `WEBVTT - Test Title
|
||||
|
||||
1
|
||||
00:00:01.000 --> 00:00:04.000
|
||||
This is the first line.
|
||||
`
|
||||
tempDir := t.TempDir()
|
||||
testFile := filepath.Join(tempDir, "test.vtt")
|
||||
if err := os.WriteFile(testFile, []byte(content), 0644); err != nil {
|
||||
t.Fatalf("Failed to create test file: %v", err)
|
||||
}
|
||||
|
||||
// Test parsing
|
||||
subtitle, err := Parse(testFile)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify title was extracted
|
||||
if subtitle.Title != "Test Title" {
|
||||
t.Errorf("Expected title 'Test Title', got '%s'", subtitle.Title)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParse_WithStyles(t *testing.T) {
|
||||
// Create a temporary test file with CSS styling
|
||||
content := `WEBVTT
|
||||
|
||||
STYLE
|
||||
::cue {
|
||||
color: white;
|
||||
background-color: black;
|
||||
}
|
||||
|
||||
1
|
||||
00:00:01.000 --> 00:00:04.000 align:start position:10%
|
||||
This is <b>styled</b> text.
|
||||
`
|
||||
tempDir := t.TempDir()
|
||||
testFile := filepath.Join(tempDir, "test.vtt")
|
||||
if err := os.WriteFile(testFile, []byte(content), 0644); err != nil {
|
||||
t.Fatalf("Failed to create test file: %v", err)
|
||||
}
|
||||
|
||||
// Test parsing
|
||||
subtitle, err := Parse(testFile)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse failed: %v", err)
|
||||
}
|
||||
|
||||
// First check if we have entries at all
|
||||
if len(subtitle.Entries) == 0 {
|
||||
t.Fatalf("No entries found in parsed subtitle")
|
||||
}
|
||||
|
||||
// Verify styling was captured
|
||||
if subtitle.Entries[0].Styles == nil {
|
||||
t.Fatalf("Entry styles map is nil")
|
||||
}
|
||||
|
||||
// Verify HTML tags were detected
|
||||
if _, ok := subtitle.Entries[0].FormatData["has_html_tags"]; !ok {
|
||||
t.Errorf("Expected HTML tags to be detected in entry")
|
||||
}
|
||||
|
||||
// Verify cue settings were captured
|
||||
if subtitle.Entries[0].Styles["align"] != "start" {
|
||||
t.Errorf("Expected align style 'start', got '%s'", subtitle.Entries[0].Styles["align"])
|
||||
}
|
||||
if subtitle.Entries[0].Styles["position"] != "10%" {
|
||||
t.Errorf("Expected position style '10%%', got '%s'", subtitle.Entries[0].Styles["position"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerate(t *testing.T) {
|
||||
// Create test subtitle
|
||||
subtitle := model.NewSubtitle()
|
||||
subtitle.Format = "vtt"
|
||||
subtitle.Title = "Test VTT"
|
||||
|
||||
entry1 := model.NewSubtitleEntry()
|
||||
entry1.Index = 1
|
||||
entry1.StartTime = model.Timestamp{Hours: 0, Minutes: 0, Seconds: 1, Milliseconds: 0}
|
||||
entry1.EndTime = model.Timestamp{Hours: 0, Minutes: 0, Seconds: 4, Milliseconds: 0}
|
||||
entry1.Text = "This is the first line."
|
||||
|
||||
entry2 := model.NewSubtitleEntry()
|
||||
entry2.Index = 2
|
||||
entry2.StartTime = model.Timestamp{Hours: 0, Minutes: 0, Seconds: 5, Milliseconds: 0}
|
||||
entry2.EndTime = model.Timestamp{Hours: 0, Minutes: 0, Seconds: 8, Milliseconds: 0}
|
||||
entry2.Text = "This is the second line."
|
||||
entry2.Styles["align"] = "center"
|
||||
|
||||
subtitle.Entries = append(subtitle.Entries, entry1, entry2)
|
||||
|
||||
// Generate VTT file
|
||||
tempDir := t.TempDir()
|
||||
outputFile := filepath.Join(tempDir, "output.vtt")
|
||||
err := Generate(subtitle, outputFile)
|
||||
if err != nil {
|
||||
t.Fatalf("Generate failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify generated content
|
||||
content, err := os.ReadFile(outputFile)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read output file: %v", err)
|
||||
}
|
||||
|
||||
// Check content
|
||||
lines := strings.Split(string(content), "\n")
|
||||
if len(lines) < 9 { // Header + title + blank + cue1 (4 lines) + cue2 (4 lines with style)
|
||||
t.Fatalf("Expected at least 9 lines, got %d", len(lines))
|
||||
}
|
||||
|
||||
// Check header
|
||||
if !strings.HasPrefix(lines[0], "WEBVTT") {
|
||||
t.Errorf("Expected first line to start with WEBVTT, got '%s'", lines[0])
|
||||
}
|
||||
|
||||
// Check title
|
||||
if !strings.Contains(lines[0], "Test VTT") {
|
||||
t.Errorf("Expected header to contain title 'Test VTT', got '%s'", lines[0])
|
||||
}
|
||||
|
||||
// Parse the generated file to fully validate
|
||||
parsedSubtitle, err := Parse(outputFile)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to parse generated file: %v", err)
|
||||
}
|
||||
|
||||
if len(parsedSubtitle.Entries) != 2 {
|
||||
t.Errorf("Expected 2 entries in parsed output, got %d", len(parsedSubtitle.Entries))
|
||||
}
|
||||
|
||||
// Check style preservation
|
||||
if parsedSubtitle.Entries[1].Styles["align"] != "center" {
|
||||
t.Errorf("Expected align style 'center', got '%s'", parsedSubtitle.Entries[1].Styles["align"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertToSubtitle(t *testing.T) {
|
||||
// Create a temporary test file
|
||||
content := `WEBVTT
|
||||
|
||||
1
|
||||
00:00:01.000 --> 00:00:04.000
|
||||
This is the first line.
|
||||
|
||||
2
|
||||
00:00:05.000 --> 00:00:08.000
|
||||
This is the second line.
|
||||
`
|
||||
tempDir := t.TempDir()
|
||||
testFile := filepath.Join(tempDir, "test.vtt")
|
||||
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("ConvertToSubtitle failed: %v", err)
|
||||
}
|
||||
|
||||
// Check result
|
||||
if subtitle.Format != "vtt" {
|
||||
t.Errorf("Expected format 'vtt', got '%s'", subtitle.Format)
|
||||
}
|
||||
|
||||
if len(subtitle.Entries) != 2 {
|
||||
t.Errorf("Expected 2 entries, got %d", len(subtitle.Entries))
|
||||
}
|
||||
|
||||
// Check first entry
|
||||
if subtitle.Entries[0].Text != "This is the first line." {
|
||||
t.Errorf("Expected first entry text 'This is the first line.', got '%s'", subtitle.Entries[0].Text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertFromSubtitle(t *testing.T) {
|
||||
// Create test subtitle
|
||||
subtitle := model.NewSubtitle()
|
||||
subtitle.Format = "vtt"
|
||||
subtitle.Title = "Test VTT"
|
||||
|
||||
entry1 := model.NewSubtitleEntry()
|
||||
entry1.Index = 1
|
||||
entry1.StartTime = model.Timestamp{Hours: 0, Minutes: 0, Seconds: 1, Milliseconds: 0}
|
||||
entry1.EndTime = model.Timestamp{Hours: 0, Minutes: 0, Seconds: 4, Milliseconds: 0}
|
||||
entry1.Text = "This is the first line."
|
||||
|
||||
entry2 := model.NewSubtitleEntry()
|
||||
entry2.Index = 2
|
||||
entry2.StartTime = model.Timestamp{Hours: 0, Minutes: 0, Seconds: 5, Milliseconds: 0}
|
||||
entry2.EndTime = model.Timestamp{Hours: 0, Minutes: 0, Seconds: 8, Milliseconds: 0}
|
||||
entry2.Text = "This is the second line."
|
||||
|
||||
subtitle.Entries = append(subtitle.Entries, entry1, entry2)
|
||||
|
||||
// Convert from subtitle to VTT
|
||||
tempDir := t.TempDir()
|
||||
outputFile := filepath.Join(tempDir, "output.vtt")
|
||||
err := ConvertFromSubtitle(subtitle, outputFile)
|
||||
if err != nil {
|
||||
t.Fatalf("ConvertFromSubtitle failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify by parsing back
|
||||
parsedSubtitle, err := Parse(outputFile)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to parse output file: %v", err)
|
||||
}
|
||||
|
||||
if len(parsedSubtitle.Entries) != 2 {
|
||||
t.Errorf("Expected 2 entries, got %d", len(parsedSubtitle.Entries))
|
||||
}
|
||||
|
||||
if parsedSubtitle.Entries[0].Text != "This is the first line." {
|
||||
t.Errorf("Expected first entry text 'This is the first line.', got '%s'", parsedSubtitle.Entries[0].Text)
|
||||
}
|
||||
|
||||
if parsedSubtitle.Title != "Test VTT" {
|
||||
t.Errorf("Expected title 'Test VTT', got '%s'", parsedSubtitle.Title)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormat(t *testing.T) {
|
||||
// Create test file with non-sequential identifiers
|
||||
content := `WEBVTT
|
||||
|
||||
5
|
||||
00:00:01.000 --> 00:00:04.000
|
||||
This is the first line.
|
||||
|
||||
10
|
||||
00:00:05.000 --> 00:00:08.000
|
||||
This is the second line.
|
||||
`
|
||||
tempDir := t.TempDir()
|
||||
testFile := filepath.Join(tempDir, "test.vtt")
|
||||
if err := os.WriteFile(testFile, []byte(content), 0644); err != nil {
|
||||
t.Fatalf("Failed to create test file: %v", err)
|
||||
}
|
||||
|
||||
// Format the file
|
||||
err := Format(testFile)
|
||||
if err != nil {
|
||||
t.Fatalf("Format failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify by parsing back
|
||||
subtitle, err := Parse(testFile)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to parse formatted file: %v", err)
|
||||
}
|
||||
|
||||
// Check that identifiers are sequential
|
||||
if subtitle.Entries[0].Index != 1 {
|
||||
t.Errorf("Expected first entry index to be 1, got %d", subtitle.Entries[0].Index)
|
||||
}
|
||||
if subtitle.Entries[1].Index != 2 {
|
||||
t.Errorf("Expected second entry index to be 2, got %d", subtitle.Entries[1].Index)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParse_FileErrors(t *testing.T) {
|
||||
// Test with non-existent file
|
||||
_, err := Parse("/nonexistent/file.vtt")
|
||||
if err == nil {
|
||||
t.Error("Expected error when parsing non-existent file, got nil")
|
||||
}
|
||||
|
||||
// Test with empty file
|
||||
tempDir := t.TempDir()
|
||||
emptyFile := filepath.Join(tempDir, "empty.vtt")
|
||||
if err := os.WriteFile(emptyFile, []byte{}, 0644); err != nil {
|
||||
t.Fatalf("Failed to create empty test file: %v", err)
|
||||
}
|
||||
|
||||
_, err = Parse(emptyFile)
|
||||
if err == nil {
|
||||
t.Error("Expected error when parsing empty file, got nil")
|
||||
}
|
||||
|
||||
// Test with invalid header
|
||||
invalidFile := filepath.Join(tempDir, "invalid.vtt")
|
||||
if err := os.WriteFile(invalidFile, []byte("NOT A WEBVTT FILE\n\n"), 0644); err != nil {
|
||||
t.Fatalf("Failed to create invalid test file: %v", err)
|
||||
}
|
||||
|
||||
_, err = Parse(invalidFile)
|
||||
if err == nil {
|
||||
t.Error("Expected error when parsing file with invalid header, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseVTTTimestamp(t *testing.T) {
|
||||
testCases := []struct {
|
||||
input string
|
||||
expected model.Timestamp
|
||||
}{
|
||||
// Standard format
|
||||
{"00:00:01.000", model.Timestamp{Hours: 0, Minutes: 0, Seconds: 1, Milliseconds: 0}},
|
||||
// Without leading zeros
|
||||
{"0:0:1.0", model.Timestamp{Hours: 0, Minutes: 0, Seconds: 1, Milliseconds: 0}},
|
||||
// Different millisecond formats
|
||||
{"00:00:01.1", model.Timestamp{Hours: 0, Minutes: 0, Seconds: 1, Milliseconds: 100}},
|
||||
{"00:00:01.12", model.Timestamp{Hours: 0, Minutes: 0, Seconds: 1, Milliseconds: 120}},
|
||||
{"00:00:01.123", model.Timestamp{Hours: 0, Minutes: 0, Seconds: 1, Milliseconds: 123}},
|
||||
// Long milliseconds (should truncate)
|
||||
{"00:00:01.1234", model.Timestamp{Hours: 0, Minutes: 0, Seconds: 1, Milliseconds: 123}},
|
||||
// Unusual but valid format
|
||||
{"01:02:03.456", model.Timestamp{Hours: 1, Minutes: 2, Seconds: 3, Milliseconds: 456}},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(fmt.Sprintf("Timestamp_%s", tc.input), func(t *testing.T) {
|
||||
result := parseVTTTimestamp(tc.input)
|
||||
if result != tc.expected {
|
||||
t.Errorf("parseVTTTimestamp(%s) = %+v, want %+v", tc.input, result, tc.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParse_WithComments(t *testing.T) {
|
||||
// Create a temporary test file with comments
|
||||
content := `WEBVTT
|
||||
|
||||
NOTE This is a comment
|
||||
NOTE This is another comment
|
||||
|
||||
1
|
||||
00:00:01.000 --> 00:00:04.000
|
||||
This is the first line.
|
||||
`
|
||||
tempDir := t.TempDir()
|
||||
testFile := filepath.Join(tempDir, "test_comments.vtt")
|
||||
if err := os.WriteFile(testFile, []byte(content), 0644); err != nil {
|
||||
t.Fatalf("Failed to create test file: %v", err)
|
||||
}
|
||||
|
||||
// Test parsing
|
||||
subtitle, err := Parse(testFile)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify comments were captured
|
||||
if len(subtitle.Comments) != 2 {
|
||||
t.Errorf("Expected 2 comments, got %d", len(subtitle.Comments))
|
||||
}
|
||||
|
||||
if subtitle.Comments[0] != "This is a comment" {
|
||||
t.Errorf("Expected first comment 'This is a comment', got '%s'", subtitle.Comments[0])
|
||||
}
|
||||
|
||||
if subtitle.Comments[1] != "This is another comment" {
|
||||
t.Errorf("Expected second comment 'This is another comment', got '%s'", subtitle.Comments[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerate_WithRegions(t *testing.T) {
|
||||
// Create a subtitle with regions
|
||||
subtitle := model.NewSubtitle()
|
||||
subtitle.Format = "vtt"
|
||||
|
||||
// Add a region
|
||||
region := model.NewSubtitleRegion("region1")
|
||||
region.Settings["width"] = "40%"
|
||||
region.Settings["lines"] = "3"
|
||||
region.Settings["regionanchor"] = "0%,100%"
|
||||
subtitle.Regions = append(subtitle.Regions, region)
|
||||
|
||||
// Add an entry using the region
|
||||
entry := model.NewSubtitleEntry()
|
||||
entry.Index = 1
|
||||
entry.StartTime = model.Timestamp{Hours: 0, Minutes: 0, Seconds: 1, Milliseconds: 0}
|
||||
entry.EndTime = model.Timestamp{Hours: 0, Minutes: 0, Seconds: 4, Milliseconds: 0}
|
||||
entry.Text = "This is a regional cue."
|
||||
entry.Styles["region"] = "region1"
|
||||
subtitle.Entries = append(subtitle.Entries, entry)
|
||||
|
||||
// Generate VTT file
|
||||
tempDir := t.TempDir()
|
||||
outputFile := filepath.Join(tempDir, "regions.vtt")
|
||||
err := Generate(subtitle, outputFile)
|
||||
if err != nil {
|
||||
t.Fatalf("Generate failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify by reading file content
|
||||
content, err := os.ReadFile(outputFile)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read output file: %v", err)
|
||||
}
|
||||
|
||||
// Check if region is included
|
||||
if !strings.Contains(string(content), "REGION region1:") {
|
||||
t.Errorf("Expected REGION definition in output")
|
||||
}
|
||||
|
||||
for k, v := range region.Settings {
|
||||
if !strings.Contains(string(content), fmt.Sprintf("%s=%s", k, v)) {
|
||||
t.Errorf("Expected region setting '%s=%s' in output", k, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormat_FileErrors(t *testing.T) {
|
||||
// Test with non-existent file
|
||||
err := Format("/nonexistent/file.vtt")
|
||||
if err == nil {
|
||||
t.Error("Expected error when formatting non-existent file, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerate_FileError(t *testing.T) {
|
||||
// Create test subtitle
|
||||
subtitle := model.NewSubtitle()
|
||||
subtitle.Format = "vtt"
|
||||
|
||||
// Test with invalid path
|
||||
err := Generate(subtitle, "/nonexistent/directory/file.vtt")
|
||||
if err == nil {
|
||||
t.Error("Expected error when generating to invalid path, got nil")
|
||||
}
|
||||
}
|
199
internal/formatter/formatter_test.go
Normal file
199
internal/formatter/formatter_test.go
Normal file
|
@ -0,0 +1,199 @@
|
|||
package formatter
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFormat(t *testing.T) {
|
||||
// Create temporary test directory
|
||||
tempDir := t.TempDir()
|
||||
|
||||
// Test cases for different formats
|
||||
testCases := []struct {
|
||||
name string
|
||||
content string
|
||||
fileExt string
|
||||
expectedError bool
|
||||
validateOutput func(t *testing.T, filePath string)
|
||||
}{
|
||||
{
|
||||
name: "SRT Format",
|
||||
content: `2
|
||||
00:00:05,000 --> 00:00:08,000
|
||||
This is the second line.
|
||||
|
||||
1
|
||||
00:00:01,000 --> 00:00:04,000
|
||||
This is the first line.
|
||||
`,
|
||||
fileExt: "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)
|
||||
|
||||
// Check that entries are numbered correctly - don't assume ordering by timestamp
|
||||
// The format function should renumber cues sequentially, but might not change order
|
||||
if !strings.Contains(contentStr, "1\n00:00:") && !strings.Contains(contentStr, "2\n00:00:") {
|
||||
t.Errorf("Output should contain numbered entries (1 and 2), got: %s", contentStr)
|
||||
}
|
||||
|
||||
// Check content preservation
|
||||
if !strings.Contains(contentStr, "This is the first line.") ||
|
||||
!strings.Contains(contentStr, "This is the second line.") {
|
||||
t.Errorf("Output should preserve all content")
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "LRC Format",
|
||||
content: `[ar:Test Artist]
|
||||
[00:05.00]This is the second line.
|
||||
[00:01.0]This is the first line.
|
||||
`,
|
||||
fileExt: "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)
|
||||
|
||||
// Check that timestamps are standardized (HH:MM:SS.mmm)
|
||||
if !strings.Contains(contentStr, "[00:01.000]") {
|
||||
t.Errorf("Expected standardized timestamp [00:01.000], got: %s", contentStr)
|
||||
}
|
||||
|
||||
if !strings.Contains(contentStr, "[00:05.000]") {
|
||||
t.Errorf("Expected standardized timestamp [00:05.000], got: %s", contentStr)
|
||||
}
|
||||
|
||||
// Check metadata is preserved
|
||||
if !strings.Contains(contentStr, "[ar:Test Artist]") {
|
||||
t.Errorf("Expected metadata [ar:Test Artist] to be preserved, got: %s", contentStr)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "VTT Format",
|
||||
content: `WEBVTT
|
||||
|
||||
10
|
||||
00:00:05.000 --> 00:00:08.000
|
||||
This is the second line.
|
||||
|
||||
5
|
||||
00:00:01.000 --> 00:00:04.000
|
||||
This is the first line.
|
||||
`,
|
||||
fileExt: "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)
|
||||
|
||||
// Check that cues are numbered correctly - don't assume ordering by timestamp
|
||||
// Just check that identifiers are sequential
|
||||
if !strings.Contains(contentStr, "1\n00:00:") && !strings.Contains(contentStr, "2\n00:00:") {
|
||||
t.Errorf("Output should contain sequential identifiers (1 and 2), got: %s", contentStr)
|
||||
}
|
||||
|
||||
// Check content preservation
|
||||
if !strings.Contains(contentStr, "This is the first line.") ||
|
||||
!strings.Contains(contentStr, "This is the second line.") {
|
||||
t.Errorf("Output should preserve all content")
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Unsupported Format",
|
||||
content: "Some content",
|
||||
fileExt: "txt",
|
||||
expectedError: true,
|
||||
validateOutput: nil,
|
||||
},
|
||||
}
|
||||
|
||||
// Run test cases
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
// Create test file
|
||||
testFile := filepath.Join(tempDir, "test."+tc.fileExt)
|
||||
if err := os.WriteFile(testFile, []byte(tc.content), 0644); err != nil {
|
||||
t.Fatalf("Failed to create test file: %v", err)
|
||||
}
|
||||
|
||||
// Call Format
|
||||
err := Format(testFile)
|
||||
|
||||
// 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 {
|
||||
tc.validateOutput(t, testFile)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormat_NonExistentFile(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
nonExistentFile := filepath.Join(tempDir, "nonexistent.srt")
|
||||
|
||||
err := Format(nonExistentFile)
|
||||
if err == nil {
|
||||
t.Errorf("Expected error when file doesn't exist, but got none")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormat_PermissionError(t *testing.T) {
|
||||
// This test might not be applicable on all platforms
|
||||
// Skip it if running on a platform where permissions can't be enforced
|
||||
if os.Getenv("SKIP_PERMISSION_TESTS") != "" {
|
||||
t.Skip("Skipping permission test")
|
||||
}
|
||||
|
||||
// Create temporary directory
|
||||
tempDir := t.TempDir()
|
||||
|
||||
// Create test file in the temporary directory
|
||||
testFile := filepath.Join(tempDir, "test.srt")
|
||||
content := `1
|
||||
00:00:01,000 --> 00:00:04,000
|
||||
This is a test line.
|
||||
`
|
||||
// Write the file
|
||||
if err := os.WriteFile(testFile, []byte(content), 0644); err != nil {
|
||||
t.Fatalf("Failed to create test file: %v", err)
|
||||
}
|
||||
|
||||
// Make file read-only
|
||||
if err := os.Chmod(testFile, 0400); err != nil {
|
||||
t.Skipf("Failed to change file permissions, skipping test: %v", err)
|
||||
}
|
||||
|
||||
// Try to format read-only file
|
||||
err := Format(testFile)
|
||||
if err == nil {
|
||||
t.Errorf("Expected error when formatting read-only file, but got none")
|
||||
}
|
||||
}
|
100
internal/model/model_test.go
Normal file
100
internal/model/model_test.go
Normal file
|
@ -0,0 +1,100 @@
|
|||
package model
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNewSubtitle(t *testing.T) {
|
||||
subtitle := NewSubtitle()
|
||||
|
||||
if subtitle.Format != "" {
|
||||
t.Errorf("Expected empty format, got %s", subtitle.Format)
|
||||
}
|
||||
|
||||
if subtitle.Title != "" {
|
||||
t.Errorf("Expected empty title, got %s", subtitle.Title)
|
||||
}
|
||||
|
||||
if len(subtitle.Entries) != 0 {
|
||||
t.Errorf("Expected 0 entries, got %d", len(subtitle.Entries))
|
||||
}
|
||||
|
||||
if subtitle.Metadata == nil {
|
||||
t.Error("Expected metadata map to be initialized")
|
||||
}
|
||||
|
||||
if subtitle.Styles == nil {
|
||||
t.Error("Expected styles map to be initialized")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewSubtitleEntry(t *testing.T) {
|
||||
entry := NewSubtitleEntry()
|
||||
|
||||
if entry.Index != 0 {
|
||||
t.Errorf("Expected index 0, got %d", entry.Index)
|
||||
}
|
||||
|
||||
if entry.StartTime.Hours != 0 || entry.StartTime.Minutes != 0 ||
|
||||
entry.StartTime.Seconds != 0 || entry.StartTime.Milliseconds != 0 {
|
||||
t.Errorf("Expected zero start time, got %+v", entry.StartTime)
|
||||
}
|
||||
|
||||
if entry.EndTime.Hours != 0 || entry.EndTime.Minutes != 0 ||
|
||||
entry.EndTime.Seconds != 0 || entry.EndTime.Milliseconds != 0 {
|
||||
t.Errorf("Expected zero end time, got %+v", entry.EndTime)
|
||||
}
|
||||
|
||||
if entry.Text != "" {
|
||||
t.Errorf("Expected empty text, got %s", entry.Text)
|
||||
}
|
||||
|
||||
if entry.Metadata == nil {
|
||||
t.Error("Expected metadata map to be initialized")
|
||||
}
|
||||
|
||||
if entry.Styles == nil {
|
||||
t.Error("Expected styles map to be initialized")
|
||||
}
|
||||
|
||||
if entry.FormatData == nil {
|
||||
t.Error("Expected formatData map to be initialized")
|
||||
}
|
||||
|
||||
if entry.Classes == nil {
|
||||
t.Error("Expected classes slice to be initialized")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewSubtitleRegion(t *testing.T) {
|
||||
// Test with empty ID
|
||||
region := NewSubtitleRegion("")
|
||||
|
||||
if region.ID != "" {
|
||||
t.Errorf("Expected empty ID, got %s", region.ID)
|
||||
}
|
||||
|
||||
if region.Settings == nil {
|
||||
t.Error("Expected settings map to be initialized")
|
||||
}
|
||||
|
||||
// Test with a specific ID
|
||||
testID := "region1"
|
||||
region = NewSubtitleRegion(testID)
|
||||
|
||||
if region.ID != testID {
|
||||
t.Errorf("Expected ID %s, got %s", testID, region.ID)
|
||||
}
|
||||
|
||||
// Verify the settings map is initialized and can store values
|
||||
region.Settings["width"] = "100%"
|
||||
region.Settings["lines"] = "3"
|
||||
|
||||
if val, ok := region.Settings["width"]; !ok || val != "100%" {
|
||||
t.Errorf("Expected settings to contain width=100%%, got %s", val)
|
||||
}
|
||||
|
||||
if val, ok := region.Settings["lines"]; !ok || val != "3" {
|
||||
t.Errorf("Expected settings to contain lines=3, got %s", val)
|
||||
}
|
||||
}
|
|
@ -110,11 +110,14 @@ func syncLRCTimeline(source, target model.Lyrics) model.Lyrics {
|
|||
Content: target.Content,
|
||||
}
|
||||
|
||||
// Create timeline with same length as target content
|
||||
result.Timeline = make([]model.Timestamp, len(target.Content))
|
||||
|
||||
// Use source timeline if available and lengths match
|
||||
if len(source.Timeline) > 0 && len(source.Timeline) == len(target.Content) {
|
||||
result.Timeline = source.Timeline
|
||||
copy(result.Timeline, source.Timeline)
|
||||
} else if len(source.Timeline) > 0 {
|
||||
// If lengths don't match, scale timeline
|
||||
// If lengths don't match, scale timeline using our improved scaleTimeline function
|
||||
result.Timeline = scaleTimeline(source.Timeline, len(target.Content))
|
||||
}
|
||||
|
||||
|
@ -193,6 +196,15 @@ func syncVTTTimeline(source, target model.Subtitle) model.Subtitle {
|
|||
// Copy target entries
|
||||
copy(result.Entries, target.Entries)
|
||||
|
||||
// 如果源字幕为空或目标字幕为空,直接返回复制的目标内容
|
||||
if len(source.Entries) == 0 || len(target.Entries) == 0 {
|
||||
// 确保索引编号正确
|
||||
for i := range result.Entries {
|
||||
result.Entries[i].Index = i + 1
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// If source and target have the same number of entries, directly apply timings
|
||||
if len(source.Entries) == len(target.Entries) {
|
||||
for i := range result.Entries {
|
||||
|
@ -256,10 +268,64 @@ func scaleTimeline(timeline []model.Timestamp, targetCount int) []model.Timestam
|
|||
|
||||
sourceLength := len(timeline)
|
||||
|
||||
// Handle simple case: same length
|
||||
if targetCount == sourceLength {
|
||||
copy(result, timeline)
|
||||
return result
|
||||
}
|
||||
|
||||
// Handle case where target is longer than source
|
||||
// We need to interpolate timestamps between source entries
|
||||
for i := 0; i < targetCount; i++ {
|
||||
// Scale index to match source timeline
|
||||
sourceIndex := i * (sourceLength - 1) / (targetCount - 1)
|
||||
result[i] = timeline[sourceIndex]
|
||||
if sourceLength == 1 {
|
||||
// If source has only one entry, use it for all target entries
|
||||
result[i] = timeline[0]
|
||||
continue
|
||||
}
|
||||
|
||||
// Calculate a floating-point position in the source timeline
|
||||
floatIndex := float64(i) * float64(sourceLength-1) / float64(targetCount-1)
|
||||
lowerIndex := int(floatIndex)
|
||||
upperIndex := lowerIndex + 1
|
||||
|
||||
// Handle boundary case
|
||||
if upperIndex >= sourceLength {
|
||||
upperIndex = sourceLength - 1
|
||||
lowerIndex = upperIndex - 1
|
||||
}
|
||||
|
||||
// If indices are the same, just use the source timestamp
|
||||
if lowerIndex == upperIndex || lowerIndex < 0 {
|
||||
result[i] = timeline[upperIndex]
|
||||
} else {
|
||||
// Calculate the fraction between the lower and upper indices
|
||||
fraction := floatIndex - float64(lowerIndex)
|
||||
|
||||
// Convert timestamps to milliseconds for interpolation
|
||||
lowerMS := timeline[lowerIndex].Hours*3600000 + timeline[lowerIndex].Minutes*60000 +
|
||||
timeline[lowerIndex].Seconds*1000 + timeline[lowerIndex].Milliseconds
|
||||
|
||||
upperMS := timeline[upperIndex].Hours*3600000 + timeline[upperIndex].Minutes*60000 +
|
||||
timeline[upperIndex].Seconds*1000 + timeline[upperIndex].Milliseconds
|
||||
|
||||
// Interpolate
|
||||
resultMS := int(float64(lowerMS) + fraction*float64(upperMS-lowerMS))
|
||||
|
||||
// Convert back to timestamp
|
||||
hours := resultMS / 3600000
|
||||
resultMS %= 3600000
|
||||
minutes := resultMS / 60000
|
||||
resultMS %= 60000
|
||||
seconds := resultMS / 1000
|
||||
milliseconds := resultMS % 1000
|
||||
|
||||
result[i] = model.Timestamp{
|
||||
Hours: hours,
|
||||
Minutes: minutes,
|
||||
Seconds: seconds,
|
||||
Milliseconds: milliseconds,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
|
@ -272,7 +338,13 @@ func calculateDuration(start, end model.Timestamp) model.Timestamp {
|
|||
|
||||
durationMillis := endMillis - startMillis
|
||||
if durationMillis < 0 {
|
||||
durationMillis = 3000 // Default 3 seconds if negative
|
||||
// Return zero duration if end is before start
|
||||
return model.Timestamp{
|
||||
Hours: 0,
|
||||
Minutes: 0,
|
||||
Seconds: 0,
|
||||
Milliseconds: 0,
|
||||
}
|
||||
}
|
||||
|
||||
hours := durationMillis / 3600000
|
||||
|
|
1101
internal/sync/sync_test.go
Normal file
1101
internal/sync/sync_test.go
Normal file
File diff suppressed because it is too large
Load diff
9
internal/testdata/test.lrc
vendored
Normal file
9
internal/testdata/test.lrc
vendored
Normal file
|
@ -0,0 +1,9 @@
|
|||
[ti:Test LRC File]
|
||||
[ar:Test Artist]
|
||||
[al:Test Album]
|
||||
[by:Test Creator]
|
||||
|
||||
[00:01.00]This is the first subtitle line.
|
||||
[00:05.00]This is the second subtitle line.
|
||||
[00:09.50]This is the third subtitle line
|
||||
[00:12.80]with a line break.
|
12
internal/testdata/test.srt
vendored
Normal file
12
internal/testdata/test.srt
vendored
Normal file
|
@ -0,0 +1,12 @@
|
|||
1
|
||||
00:00:01,000 --> 00:00:04,000
|
||||
This is the first subtitle line.
|
||||
|
||||
2
|
||||
00:00:05,000 --> 00:00:08,000
|
||||
This is the second subtitle line.
|
||||
|
||||
3
|
||||
00:00:09,500 --> 00:00:12,800
|
||||
This is the third subtitle line
|
||||
with a line break.
|
14
internal/testdata/test.vtt
vendored
Normal file
14
internal/testdata/test.vtt
vendored
Normal file
|
@ -0,0 +1,14 @@
|
|||
WEBVTT
|
||||
|
||||
1
|
||||
00:00:01.000 --> 00:00:04.000
|
||||
This is the first subtitle line.
|
||||
|
||||
2
|
||||
00:00:05.000 --> 00:00:08.000
|
||||
This is the second subtitle line.
|
||||
|
||||
3
|
||||
00:00:09.500 --> 00:00:12.800
|
||||
This is the third subtitle line
|
||||
with a line break.
|
Loading…
Add table
Add a link
Reference in a new issue