Compare commits

..

2 commits

Author SHA1 Message Date
CDN
bcdcf598ea
chore: bump version
All checks were successful
Build and Release / Build (darwin-amd64) (push) Successful in 22s
Deploy docs / deploy (push) Successful in 44s
Build and Release / Build (linux-amd64) (push) Successful in 23s
Build and Release / Build (darwin-arm64) (push) Successful in 19s
Build and Release / Build (windows-amd64) (push) Successful in 22s
Build and Release / Build (linux-arm64) (push) Successful in 18s
Build and Release / Build (windows-arm64) (push) Successful in 17s
Build and Release / Create Release (push) Successful in 14s
2025-04-23 16:31:06 +08:00
CDN
bb87f058f0
feat: add tests 2025-04-23 16:30:45 +08:00
18 changed files with 4437 additions and 81 deletions

383
cmd/root_test.go Normal file
View file

@ -0,0 +1,383 @@
package cmd
import (
"bytes"
"io"
"os"
"path/filepath"
"strings"
"testing"
"sub-cli/internal/config"
)
// setupTestEnv creates a testing environment with redirected stdout
// and returns the output buffer and cleanup function
func setupTestEnv() (*bytes.Buffer, func()) {
// Save original stdout
oldStdout := os.Stdout
// Create pipe to capture stdout
r, w, _ := os.Pipe()
os.Stdout = w
// Create buffer to store output
outBuf := &bytes.Buffer{}
// Create cleanup function
cleanup := func() {
// Restore original stdout
os.Stdout = oldStdout
// Close writer
w.Close()
// Read from pipe
io.Copy(outBuf, r)
r.Close()
}
return outBuf, cleanup
}
// TestExecute_Version tests the version command
func TestExecute_Version(t *testing.T) {
// Save original args
oldArgs := os.Args
defer func() { os.Args = oldArgs }()
// Set up test environment
outBuf, cleanup := setupTestEnv()
// Set args for version command
os.Args = []string{"sub-cli", "version"}
// Execute command
Execute()
// Get output
cleanup()
output := outBuf.String()
// Verify output
expectedOutput := "sub-cli version " + config.Version
if !strings.Contains(output, expectedOutput) {
t.Errorf("Expected version output to contain '%s', got '%s'", expectedOutput, output)
}
}
// TestExecute_Help tests the help command
func TestExecute_Help(t *testing.T) {
// Save original args
oldArgs := os.Args
defer func() { os.Args = oldArgs }()
// Set up test environment
outBuf, cleanup := setupTestEnv()
// Set args for help command
os.Args = []string{"sub-cli", "help"}
// Execute command
Execute()
// Get output
cleanup()
output := outBuf.String()
// Verify output contains usage information
if !strings.Contains(output, "Usage:") {
t.Errorf("Expected help output to contain usage information")
}
if !strings.Contains(output, "Commands:") {
t.Errorf("Expected help output to contain commands information")
}
}
// TestExecute_NoArgs tests execution with no arguments
func TestExecute_NoArgs(t *testing.T) {
// Save original args
oldArgs := os.Args
defer func() { os.Args = oldArgs }()
// Set up test environment
outBuf, cleanup := setupTestEnv()
// Set args with no command
os.Args = []string{"sub-cli"}
// Execute command
Execute()
// Get output
cleanup()
output := outBuf.String()
// Verify output contains usage information
if !strings.Contains(output, "Usage:") {
t.Errorf("Expected output to contain usage information when no args provided")
}
}
// TestExecute_UnknownCommand tests execution with unknown command
func TestExecute_UnknownCommand(t *testing.T) {
// Save original args
oldArgs := os.Args
defer func() { os.Args = oldArgs }()
// Set up test environment
outBuf, cleanup := setupTestEnv()
// Set args with unknown command
os.Args = []string{"sub-cli", "unknown-command"}
// Execute command
Execute()
// Get output
cleanup()
output := outBuf.String()
// Verify output
if !strings.Contains(output, "Unknown command") {
t.Errorf("Expected output to contain 'Unknown command' message")
}
if !strings.Contains(output, "Usage:") {
t.Errorf("Expected output to contain usage information when unknown command provided")
}
}
// TestHandleSync tests the sync command
func TestHandleSync(t *testing.T) {
// Create temporary test directory
tempDir := t.TempDir()
// Create source file
sourceContent := `[00:01.00]This is line one.
[00:05.00]This is line two.`
sourceFile := filepath.Join(tempDir, "source.lrc")
if err := os.WriteFile(sourceFile, []byte(sourceContent), 0644); err != nil {
t.Fatalf("Failed to create source file: %v", err)
}
// Create target file
targetContent := `[00:10.00]This is target line one.
[00:20.00]This is target line two.`
targetFile := filepath.Join(tempDir, "target.lrc")
if err := os.WriteFile(targetFile, []byte(targetContent), 0644); err != nil {
t.Fatalf("Failed to create target file: %v", err)
}
// Set up test environment
outBuf, cleanup := setupTestEnv()
// Execute sync command
handleSync([]string{sourceFile, targetFile})
// Get output
cleanup()
output := outBuf.String()
// Verify no error message
if strings.Contains(output, "Error:") {
t.Errorf("Expected no error, but got: %s", output)
}
// Verify target file has been modified
modifiedContent, err := os.ReadFile(targetFile)
if err != nil {
t.Fatalf("Failed to read modified target file: %v", err)
}
// Check that target file now has source timings
if !strings.Contains(string(modifiedContent), "[00:01.000]") {
t.Errorf("Expected modified target to contain source timing [00:01.000], got: %s", string(modifiedContent))
}
// Check that target content is preserved
if !strings.Contains(string(modifiedContent), "This is target line one.") {
t.Errorf("Expected modified target to preserve content 'This is target line one.', got: %s", string(modifiedContent))
}
}
// TestHandleSync_NoArgs tests sync command with insufficient arguments
func TestHandleSync_NoArgs(t *testing.T) {
// Set up test environment
outBuf, cleanup := setupTestEnv()
// Execute sync command with no args
handleSync([]string{})
// Get output
cleanup()
output := outBuf.String()
// Verify output contains usage information
if !strings.Contains(output, "Usage: sub-cli sync") {
t.Errorf("Expected sync usage information when no args provided")
}
}
// TestHandleSync_OneArg tests sync command with only one argument
func TestHandleSync_OneArg(t *testing.T) {
// Set up test environment
outBuf, cleanup := setupTestEnv()
// Execute sync command with one arg
handleSync([]string{"source.lrc"})
// Get output
cleanup()
output := outBuf.String()
// Verify output contains usage information
if !strings.Contains(output, "Usage: sub-cli sync") {
t.Errorf("Expected sync usage information when only one arg provided")
}
}
// TestHandleConvert tests the convert command
func TestHandleConvert(t *testing.T) {
// Create temporary test 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)
}
// Define target file
targetFile := filepath.Join(tempDir, "target.vtt")
// Set up test environment
outBuf, cleanup := setupTestEnv()
// Execute convert command
handleConvert([]string{sourceFile, targetFile})
// Get output
cleanup()
output := outBuf.String()
// Verify no error message
if strings.Contains(output, "Error:") {
t.Errorf("Expected no error, but got: %s", output)
}
// Verify target file has been created
if _, err := os.Stat(targetFile); os.IsNotExist(err) {
t.Errorf("Target file was not created")
}
// Verify target file content
targetContent, err := os.ReadFile(targetFile)
if err != nil {
t.Fatalf("Failed to read target file: %v", err)
}
// Check that target file has VTT format
if !strings.Contains(string(targetContent), "WEBVTT") {
t.Errorf("Expected target file to have WEBVTT header, got: %s", string(targetContent))
}
// Check that content is preserved
if !strings.Contains(string(targetContent), "This is a test subtitle.") {
t.Errorf("Expected target file to preserve content, got: %s", string(targetContent))
}
}
// TestHandleConvert_NoArgs tests convert command with insufficient arguments
func TestHandleConvert_NoArgs(t *testing.T) {
// Set up test environment
outBuf, cleanup := setupTestEnv()
// Execute convert command with no args
handleConvert([]string{})
// Get output
cleanup()
output := outBuf.String()
// Verify output contains usage information
if !strings.Contains(output, "Usage: sub-cli convert") {
t.Errorf("Expected convert usage information when no args provided")
}
}
// TestHandleFormat tests the fmt command
func TestHandleFormat(t *testing.T) {
// Create temporary test directory
tempDir := t.TempDir()
// Create test file with non-sequential numbers
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.`
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)
}
// Set up test environment
outBuf, cleanup := setupTestEnv()
// Execute fmt command
handleFormat([]string{testFile})
// Get output
cleanup()
output := outBuf.String()
// Verify no error message
if strings.Contains(output, "Error:") {
t.Errorf("Expected no error, but got: %s", output)
}
// Verify file has been modified
modifiedContent, err := os.ReadFile(testFile)
if err != nil {
t.Fatalf("Failed to read modified file: %v", err)
}
// Check that entries are correctly numbered - don't assume ordering by timestamp
contentStr := string(modifiedContent)
// Just check that identifiers 1 and 2 exist and content is preserved
if !strings.Contains(contentStr, "1\n00:00:") && !strings.Contains(contentStr, "2\n00:00:") {
t.Errorf("Output should contain sequential identifiers (1 and 2)")
}
// 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")
}
}
// TestHandleFormat_NoArgs tests fmt command with no arguments
func TestHandleFormat_NoArgs(t *testing.T) {
// Set up test environment
outBuf, cleanup := setupTestEnv()
// Execute fmt command with no args
handleFormat([]string{})
// Get output
cleanup()
output := outBuf.String()
// Verify output contains usage information
if !strings.Contains(output, "Usage: sub-cli fmt") {
t.Errorf("Expected fmt usage information when no args provided")
}
}

View file

@ -114,15 +114,18 @@ Currently, synchronization only works between files of the same format:
#### For LRC Files:
- **When entry counts match**: The source timeline is directly applied to the target content.
- **When entry counts differ**: The source timeline is scaled to match the target content using linear interpolation.
- **When entry counts differ**: The source timeline is scaled to match the target content using linear interpolation:
- For each target entry position, a corresponding position in the source timeline is calculated
- Times are linearly interpolated between the nearest source entries
- This ensures smooth and proportional timing across entries of different counts
- **Preserved from target**: All content text and metadata (artist, title, etc.).
- **Modified in target**: Only timestamps are updated.
#### For SRT Files:
- **When entry counts match**: Both start and end times from the source are directly applied to the target entries.
- **When entry counts differ**: A scaled approach is used:
- Start times are taken from proportionally matched source entries
- **When entry counts differ**: A scaled approach using linear interpolation is used:
- Start times are calculated using linear interpolation between the nearest source entries
- End times are calculated based on source entry durations
- The timing relationship between entries is preserved
- **Preserved from target**: All subtitle text content.
@ -131,17 +134,35 @@ Currently, synchronization only works between files of the same format:
#### For VTT Files:
- **When entry counts match**: Both start and end times from the source are directly applied to the target entries.
- **When entry counts differ**: A scaled approach is used, similar to SRT synchronization:
- Start times are taken from proportionally matched source entries
- **When entry counts differ**: A scaled approach using linear interpolation is used, similar to SRT synchronization:
- Start times are calculated using linear interpolation between the nearest source entries
- End times are calculated based on source entry durations
- The timing relationship between entries is preserved
- **Preserved from target**: All subtitle text content, formatting, cue settings, and styling.
- **Modified in target**: Timestamps are updated and cue identifiers are standardized (sequential from 1).
### Timeline Interpolation Details
The sync command uses linear interpolation to handle different entry counts between source and target files:
- **What is linear interpolation?** It's a mathematical technique for estimating values between two known points. For timeline synchronization, it creates a smooth transition between source timestamps when applied to a different number of target entries.
- **How it works:**
1. The algorithm maps each target entry position to a corresponding position in the source timeline
2. For each target position, it calculates a timestamp by interpolating between the nearest source timestamps
3. The calculation ensures proportionally distributed timestamps that maintain the rhythm of the original
- **Example:** If source file has entries at 1s, 5s, and 9s (3 entries), and target has 5 entries, the interpolated timestamps would be approximately 1s, 3s, 5s, 7s, and 9s, maintaining even spacing.
- **Benefits of linear interpolation:**
- More accurate timing when entry counts differ significantly
- Preserves the pacing and rhythm of the source timeline
- Handles both expanding (target has more entries) and contracting (target has fewer entries) scenarios
### Edge Cases
- If the source file has no timing information, the target remains unchanged.
- If source duration calculations result in negative values, a default 3-second duration is applied.
- If source duration calculations result in negative values, a default duration of zero is applied (improved from previous 3-second default).
- The command displays a warning when entry counts differ but proceeds with the scaled synchronization.
- Format-specific features from the target file (such as styling, alignment, metadata) are preserved. The sync operation only replaces timestamps, not any other formatting or content features.

View file

@ -114,15 +114,18 @@ sub-cli sync <源文件> <目标文件>
#### 对于LRC文件
- **当条目数匹配时**: 源时间线直接应用于目标内容。
- **当条目数不同时**: 源时间线使用线性插值进行缩放以匹配目标内容。
- **当条目数不同时**: 源时间线使用线性插值进行缩放以匹配目标内容:
- 对于每个目标条目位置,计算源时间线中的对应位置
- 在最近的源条目之间进行线性插值计算时间
- 这确保了在不同数量的条目间实现平滑和比例化的时间分布
- **从目标保留**: 所有内容文本和元数据(艺术家、标题等)。
- **在目标中修改**: 只更新时间戳。
#### 对于SRT文件
- **当条目数匹配时**: 源的开始和结束时间直接应用于目标条目。
- **当条目数不同时**: 使用缩放方法:
- 开始时间取自按比例匹配的源条目
- **当条目数不同时**: 使用基于线性插值的缩放方法:
- 开始时间使用源条目之间的线性插值计算
- 结束时间根据源条目时长计算
- 保持条目之间的时间关系
- **从目标保留**: 所有字幕文本内容。
@ -131,17 +134,35 @@ sub-cli sync <源文件> <目标文件>
#### 对于VTT文件
- **当条目数匹配时**: 源的开始和结束时间直接应用于目标条目。
- **当条目数不同时**: 使用缩放方法
- 开始时间取自按比例匹配的源条目
- **当条目数不同时**: 使用基于线性插值的缩放方法类似于SRT同步
- 开始时间使用源条目之间的线性插值计算
- 结束时间根据源条目时长计算
- 保持条目之间的时间关系
- **从目标保留**: 所有字幕文本内容和样式信息。
- **在目标中修改**: 更新时间戳并标准化提示标识符。
### 时间线插值详情
同步命令使用线性插值来处理源文件和目标文件之间不同的条目数量:
- **什么是线性插值?** 这是一种估计两个已知点之间值的数学技术。对于时间线同步,它在应用于不同数量的目标条目时,可以在源时间戳之间创建平滑过渡。
- **工作原理:**
1. 算法将每个目标条目位置映射到源时间线中的对应位置
2. 对于每个目标位置,通过插值计算最近的源时间戳之间的时间戳
3. 计算确保按比例分布的时间戳,保持原始节奏
- **示例:** 如果源文件在1秒、5秒和9秒有条目共3个条目而目标有5个条目插值后的时间戳将大约为1秒、3秒、5秒、7秒和9秒保持均匀间隔。
- **线性插值的好处:**
- 当条目数相差很大时,提供更准确的时间
- 保持源时间线的节奏和韵律
- 既能处理扩展(目标条目更多)也能处理收缩(目标条目更少)的情况
### 边缘情况
- 如果源文件没有时间信息,目标保持不变。
- 如果源时长计算导致负值会应用默认的3秒时长。
- 如果源时长计算导致负值,会应用默认的零秒时长改进自之前的3秒默认值
- 当条目数不同时,命令会显示警告但会继续进行缩放同步。
- 目标文件中的特定格式功能(如样式、对齐方式、元数据)会被保留。同步操作只替换时间戳,不会更改任何其他格式或内容功能。

View file

@ -1,7 +1,7 @@
package config
// Version stores the current application version
const Version = "0.5.0"
const Version = "0.5.1"
// Usage stores the general usage information
const Usage = `Usage: sub-cli [command] [options]

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

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

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

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

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

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

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

View file

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

File diff suppressed because it is too large Load diff

9
internal/testdata/test.lrc vendored Normal file
View 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
View 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
View 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.

342
tests/integration_test.go Normal file
View file

@ -0,0 +1,342 @@
package tests
import (
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
// TestIntegration_EndToEnd runs a series of commands to test the entire workflow
func TestIntegration_EndToEnd(t *testing.T) {
// Skip if not running integration tests
if os.Getenv("RUN_INTEGRATION_TESTS") == "" {
t.Skip("Skipping integration tests. Set RUN_INTEGRATION_TESTS=1 to run.")
}
// Get the path to the built binary
binaryPath := os.Getenv("BINARY_PATH")
if binaryPath == "" {
// Default to looking in the current directory
binaryPath = "sub-cli"
}
// Create temporary directory for test files
tempDir := t.TempDir()
// Test files
srtFile := filepath.Join(tempDir, "test.srt")
lrcFile := filepath.Join(tempDir, "test.lrc")
vttFile := filepath.Join(tempDir, "test.vtt")
txtFile := filepath.Join(tempDir, "test.txt")
// Create SRT test file
srtContent := `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.
`
if err := os.WriteFile(srtFile, []byte(srtContent), 0644); err != nil {
t.Fatalf("Failed to create SRT test file: %v", err)
}
// Step 1: Test conversion from SRT to LRC
t.Log("Testing SRT to LRC conversion...")
cmd := exec.Command(binaryPath, "convert", srtFile, lrcFile)
output, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("Convert command failed: %v\nOutput: %s", err, output)
}
// Verify LRC file was created
if _, err := os.Stat(lrcFile); os.IsNotExist(err) {
t.Fatalf("LRC file was not created")
}
// Read LRC content
lrcContent, err := os.ReadFile(lrcFile)
if err != nil {
t.Fatalf("Failed to read LRC file: %v", err)
}
// Verify LRC content
if !strings.Contains(string(lrcContent), "[00:01.000]") {
t.Errorf("Expected LRC to contain timeline [00:01.000], got: %s", string(lrcContent))
}
if !strings.Contains(string(lrcContent), "This is the first subtitle line.") {
t.Errorf("Expected LRC to contain text content, got: %s", string(lrcContent))
}
// Step 2: Create a new SRT file with different timing
srtModifiedContent := `1
00:00:10,000 --> 00:00:14,000
This is the first subtitle line.
2
00:00:15,000 --> 00:00:18,000
This is the second subtitle line.
3
00:00:19,500 --> 00:00:22,800
This is the third subtitle line
with a line break.
`
srtModifiedFile := filepath.Join(tempDir, "modified.srt")
if err := os.WriteFile(srtModifiedFile, []byte(srtModifiedContent), 0644); err != nil {
t.Fatalf("Failed to create modified SRT test file: %v", err)
}
// Step 3: Test sync between SRT files
t.Log("Testing SRT to SRT sync...")
cmd = exec.Command(binaryPath, "sync", srtModifiedFile, srtFile)
output, err = cmd.CombinedOutput()
if err != nil {
t.Fatalf("Sync command failed: %v\nOutput: %s", err, output)
}
// Read synced SRT content
syncedSrtContent, err := os.ReadFile(srtFile)
if err != nil {
t.Fatalf("Failed to read synced SRT file: %v", err)
}
// Verify synced content has new timings but original text
if !strings.Contains(string(syncedSrtContent), "00:00:10,000 -->") {
t.Errorf("Expected synced SRT to have new timing 00:00:10,000, got: %s", string(syncedSrtContent))
}
if !strings.Contains(string(syncedSrtContent), "This is the first subtitle line.") {
t.Errorf("Expected synced SRT to preserve original text, got: %s", string(syncedSrtContent))
}
// Step 4: Test conversion from SRT to VTT
t.Log("Testing SRT to VTT conversion...")
cmd = exec.Command(binaryPath, "convert", srtFile, vttFile)
output, err = cmd.CombinedOutput()
if err != nil {
t.Fatalf("Convert command failed: %v\nOutput: %s", err, output)
}
// Verify VTT file was created
if _, err := os.Stat(vttFile); os.IsNotExist(err) {
t.Fatalf("VTT file was not created")
}
// Read VTT content
vttContent, err := os.ReadFile(vttFile)
if err != nil {
t.Fatalf("Failed to read VTT file: %v", err)
}
// Verify VTT content
if !strings.Contains(string(vttContent), "WEBVTT") {
t.Errorf("Expected VTT to contain WEBVTT header, got: %s", string(vttContent))
}
if !strings.Contains(string(vttContent), "00:00:10.000 -->") {
t.Errorf("Expected VTT to contain timeline 00:00:10.000, got: %s", string(vttContent))
}
// Step 5: Create VTT file with styling
vttStyledContent := `WEBVTT - Styled Test
STYLE
::cue {
color: white;
background-color: black;
}
1
00:00:20.000 --> 00:00:24.000 align:start position:10%
This is a <b>styled</b> subtitle.
2
00:00:25.000 --> 00:00:28.000 align:middle
This is another styled subtitle.
`
vttStyledFile := filepath.Join(tempDir, "styled.vtt")
if err := os.WriteFile(vttStyledFile, []byte(vttStyledContent), 0644); err != nil {
t.Fatalf("Failed to create styled VTT test file: %v", err)
}
// Step 6: Test sync between VTT files (should preserve styling)
t.Log("Testing VTT to VTT sync...")
cmd = exec.Command(binaryPath, "sync", vttFile, vttStyledFile)
output, err = cmd.CombinedOutput()
if err != nil {
t.Fatalf("Sync command failed: %v\nOutput: %s", err, output)
}
// Read synced VTT content
syncedVttContent, err := os.ReadFile(vttStyledFile)
if err != nil {
t.Fatalf("Failed to read synced VTT file: %v", err)
}
// Verify synced content has new timings but preserves styling and text
if !strings.Contains(string(syncedVttContent), "00:00:10.000 -->") {
t.Errorf("Expected synced VTT to have new timing 00:00:10.000, got: %s", string(syncedVttContent))
}
if !strings.Contains(string(syncedVttContent), "align:") {
t.Errorf("Expected synced VTT to preserve styling, got: %s", string(syncedVttContent))
}
if !strings.Contains(string(syncedVttContent), "<b>styled</b>") {
t.Errorf("Expected synced VTT to preserve HTML formatting, got: %s", string(syncedVttContent))
}
// Step 7: Test format command with VTT file
t.Log("Testing VTT formatting...")
cmd = exec.Command(binaryPath, "fmt", vttStyledFile)
output, err = cmd.CombinedOutput()
if err != nil {
t.Fatalf("Format command failed: %v\nOutput: %s", err, output)
}
// Read formatted VTT content
formattedVttContent, err := os.ReadFile(vttStyledFile)
if err != nil {
t.Fatalf("Failed to read formatted VTT file: %v", err)
}
// Verify formatted content preserves styling and has sequential cue identifiers
if !strings.Contains(string(formattedVttContent), "1\n00:00:10.000") {
t.Errorf("Expected formatted VTT to have sequential identifiers, got: %s", string(formattedVttContent))
}
if !strings.Contains(string(formattedVttContent), "align:") {
t.Errorf("Expected formatted VTT to preserve styling, got: %s", string(formattedVttContent))
}
// Step 8: Test conversion to plain text
t.Log("Testing VTT to TXT conversion...")
cmd = exec.Command(binaryPath, "convert", vttStyledFile, txtFile)
output, err = cmd.CombinedOutput()
if err != nil {
t.Fatalf("Convert command failed: %v\nOutput: %s", err, output)
}
// Verify TXT file was created
if _, err := os.Stat(txtFile); os.IsNotExist(err) {
t.Fatalf("TXT file was not created")
}
// Read TXT content
txtContent, err := os.ReadFile(txtFile)
if err != nil {
t.Fatalf("Failed to read TXT file: %v", err)
}
// Verify TXT content has text but no timing or styling
if strings.Contains(string(txtContent), "00:00:") {
t.Errorf("Expected TXT to not contain timing information, got: %s", string(txtContent))
}
if strings.Contains(string(txtContent), "align:") {
t.Errorf("Expected TXT to not contain styling information, got: %s", string(txtContent))
}
if !strings.Contains(string(txtContent), "styled") {
t.Errorf("Expected TXT to contain text content, got: %s", string(txtContent))
}
t.Log("All integration tests passed!")
}
// TestIntegration_ErrorHandling tests how the CLI handles error conditions
func TestIntegration_ErrorHandling(t *testing.T) {
// Skip if not running integration tests
if os.Getenv("RUN_INTEGRATION_TESTS") == "" {
t.Skip("Skipping integration tests. Set RUN_INTEGRATION_TESTS=1 to run.")
}
// Get the path to the built binary
binaryPath := os.Getenv("BINARY_PATH")
if binaryPath == "" {
// Default to looking in the current directory
binaryPath = "sub-cli"
}
// Create temporary directory for test files
tempDir := t.TempDir()
// Test cases
testCases := []struct {
name string
args []string
errorMsg string
}{
{
name: "Nonexistent source file",
args: []string{"convert", "nonexistent.srt", filepath.Join(tempDir, "output.vtt")},
errorMsg: "no such file",
},
{
name: "Invalid source format",
args: []string{"convert", filepath.Join(tempDir, "test.xyz"), filepath.Join(tempDir, "output.vtt")},
errorMsg: "unsupported format",
},
{
name: "Invalid target format",
args: []string{"convert", filepath.Join(tempDir, "test.srt"), filepath.Join(tempDir, "output.xyz")},
errorMsg: "unsupported format",
},
{
name: "Sync different formats",
args: []string{"sync", filepath.Join(tempDir, "test.srt"), filepath.Join(tempDir, "test.lrc")},
errorMsg: "same format",
},
{
name: "Format unsupported file",
args: []string{"fmt", filepath.Join(tempDir, "test.txt")},
errorMsg: "unsupported format",
},
}
// Create a sample SRT file for testing
srtFile := filepath.Join(tempDir, "test.srt")
srtContent := `1
00:00:01,000 --> 00:00:04,000
This is a test subtitle.
`
if err := os.WriteFile(srtFile, []byte(srtContent), 0644); err != nil {
t.Fatalf("Failed to create SRT test file: %v", err)
}
// Create a sample LRC file for testing
lrcFile := filepath.Join(tempDir, "test.lrc")
lrcContent := `[00:01.00]This is a test lyric.
`
if err := os.WriteFile(lrcFile, []byte(lrcContent), 0644); err != nil {
t.Fatalf("Failed to create LRC test file: %v", err)
}
// Create a sample TXT file for testing
txtFile := filepath.Join(tempDir, "test.txt")
txtContent := `This is a plain text file.
`
if err := os.WriteFile(txtFile, []byte(txtContent), 0644); err != nil {
t.Fatalf("Failed to create TXT test file: %v", err)
}
// Run test cases
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
cmd := exec.Command(binaryPath, tc.args...)
output, err := cmd.CombinedOutput()
// We expect an error
if err == nil {
t.Fatalf("Expected command to fail, but it succeeded. Output: %s", output)
}
// Check error message
if !strings.Contains(string(output), tc.errorMsg) {
t.Errorf("Expected error message to contain '%s', got: %s", tc.errorMsg, output)
}
})
}
}