feat: add tests

This commit is contained in:
CDN 2025-04-23 16:30:45 +08:00
parent 44c7e9bee5
commit bb87f058f0
Signed by: CDN
GPG key ID: 0C656827F9F80080
17 changed files with 4436 additions and 80 deletions

View file

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

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