chore: seperate large files
This commit is contained in:
parent
ebbf516689
commit
76e1298ded
44 changed files with 5745 additions and 4173 deletions
|
@ -1,534 +0,0 @@
|
|||
package ass
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"sub-cli/internal/model"
|
||||
)
|
||||
|
||||
// 常量定义
|
||||
const (
|
||||
ASSHeader = "[Script Info]"
|
||||
ASSStylesHeader = "[V4+ Styles]"
|
||||
ASSEventsHeader = "[Events]"
|
||||
DefaultFormat = "Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text"
|
||||
)
|
||||
|
||||
// Parse 解析ASS文件为ASSFile结构
|
||||
func Parse(filePath string) (model.ASSFile, error) {
|
||||
file, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return model.ASSFile{}, fmt.Errorf("打开ASS文件失败: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
result := model.NewASSFile()
|
||||
|
||||
scanner := bufio.NewScanner(file)
|
||||
|
||||
// 当前解析的区块
|
||||
currentSection := ""
|
||||
var styleFormat, eventFormat []string
|
||||
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if line == "" || strings.HasPrefix(line, ";") {
|
||||
// 跳过空行和注释行
|
||||
continue
|
||||
}
|
||||
|
||||
// 检查章节标题
|
||||
if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") {
|
||||
currentSection = line
|
||||
continue
|
||||
}
|
||||
|
||||
switch currentSection {
|
||||
case ASSHeader:
|
||||
// 解析脚本信息
|
||||
if strings.Contains(line, ":") {
|
||||
parts := strings.SplitN(line, ":", 2)
|
||||
key := strings.TrimSpace(parts[0])
|
||||
value := strings.TrimSpace(parts[1])
|
||||
result.ScriptInfo[key] = value
|
||||
}
|
||||
|
||||
case ASSStylesHeader:
|
||||
// 解析样式格式行和样式定义
|
||||
if strings.HasPrefix(line, "Format:") {
|
||||
formatStr := strings.TrimPrefix(line, "Format:")
|
||||
styleFormat = parseFormatLine(formatStr)
|
||||
} else if strings.HasPrefix(line, "Style:") {
|
||||
styleValues := parseStyleLine(line)
|
||||
if len(styleFormat) > 0 && len(styleValues) > 0 {
|
||||
style := model.ASSStyle{
|
||||
Name: styleValues[0], // 第一个值通常是样式名称
|
||||
Properties: make(map[string]string),
|
||||
}
|
||||
|
||||
// 将原始格式行保存下来
|
||||
style.Properties["Format"] = "Name, " + strings.Join(styleFormat[1:], ", ")
|
||||
style.Properties["Style"] = strings.Join(styleValues, ", ")
|
||||
|
||||
// 解析各个样式属性
|
||||
for i := 0; i < len(styleFormat) && i < len(styleValues); i++ {
|
||||
style.Properties[styleFormat[i]] = styleValues[i]
|
||||
}
|
||||
|
||||
result.Styles = append(result.Styles, style)
|
||||
}
|
||||
}
|
||||
|
||||
case ASSEventsHeader:
|
||||
// 解析事件格式行和对话行
|
||||
if strings.HasPrefix(line, "Format:") {
|
||||
formatStr := strings.TrimPrefix(line, "Format:")
|
||||
eventFormat = parseFormatLine(formatStr)
|
||||
} else if len(eventFormat) > 0 &&
|
||||
(strings.HasPrefix(line, "Dialogue:") ||
|
||||
strings.HasPrefix(line, "Comment:")) {
|
||||
|
||||
eventType := "Dialogue"
|
||||
if strings.HasPrefix(line, "Comment:") {
|
||||
eventType = "Comment"
|
||||
line = strings.TrimPrefix(line, "Comment:")
|
||||
} else {
|
||||
line = strings.TrimPrefix(line, "Dialogue:")
|
||||
}
|
||||
|
||||
values := parseEventLine(line)
|
||||
if len(values) >= len(eventFormat) {
|
||||
event := model.NewASSEvent()
|
||||
event.Type = eventType
|
||||
|
||||
// 填充事件属性
|
||||
for i, format := range eventFormat {
|
||||
value := values[i]
|
||||
switch strings.TrimSpace(format) {
|
||||
case "Layer":
|
||||
layer, _ := strconv.Atoi(value)
|
||||
event.Layer = layer
|
||||
case "Start":
|
||||
event.StartTime = parseASSTimestamp(value)
|
||||
case "End":
|
||||
event.EndTime = parseASSTimestamp(value)
|
||||
case "Style":
|
||||
event.Style = value
|
||||
case "Name":
|
||||
event.Name = value
|
||||
case "MarginL":
|
||||
marginL, _ := strconv.Atoi(value)
|
||||
event.MarginL = marginL
|
||||
case "MarginR":
|
||||
marginR, _ := strconv.Atoi(value)
|
||||
event.MarginR = marginR
|
||||
case "MarginV":
|
||||
marginV, _ := strconv.Atoi(value)
|
||||
event.MarginV = marginV
|
||||
case "Effect":
|
||||
event.Effect = value
|
||||
case "Text":
|
||||
// 文本可能包含逗号,所以需要特殊处理
|
||||
textStartIndex := strings.Index(line, value)
|
||||
if textStartIndex >= 0 {
|
||||
event.Text = line[textStartIndex:]
|
||||
} else {
|
||||
event.Text = value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result.Events = append(result.Events, event)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := scanner.Err(); err != nil {
|
||||
return model.ASSFile{}, fmt.Errorf("读取ASS文件失败: %w", err)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Generate 生成ASS文件
|
||||
func Generate(assFile model.ASSFile, filePath string) error {
|
||||
// 确保目录存在
|
||||
dir := filepath.Dir(filePath)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return fmt.Errorf("创建目录失败: %w", err)
|
||||
}
|
||||
|
||||
file, err := os.Create(filePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("创建ASS文件失败: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
writer := bufio.NewWriter(file)
|
||||
|
||||
// 写入脚本信息
|
||||
writer.WriteString(ASSHeader + "\n")
|
||||
for key, value := range assFile.ScriptInfo {
|
||||
writer.WriteString(fmt.Sprintf("%s: %s\n", key, value))
|
||||
}
|
||||
writer.WriteString("\n")
|
||||
|
||||
// 写入样式信息
|
||||
writer.WriteString(ASSStylesHeader + "\n")
|
||||
if len(assFile.Styles) > 0 {
|
||||
// 获取样式格式
|
||||
format := "Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding"
|
||||
if style := assFile.Styles[0]; style.Properties["Format"] != "" {
|
||||
format = "Format: " + style.Properties["Format"]
|
||||
}
|
||||
writer.WriteString(format + "\n")
|
||||
|
||||
// 写入各个样式
|
||||
for _, style := range assFile.Styles {
|
||||
if style.Properties["Style"] != "" {
|
||||
writer.WriteString("Style: " + style.Properties["Style"] + "\n")
|
||||
} else {
|
||||
// 手动构造样式行
|
||||
writer.WriteString(fmt.Sprintf("Style: %s,Arial,20,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,2,2,10,10,10,1\n", style.Name))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 写入默认样式
|
||||
writer.WriteString("Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\n")
|
||||
writer.WriteString("Style: Default,Arial,20,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,2,2,10,10,10,1\n")
|
||||
}
|
||||
writer.WriteString("\n")
|
||||
|
||||
// 写入事件信息
|
||||
writer.WriteString(ASSEventsHeader + "\n")
|
||||
writer.WriteString(DefaultFormat + "\n")
|
||||
|
||||
// 写入各个对话行
|
||||
for _, event := range assFile.Events {
|
||||
startTime := formatASSTimestamp(event.StartTime)
|
||||
endTime := formatASSTimestamp(event.EndTime)
|
||||
|
||||
line := fmt.Sprintf("%s: %d,%s,%s,%s,%s,%d,%d,%d,%s,%s\n",
|
||||
event.Type,
|
||||
event.Layer,
|
||||
startTime,
|
||||
endTime,
|
||||
event.Style,
|
||||
event.Name,
|
||||
event.MarginL,
|
||||
event.MarginR,
|
||||
event.MarginV,
|
||||
event.Effect,
|
||||
event.Text)
|
||||
|
||||
writer.WriteString(line)
|
||||
}
|
||||
|
||||
return writer.Flush()
|
||||
}
|
||||
|
||||
// Format 格式化ASS文件
|
||||
func Format(filePath string) error {
|
||||
// 解析文件
|
||||
assFile, err := Parse(filePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 重新生成文件
|
||||
return Generate(assFile, filePath)
|
||||
}
|
||||
|
||||
// ConvertToSubtitle 将ASS文件转换为通用字幕格式
|
||||
func ConvertToSubtitle(filePath string) (model.Subtitle, error) {
|
||||
assFile, err := Parse(filePath)
|
||||
if err != nil {
|
||||
return model.Subtitle{}, err
|
||||
}
|
||||
|
||||
subtitle := model.NewSubtitle()
|
||||
subtitle.Format = "ass"
|
||||
|
||||
// 复制脚本信息到元数据
|
||||
for key, value := range assFile.ScriptInfo {
|
||||
subtitle.Metadata[key] = value
|
||||
}
|
||||
|
||||
// 复制样式信息到FormatData
|
||||
styleMap := make(map[string]model.ASSStyle)
|
||||
for _, style := range assFile.Styles {
|
||||
styleMap[style.Name] = style
|
||||
}
|
||||
subtitle.FormatData["styles"] = styleMap
|
||||
|
||||
// 转换事件到字幕条目
|
||||
for i, event := range assFile.Events {
|
||||
entry := model.NewSubtitleEntry()
|
||||
entry.Index = i + 1
|
||||
entry.StartTime = event.StartTime
|
||||
entry.EndTime = event.EndTime
|
||||
entry.Text = event.Text
|
||||
|
||||
// 保存ASS特定属性到FormatData
|
||||
eventData := make(map[string]interface{})
|
||||
eventData["type"] = event.Type
|
||||
eventData["layer"] = event.Layer
|
||||
eventData["style"] = event.Style
|
||||
eventData["name"] = event.Name
|
||||
eventData["marginL"] = event.MarginL
|
||||
eventData["marginR"] = event.MarginR
|
||||
eventData["marginV"] = event.MarginV
|
||||
eventData["effect"] = event.Effect
|
||||
entry.FormatData["ass"] = eventData
|
||||
|
||||
// 设置基本样式属性
|
||||
if style, ok := styleMap[event.Style]; ok {
|
||||
if bold, exists := style.Properties["Bold"]; exists && bold == "1" {
|
||||
entry.Styles["bold"] = "true"
|
||||
}
|
||||
if italic, exists := style.Properties["Italic"]; exists && italic == "1" {
|
||||
entry.Styles["italic"] = "true"
|
||||
}
|
||||
if underline, exists := style.Properties["Underline"]; exists && underline == "1" {
|
||||
entry.Styles["underline"] = "true"
|
||||
}
|
||||
}
|
||||
|
||||
subtitle.Entries = append(subtitle.Entries, entry)
|
||||
}
|
||||
|
||||
return subtitle, nil
|
||||
}
|
||||
|
||||
// ConvertFromSubtitle 将通用字幕格式转换为ASS文件
|
||||
func ConvertFromSubtitle(subtitle model.Subtitle, filePath string) error {
|
||||
assFile := model.NewASSFile()
|
||||
|
||||
// 复制元数据到脚本信息
|
||||
for key, value := range subtitle.Metadata {
|
||||
assFile.ScriptInfo[key] = value
|
||||
}
|
||||
|
||||
// 添加标题(如果有)
|
||||
if subtitle.Title != "" {
|
||||
assFile.ScriptInfo["Title"] = subtitle.Title
|
||||
}
|
||||
|
||||
// 从FormatData恢复样式(如果有)
|
||||
if styles, ok := subtitle.FormatData["styles"].(map[string]model.ASSStyle); ok {
|
||||
for _, style := range styles {
|
||||
assFile.Styles = append(assFile.Styles, style)
|
||||
}
|
||||
}
|
||||
|
||||
// 转换字幕条目到ASS事件
|
||||
for _, entry := range subtitle.Entries {
|
||||
event := model.NewASSEvent()
|
||||
event.StartTime = entry.StartTime
|
||||
event.EndTime = entry.EndTime
|
||||
event.Text = entry.Text
|
||||
|
||||
// 从FormatData恢复ASS特定属性(如果有)
|
||||
if assData, ok := entry.FormatData["ass"].(map[string]interface{}); ok {
|
||||
if eventType, ok := assData["type"].(string); ok {
|
||||
event.Type = eventType
|
||||
}
|
||||
if layer, ok := assData["layer"].(int); ok {
|
||||
event.Layer = layer
|
||||
}
|
||||
if style, ok := assData["style"].(string); ok {
|
||||
event.Style = style
|
||||
}
|
||||
if name, ok := assData["name"].(string); ok {
|
||||
event.Name = name
|
||||
}
|
||||
if marginL, ok := assData["marginL"].(int); ok {
|
||||
event.MarginL = marginL
|
||||
}
|
||||
if marginR, ok := assData["marginR"].(int); ok {
|
||||
event.MarginR = marginR
|
||||
}
|
||||
if marginV, ok := assData["marginV"].(int); ok {
|
||||
event.MarginV = marginV
|
||||
}
|
||||
if effect, ok := assData["effect"].(string); ok {
|
||||
event.Effect = effect
|
||||
}
|
||||
} else {
|
||||
// 根据基本样式设置ASS样式
|
||||
if _, ok := entry.Styles["bold"]; ok {
|
||||
// 创建一个加粗样式(如果尚未存在)
|
||||
styleName := "Bold"
|
||||
found := false
|
||||
for _, style := range assFile.Styles {
|
||||
if style.Name == styleName {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
boldStyle := model.ASSStyle{
|
||||
Name: styleName,
|
||||
Properties: map[string]string{
|
||||
"Bold": "1",
|
||||
},
|
||||
}
|
||||
assFile.Styles = append(assFile.Styles, boldStyle)
|
||||
}
|
||||
|
||||
event.Style = styleName
|
||||
}
|
||||
|
||||
if _, ok := entry.Styles["italic"]; ok {
|
||||
// 创建一个斜体样式(如果尚未存在)
|
||||
styleName := "Italic"
|
||||
found := false
|
||||
for _, style := range assFile.Styles {
|
||||
if style.Name == styleName {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
italicStyle := model.ASSStyle{
|
||||
Name: styleName,
|
||||
Properties: map[string]string{
|
||||
"Italic": "1",
|
||||
},
|
||||
}
|
||||
assFile.Styles = append(assFile.Styles, italicStyle)
|
||||
}
|
||||
|
||||
event.Style = styleName
|
||||
}
|
||||
|
||||
if _, ok := entry.Styles["underline"]; ok {
|
||||
// 创建一个下划线样式(如果尚未存在)
|
||||
styleName := "Underline"
|
||||
found := false
|
||||
for _, style := range assFile.Styles {
|
||||
if style.Name == styleName {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
underlineStyle := model.ASSStyle{
|
||||
Name: styleName,
|
||||
Properties: map[string]string{
|
||||
"Underline": "1",
|
||||
},
|
||||
}
|
||||
assFile.Styles = append(assFile.Styles, underlineStyle)
|
||||
}
|
||||
|
||||
event.Style = styleName
|
||||
}
|
||||
}
|
||||
|
||||
assFile.Events = append(assFile.Events, event)
|
||||
}
|
||||
|
||||
// 生成ASS文件
|
||||
return Generate(assFile, filePath)
|
||||
}
|
||||
|
||||
// 辅助函数
|
||||
|
||||
// parseFormatLine 解析格式行中的各个字段
|
||||
func parseFormatLine(formatStr string) []string {
|
||||
fields := strings.Split(formatStr, ",")
|
||||
result := make([]string, 0, len(fields))
|
||||
|
||||
for _, field := range fields {
|
||||
result = append(result, strings.TrimSpace(field))
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// parseStyleLine 解析样式行
|
||||
func parseStyleLine(line string) []string {
|
||||
// 去掉"Style:"前缀
|
||||
styleStr := strings.TrimPrefix(line, "Style:")
|
||||
return splitCSV(styleStr)
|
||||
}
|
||||
|
||||
// parseEventLine 解析事件行
|
||||
func parseEventLine(line string) []string {
|
||||
return splitCSV(line)
|
||||
}
|
||||
|
||||
// splitCSV 拆分CSV格式的字符串,但保留Text字段中的逗号
|
||||
func splitCSV(line string) []string {
|
||||
var result []string
|
||||
inText := false
|
||||
current := ""
|
||||
|
||||
for _, char := range line {
|
||||
if char == ',' && !inText {
|
||||
result = append(result, strings.TrimSpace(current))
|
||||
current = ""
|
||||
} else {
|
||||
current += string(char)
|
||||
// 这是个简化处理,实际ASS格式更复杂
|
||||
// 当处理到足够数量的字段后,剩余部分都当作Text字段
|
||||
if len(result) >= 9 {
|
||||
inText = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if current != "" {
|
||||
result = append(result, strings.TrimSpace(current))
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// parseASSTimestamp 解析ASS格式的时间戳 (h:mm:ss.cc)
|
||||
func parseASSTimestamp(timeStr string) model.Timestamp {
|
||||
// 匹配 h:mm:ss.cc 格式
|
||||
re := regexp.MustCompile(`(\d+):(\d+):(\d+)\.(\d+)`)
|
||||
matches := re.FindStringSubmatch(timeStr)
|
||||
|
||||
if len(matches) == 5 {
|
||||
hours, _ := strconv.Atoi(matches[1])
|
||||
minutes, _ := strconv.Atoi(matches[2])
|
||||
seconds, _ := strconv.Atoi(matches[3])
|
||||
// ASS使用厘秒(1/100秒),需要转换为毫秒
|
||||
centiseconds, _ := strconv.Atoi(matches[4])
|
||||
milliseconds := centiseconds * 10
|
||||
|
||||
return model.Timestamp{
|
||||
Hours: hours,
|
||||
Minutes: minutes,
|
||||
Seconds: seconds,
|
||||
Milliseconds: milliseconds,
|
||||
}
|
||||
}
|
||||
|
||||
// 返回零时间戳,如果解析失败
|
||||
return model.Timestamp{}
|
||||
}
|
||||
|
||||
// formatASSTimestamp 格式化为ASS格式的时间戳 (h:mm:ss.cc)
|
||||
func formatASSTimestamp(timestamp model.Timestamp) string {
|
||||
// ASS使用厘秒(1/100秒)
|
||||
centiseconds := timestamp.Milliseconds / 10
|
||||
return fmt.Sprintf("%d:%02d:%02d.%02d",
|
||||
timestamp.Hours,
|
||||
timestamp.Minutes,
|
||||
timestamp.Seconds,
|
||||
centiseconds)
|
||||
}
|
|
@ -1,529 +0,0 @@
|
|||
package ass
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"sub-cli/internal/model"
|
||||
)
|
||||
|
||||
func TestParse(t *testing.T) {
|
||||
// Create temporary test file
|
||||
content := `[Script Info]
|
||||
ScriptType: v4.00+
|
||||
Title: Test ASS File
|
||||
PlayResX: 640
|
||||
PlayResY: 480
|
||||
|
||||
[V4+ Styles]
|
||||
Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
|
||||
Style: Default,Arial,20,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,2,2,10,10,10,1
|
||||
Style: Bold,Arial,20,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,1,0,0,0,100,100,0,0,1,2,2,2,10,10,10,1
|
||||
|
||||
[Events]
|
||||
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
|
||||
Dialogue: 0,0:00:01.00,0:00:04.00,Default,,0,0,0,,This is the first subtitle line.
|
||||
Dialogue: 0,0:00:05.00,0:00:08.00,Bold,,0,0,0,,This is the second subtitle line with bold style.
|
||||
Comment: 0,0:00:09.00,0:00:12.00,Default,,0,0,0,,This is a comment.
|
||||
`
|
||||
tempDir := t.TempDir()
|
||||
testFile := filepath.Join(tempDir, "test.ass")
|
||||
if err := os.WriteFile(testFile, []byte(content), 0644); err != nil {
|
||||
t.Fatalf("Failed to create test file: %v", err)
|
||||
}
|
||||
|
||||
// Test parsing
|
||||
assFile, err := Parse(testFile)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify results
|
||||
// Script info
|
||||
if assFile.ScriptInfo["Title"] != "Test ASS File" {
|
||||
t.Errorf("Title mismatch: expected 'Test ASS File', got '%s'", assFile.ScriptInfo["Title"])
|
||||
}
|
||||
if assFile.ScriptInfo["ScriptType"] != "v4.00+" {
|
||||
t.Errorf("Script type mismatch: expected 'v4.00+', got '%s'", assFile.ScriptInfo["ScriptType"])
|
||||
}
|
||||
|
||||
// Styles
|
||||
if len(assFile.Styles) != 3 {
|
||||
t.Errorf("Expected 3 styles, got %d", len(assFile.Styles))
|
||||
} else {
|
||||
// Find Bold style
|
||||
var boldStyle *model.ASSStyle
|
||||
for i, style := range assFile.Styles {
|
||||
if style.Name == "Bold" {
|
||||
boldStyle = &assFile.Styles[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if boldStyle == nil {
|
||||
t.Errorf("Bold style not found")
|
||||
} else {
|
||||
boldValue, exists := boldStyle.Properties["Bold"]
|
||||
if !exists || boldValue != "1" {
|
||||
t.Errorf("Bold style Bold property mismatch: expected '1', got '%s'", boldValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Events
|
||||
if len(assFile.Events) != 3 {
|
||||
t.Errorf("Expected 3 events, got %d", len(assFile.Events))
|
||||
} else {
|
||||
// Check first dialogue line
|
||||
if assFile.Events[0].Type != "Dialogue" {
|
||||
t.Errorf("First event type mismatch: expected 'Dialogue', got '%s'", assFile.Events[0].Type)
|
||||
}
|
||||
if assFile.Events[0].StartTime.Seconds != 1 || assFile.Events[0].StartTime.Milliseconds != 0 {
|
||||
t.Errorf("First event start time mismatch: expected 00:00:01.00, got %d:%02d:%02d.%03d",
|
||||
assFile.Events[0].StartTime.Hours, assFile.Events[0].StartTime.Minutes,
|
||||
assFile.Events[0].StartTime.Seconds, assFile.Events[0].StartTime.Milliseconds)
|
||||
}
|
||||
if assFile.Events[0].Text != "This is the first subtitle line." {
|
||||
t.Errorf("First event text mismatch: expected 'This is the first subtitle line.', got '%s'", assFile.Events[0].Text)
|
||||
}
|
||||
|
||||
// Check second dialogue line (bold style)
|
||||
if assFile.Events[1].Style != "Bold" {
|
||||
t.Errorf("Second event style mismatch: expected 'Bold', got '%s'", assFile.Events[1].Style)
|
||||
}
|
||||
|
||||
// Check comment line
|
||||
if assFile.Events[2].Type != "Comment" {
|
||||
t.Errorf("Third event type mismatch: expected 'Comment', got '%s'", assFile.Events[2].Type)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerate(t *testing.T) {
|
||||
// Create test ASS file structure
|
||||
assFile := model.NewASSFile()
|
||||
assFile.ScriptInfo["Title"] = "Generation Test"
|
||||
|
||||
// Add a custom style
|
||||
boldStyle := model.ASSStyle{
|
||||
Name: "Bold",
|
||||
Properties: map[string]string{
|
||||
"Format": "Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding",
|
||||
"Style": "Bold,Arial,20,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,1,0,0,0,100,100,0,0,1,2,2,2,10,10,10,1",
|
||||
"Bold": "1",
|
||||
},
|
||||
}
|
||||
assFile.Styles = append(assFile.Styles, boldStyle)
|
||||
|
||||
// Add two dialogue events
|
||||
event1 := model.NewASSEvent()
|
||||
event1.StartTime = model.Timestamp{Seconds: 1}
|
||||
event1.EndTime = model.Timestamp{Seconds: 4}
|
||||
event1.Text = "This is the first line."
|
||||
|
||||
event2 := model.NewASSEvent()
|
||||
event2.StartTime = model.Timestamp{Seconds: 5}
|
||||
event2.EndTime = model.Timestamp{Seconds: 8}
|
||||
event2.Style = "Bold"
|
||||
event2.Text = "This is the second line with bold style."
|
||||
|
||||
assFile.Events = append(assFile.Events, event1, event2)
|
||||
|
||||
// Generate ASS file
|
||||
tempDir := t.TempDir()
|
||||
outputFile := filepath.Join(tempDir, "output.ass")
|
||||
err := Generate(assFile, outputFile)
|
||||
if err != nil {
|
||||
t.Fatalf("Generation failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify generated content
|
||||
content, err := os.ReadFile(outputFile)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read output file: %v", err)
|
||||
}
|
||||
|
||||
contentStr := string(content)
|
||||
|
||||
// Check script info
|
||||
if !strings.Contains(contentStr, "Title: Generation Test") {
|
||||
t.Errorf("Output file should contain title 'Title: Generation Test'")
|
||||
}
|
||||
|
||||
// Check styles
|
||||
if !strings.Contains(contentStr, "Style: Bold,Arial,20") {
|
||||
t.Errorf("Output file should contain Bold style")
|
||||
}
|
||||
|
||||
// Check dialogue lines
|
||||
if !strings.Contains(contentStr, "Dialogue: 0,0:00:01.00,0:00:04.00,Default") {
|
||||
t.Errorf("Output file should contain first dialogue line")
|
||||
}
|
||||
if !strings.Contains(contentStr, "This is the first line.") {
|
||||
t.Errorf("Output file should contain first line text")
|
||||
}
|
||||
|
||||
if !strings.Contains(contentStr, "Dialogue: 0,0:00:05.00,0:00:08.00,Bold") {
|
||||
t.Errorf("Output file should contain second dialogue line")
|
||||
}
|
||||
if !strings.Contains(contentStr, "This is the second line with bold style.") {
|
||||
t.Errorf("Output file should contain second line text")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormat(t *testing.T) {
|
||||
// Create test file (intentionally with mixed formatting)
|
||||
content := `[Script Info]
|
||||
ScriptType:v4.00+
|
||||
Title: Formatting Test
|
||||
|
||||
[V4+ Styles]
|
||||
Format:Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
|
||||
Style:Default,Arial,20,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,2,2,10,10,10,1
|
||||
|
||||
[Events]
|
||||
Format:Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
|
||||
Dialogue:0,0:0:1.0,0:0:4.0,Default,,0,0,0,,Text before formatting.
|
||||
`
|
||||
tempDir := t.TempDir()
|
||||
testFile := filepath.Join(tempDir, "format_test.ass")
|
||||
if err := os.WriteFile(testFile, []byte(content), 0644); err != nil {
|
||||
t.Fatalf("Failed to create test file: %v", err)
|
||||
}
|
||||
|
||||
// Test formatting
|
||||
err := Format(testFile)
|
||||
if err != nil {
|
||||
t.Fatalf("Formatting failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify formatted file
|
||||
formattedContent, err := os.ReadFile(testFile)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read formatted file: %v", err)
|
||||
}
|
||||
|
||||
formattedStr := string(formattedContent)
|
||||
|
||||
// Check formatting
|
||||
if !strings.Contains(formattedStr, "ScriptType: v4.00+") {
|
||||
t.Errorf("Formatted file should contain standardized ScriptType line")
|
||||
}
|
||||
|
||||
if !strings.Contains(formattedStr, "Title: Formatting Test") {
|
||||
t.Errorf("Formatted file should contain standardized Title line")
|
||||
}
|
||||
|
||||
// Check timestamp formatting
|
||||
if !strings.Contains(formattedStr, "0:00:01.00,0:00:04.00") {
|
||||
t.Errorf("Formatted file should contain standardized timestamp format (0:00:01.00,0:00:04.00)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertToSubtitle(t *testing.T) {
|
||||
// Create test file
|
||||
content := `[Script Info]
|
||||
ScriptType: v4.00+
|
||||
Title: Conversion Test
|
||||
|
||||
[V4+ Styles]
|
||||
Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
|
||||
Style: Default,Arial,20,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,2,2,10,10,10,1
|
||||
Style: Bold,Arial,20,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,1,0,0,0,100,100,0,0,1,2,2,2,10,10,10,1
|
||||
Style: Italic,Arial,20,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,1,0,0,100,100,0,0,1,2,2,2,10,10,10,1
|
||||
|
||||
[Events]
|
||||
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
|
||||
Dialogue: 0,0:00:01.00,0:00:04.00,Default,,0,0,0,,Normal text.
|
||||
Dialogue: 0,0:00:05.00,0:00:08.00,Bold,,0,0,0,,Bold text.
|
||||
Dialogue: 0,0:00:09.00,0:00:12.00,Italic,,0,0,0,,Italic text.
|
||||
`
|
||||
tempDir := t.TempDir()
|
||||
testFile := filepath.Join(tempDir, "convert_test.ass")
|
||||
if err := os.WriteFile(testFile, []byte(content), 0644); err != nil {
|
||||
t.Fatalf("Failed to create test file: %v", err)
|
||||
}
|
||||
|
||||
// Convert to subtitle
|
||||
subtitle, err := ConvertToSubtitle(testFile)
|
||||
if err != nil {
|
||||
t.Fatalf("Conversion failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify results
|
||||
if subtitle.Format != "ass" {
|
||||
t.Errorf("Expected format 'ass', got '%s'", subtitle.Format)
|
||||
}
|
||||
|
||||
if subtitle.Metadata["Title"] != "Conversion Test" {
|
||||
t.Errorf("Expected title 'Conversion Test', got '%s'", subtitle.Metadata["Title"])
|
||||
}
|
||||
|
||||
if len(subtitle.Entries) != 3 {
|
||||
t.Errorf("Expected 3 entries, got %d", len(subtitle.Entries))
|
||||
} else {
|
||||
// Check first entry
|
||||
if subtitle.Entries[0].Text != "Normal text." {
|
||||
t.Errorf("First entry text mismatch: expected 'Normal text.', got '%s'", subtitle.Entries[0].Text)
|
||||
}
|
||||
|
||||
// Check second entry (bold)
|
||||
if subtitle.Entries[1].Text != "Bold text." {
|
||||
t.Errorf("Second entry text mismatch: expected 'Bold text.', got '%s'", subtitle.Entries[1].Text)
|
||||
}
|
||||
bold, ok := subtitle.Entries[1].Styles["bold"]
|
||||
if !ok || bold != "true" {
|
||||
t.Errorf("Second entry should have bold=true style")
|
||||
}
|
||||
|
||||
// Check third entry (italic)
|
||||
if subtitle.Entries[2].Text != "Italic text." {
|
||||
t.Errorf("Third entry text mismatch: expected 'Italic text.', got '%s'", subtitle.Entries[2].Text)
|
||||
}
|
||||
italic, ok := subtitle.Entries[2].Styles["italic"]
|
||||
if !ok || italic != "true" {
|
||||
t.Errorf("Third entry should have italic=true style")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertFromSubtitle(t *testing.T) {
|
||||
// Create test subtitle
|
||||
subtitle := model.NewSubtitle()
|
||||
subtitle.Format = "ass"
|
||||
subtitle.Title = "Conversion from Subtitle Test"
|
||||
|
||||
// Create a normal entry
|
||||
entry1 := model.NewSubtitleEntry()
|
||||
entry1.Index = 1
|
||||
entry1.StartTime = model.Timestamp{Seconds: 1}
|
||||
entry1.EndTime = model.Timestamp{Seconds: 4}
|
||||
entry1.Text = "Normal text."
|
||||
|
||||
// Create a bold entry
|
||||
entry2 := model.NewSubtitleEntry()
|
||||
entry2.Index = 2
|
||||
entry2.StartTime = model.Timestamp{Seconds: 5}
|
||||
entry2.EndTime = model.Timestamp{Seconds: 8}
|
||||
entry2.Text = "Bold text."
|
||||
entry2.Styles["bold"] = "true"
|
||||
|
||||
// Create an italic entry
|
||||
entry3 := model.NewSubtitleEntry()
|
||||
entry3.Index = 3
|
||||
entry3.StartTime = model.Timestamp{Seconds: 9}
|
||||
entry3.EndTime = model.Timestamp{Seconds: 12}
|
||||
entry3.Text = "Italic text."
|
||||
entry3.Styles["italic"] = "true"
|
||||
|
||||
subtitle.Entries = append(subtitle.Entries, entry1, entry2, entry3)
|
||||
|
||||
// Convert from subtitle
|
||||
tempDir := t.TempDir()
|
||||
outputFile := filepath.Join(tempDir, "convert_from_subtitle.ass")
|
||||
err := ConvertFromSubtitle(subtitle, outputFile)
|
||||
if err != nil {
|
||||
t.Fatalf("Conversion failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify converted ASS file
|
||||
assFile, err := Parse(outputFile)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to parse converted file: %v", err)
|
||||
}
|
||||
|
||||
// Check script info
|
||||
if assFile.ScriptInfo["Title"] != "Conversion from Subtitle Test" {
|
||||
t.Errorf("Expected title 'Conversion from Subtitle Test', got '%s'", assFile.ScriptInfo["Title"])
|
||||
}
|
||||
|
||||
// Check events
|
||||
if len(assFile.Events) != 3 {
|
||||
t.Errorf("Expected 3 events, got %d", len(assFile.Events))
|
||||
} else {
|
||||
// Check first dialogue line
|
||||
if assFile.Events[0].Text != "Normal text." {
|
||||
t.Errorf("First event text mismatch: expected 'Normal text.', got '%s'", assFile.Events[0].Text)
|
||||
}
|
||||
|
||||
// Check second dialogue line (bold)
|
||||
if assFile.Events[1].Text != "Bold text." {
|
||||
t.Errorf("Second event text mismatch: expected 'Bold text.', got '%s'", assFile.Events[1].Text)
|
||||
}
|
||||
if assFile.Events[1].Style != "Bold" {
|
||||
t.Errorf("Second event should use Bold style, got '%s'", assFile.Events[1].Style)
|
||||
}
|
||||
|
||||
// Check third dialogue line (italic)
|
||||
if assFile.Events[2].Text != "Italic text." {
|
||||
t.Errorf("Third event text mismatch: expected 'Italic text.', got '%s'", assFile.Events[2].Text)
|
||||
}
|
||||
if assFile.Events[2].Style != "Italic" {
|
||||
t.Errorf("Third event should use Italic style, got '%s'", assFile.Events[2].Style)
|
||||
}
|
||||
}
|
||||
|
||||
// Check styles
|
||||
styleNames := make(map[string]bool)
|
||||
for _, style := range assFile.Styles {
|
||||
styleNames[style.Name] = true
|
||||
}
|
||||
|
||||
if !styleNames["Bold"] {
|
||||
t.Errorf("Should contain Bold style")
|
||||
}
|
||||
if !styleNames["Italic"] {
|
||||
t.Errorf("Should contain Italic style")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParse_EdgeCases(t *testing.T) {
|
||||
// Test empty file
|
||||
tempDir := t.TempDir()
|
||||
emptyFile := filepath.Join(tempDir, "empty.ass")
|
||||
if err := os.WriteFile(emptyFile, []byte{}, 0644); err != nil {
|
||||
t.Fatalf("Failed to create empty test file: %v", err)
|
||||
}
|
||||
|
||||
assFile, err := Parse(emptyFile)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to parse empty file: %v", err)
|
||||
}
|
||||
|
||||
if len(assFile.Events) != 0 {
|
||||
t.Errorf("Empty file should have 0 events, got %d", len(assFile.Events))
|
||||
}
|
||||
|
||||
// Test file missing required sections
|
||||
malformedContent := `[Script Info]
|
||||
Title: Missing Sections Test
|
||||
`
|
||||
malformedFile := filepath.Join(tempDir, "malformed.ass")
|
||||
if err := os.WriteFile(malformedFile, []byte(malformedContent), 0644); err != nil {
|
||||
t.Fatalf("Failed to create malformed file: %v", err)
|
||||
}
|
||||
|
||||
assFile, err = Parse(malformedFile)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to parse malformed file: %v", err)
|
||||
}
|
||||
|
||||
if assFile.ScriptInfo["Title"] != "Missing Sections Test" {
|
||||
t.Errorf("Should correctly parse the title")
|
||||
}
|
||||
if len(assFile.Events) != 0 {
|
||||
t.Errorf("File missing Events section should have 0 events")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParse_FileError(t *testing.T) {
|
||||
// Test non-existent file
|
||||
_, err := Parse("/nonexistent/file.ass")
|
||||
if err == nil {
|
||||
t.Error("Parsing non-existent file should return an error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerate_FileError(t *testing.T) {
|
||||
// Test invalid path
|
||||
assFile := model.NewASSFile()
|
||||
err := Generate(assFile, "/nonexistent/directory/file.ass")
|
||||
if err == nil {
|
||||
t.Error("Generating to invalid path should return an error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertToSubtitle_FileError(t *testing.T) {
|
||||
// Test non-existent file
|
||||
_, err := ConvertToSubtitle("/nonexistent/file.ass")
|
||||
if err == nil {
|
||||
t.Error("Converting non-existent file should return an error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertFromSubtitle_FileError(t *testing.T) {
|
||||
// Test invalid path
|
||||
subtitle := model.NewSubtitle()
|
||||
err := ConvertFromSubtitle(subtitle, "/nonexistent/directory/file.ass")
|
||||
if err == nil {
|
||||
t.Error("Converting to invalid path should return an error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseASSTimestamp(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
input string
|
||||
expected model.Timestamp
|
||||
}{
|
||||
{
|
||||
name: "Standard format",
|
||||
input: "0:00:01.00",
|
||||
expected: model.Timestamp{Hours: 0, Minutes: 0, Seconds: 1, Milliseconds: 0},
|
||||
},
|
||||
{
|
||||
name: "With centiseconds",
|
||||
input: "0:00:01.50",
|
||||
expected: model.Timestamp{Hours: 0, Minutes: 0, Seconds: 1, Milliseconds: 500},
|
||||
},
|
||||
{
|
||||
name: "Complete hours, minutes, seconds",
|
||||
input: "1:02:03.45",
|
||||
expected: model.Timestamp{Hours: 1, Minutes: 2, Seconds: 3, Milliseconds: 450},
|
||||
},
|
||||
{
|
||||
name: "Invalid format",
|
||||
input: "invalid",
|
||||
expected: model.Timestamp{},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
result := parseASSTimestamp(tc.input)
|
||||
if result != tc.expected {
|
||||
t.Errorf("For input '%s', expected %+v, got %+v", tc.input, tc.expected, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatASSTimestamp(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
input model.Timestamp
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "Zero timestamp",
|
||||
input: model.Timestamp{},
|
||||
expected: "0:00:00.00",
|
||||
},
|
||||
{
|
||||
name: "Simple seconds",
|
||||
input: model.Timestamp{Hours: 0, Minutes: 0, Seconds: 1, Milliseconds: 0},
|
||||
expected: "0:00:01.00",
|
||||
},
|
||||
{
|
||||
name: "With milliseconds",
|
||||
input: model.Timestamp{Hours: 0, Minutes: 0, Seconds: 1, Milliseconds: 500},
|
||||
expected: "0:00:01.50",
|
||||
},
|
||||
{
|
||||
name: "Complete timestamp",
|
||||
input: model.Timestamp{Hours: 1, Minutes: 2, Seconds: 3, Milliseconds: 450},
|
||||
expected: "1:02:03.45",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
result := formatASSTimestamp(tc.input)
|
||||
if result != tc.expected {
|
||||
t.Errorf("For timestamp %+v, expected '%s', got '%s'", tc.input, tc.expected, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
186
internal/format/ass/converter.go
Normal file
186
internal/format/ass/converter.go
Normal file
|
@ -0,0 +1,186 @@
|
|||
package ass
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"sub-cli/internal/model"
|
||||
)
|
||||
|
||||
// ConvertToSubtitle 将ASS文件转换为通用字幕格式
|
||||
func ConvertToSubtitle(filePath string) (model.Subtitle, error) {
|
||||
// 解析ASS文件
|
||||
assFile, err := Parse(filePath)
|
||||
if err != nil {
|
||||
return model.Subtitle{}, fmt.Errorf("解析ASS文件失败: %w", err)
|
||||
}
|
||||
|
||||
// 创建通用字幕结构
|
||||
subtitle := model.NewSubtitle()
|
||||
subtitle.Format = "ass"
|
||||
|
||||
// 转换标题
|
||||
if title, ok := assFile.ScriptInfo["Title"]; ok {
|
||||
subtitle.Title = title
|
||||
}
|
||||
|
||||
// 转换事件为字幕条目
|
||||
for i, event := range assFile.Events {
|
||||
// 只转换对话类型的事件
|
||||
if event.Type == "Dialogue" {
|
||||
entry := model.SubtitleEntry{
|
||||
Index: i + 1,
|
||||
StartTime: event.StartTime,
|
||||
EndTime: event.EndTime,
|
||||
Text: event.Text,
|
||||
Styles: make(map[string]string),
|
||||
Metadata: make(map[string]string),
|
||||
}
|
||||
|
||||
// 记录样式信息
|
||||
entry.Styles["style"] = event.Style
|
||||
|
||||
// 记录ASS特有信息
|
||||
entry.Metadata["Layer"] = fmt.Sprintf("%d", event.Layer)
|
||||
entry.Metadata["Name"] = event.Name
|
||||
entry.Metadata["MarginL"] = fmt.Sprintf("%d", event.MarginL)
|
||||
entry.Metadata["MarginR"] = fmt.Sprintf("%d", event.MarginR)
|
||||
entry.Metadata["MarginV"] = fmt.Sprintf("%d", event.MarginV)
|
||||
entry.Metadata["Effect"] = event.Effect
|
||||
|
||||
subtitle.Entries = append(subtitle.Entries, entry)
|
||||
}
|
||||
}
|
||||
|
||||
return subtitle, nil
|
||||
}
|
||||
|
||||
// ConvertFromSubtitle 将通用字幕格式转换为ASS文件
|
||||
func ConvertFromSubtitle(subtitle model.Subtitle, filePath string) error {
|
||||
// 创建ASS文件结构
|
||||
assFile := model.NewASSFile()
|
||||
|
||||
// 设置标题
|
||||
if subtitle.Title != "" {
|
||||
assFile.ScriptInfo["Title"] = subtitle.Title
|
||||
}
|
||||
|
||||
// 转换字幕条目为ASS事件
|
||||
for _, entry := range subtitle.Entries {
|
||||
event := model.NewASSEvent()
|
||||
event.Type = "Dialogue"
|
||||
event.StartTime = entry.StartTime
|
||||
event.EndTime = entry.EndTime
|
||||
event.Text = entry.Text
|
||||
|
||||
// 检查是否有ASS特有的元数据
|
||||
if layer, ok := entry.Metadata["Layer"]; ok {
|
||||
fmt.Sscanf(layer, "%d", &event.Layer)
|
||||
}
|
||||
|
||||
if name, ok := entry.Metadata["Name"]; ok {
|
||||
event.Name = name
|
||||
}
|
||||
|
||||
if marginL, ok := entry.Metadata["MarginL"]; ok {
|
||||
fmt.Sscanf(marginL, "%d", &event.MarginL)
|
||||
}
|
||||
|
||||
if marginR, ok := entry.Metadata["MarginR"]; ok {
|
||||
fmt.Sscanf(marginR, "%d", &event.MarginR)
|
||||
}
|
||||
|
||||
if marginV, ok := entry.Metadata["MarginV"]; ok {
|
||||
fmt.Sscanf(marginV, "%d", &event.MarginV)
|
||||
}
|
||||
|
||||
if effect, ok := entry.Metadata["Effect"]; ok {
|
||||
event.Effect = effect
|
||||
}
|
||||
|
||||
// 处理样式
|
||||
if style, ok := entry.Styles["style"]; ok {
|
||||
event.Style = style
|
||||
} else {
|
||||
// 根据基本样式设置ASS样式
|
||||
if _, ok := entry.Styles["bold"]; ok {
|
||||
// 创建一个加粗样式(如果尚未存在)
|
||||
styleName := "Bold"
|
||||
found := false
|
||||
for _, style := range assFile.Styles {
|
||||
if style.Name == styleName {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
boldStyle := model.ASSStyle{
|
||||
Name: styleName,
|
||||
Properties: map[string]string{
|
||||
"Format": "Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding",
|
||||
"Style": "Bold,Arial,20,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,1,0,0,0,100,100,0,0,1,2,2,2,10,10,10,1",
|
||||
},
|
||||
}
|
||||
assFile.Styles = append(assFile.Styles, boldStyle)
|
||||
}
|
||||
|
||||
event.Style = styleName
|
||||
}
|
||||
|
||||
if _, ok := entry.Styles["italic"]; ok {
|
||||
// 创建一个斜体样式(如果尚未存在)
|
||||
styleName := "Italic"
|
||||
found := false
|
||||
for _, style := range assFile.Styles {
|
||||
if style.Name == styleName {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
italicStyle := model.ASSStyle{
|
||||
Name: styleName,
|
||||
Properties: map[string]string{
|
||||
"Format": "Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding",
|
||||
"Style": "Italic,Arial,20,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,1,0,0,100,100,0,0,1,2,2,2,10,10,10,1",
|
||||
},
|
||||
}
|
||||
assFile.Styles = append(assFile.Styles, italicStyle)
|
||||
}
|
||||
|
||||
event.Style = styleName
|
||||
}
|
||||
|
||||
if _, ok := entry.Styles["underline"]; ok {
|
||||
// 创建一个下划线样式(如果尚未存在)
|
||||
styleName := "Underline"
|
||||
found := false
|
||||
for _, style := range assFile.Styles {
|
||||
if style.Name == styleName {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
underlineStyle := model.ASSStyle{
|
||||
Name: styleName,
|
||||
Properties: map[string]string{
|
||||
"Format": "Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding",
|
||||
"Style": "Underline,Arial,20,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,1,0,100,100,0,0,1,2,2,2,10,10,10,1",
|
||||
},
|
||||
}
|
||||
assFile.Styles = append(assFile.Styles, underlineStyle)
|
||||
}
|
||||
|
||||
event.Style = styleName
|
||||
}
|
||||
}
|
||||
|
||||
assFile.Events = append(assFile.Events, event)
|
||||
}
|
||||
|
||||
// 生成ASS文件
|
||||
return Generate(assFile, filePath)
|
||||
}
|
210
internal/format/ass/converter_test.go
Normal file
210
internal/format/ass/converter_test.go
Normal file
|
@ -0,0 +1,210 @@
|
|||
package ass
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"sub-cli/internal/model"
|
||||
)
|
||||
|
||||
func TestConvertToSubtitle(t *testing.T) {
|
||||
// Create test ASS file
|
||||
content := `[Script Info]
|
||||
ScriptType: v4.00+
|
||||
Title: Test ASS File
|
||||
PlayResX: 640
|
||||
PlayResY: 480
|
||||
|
||||
[V4+ Styles]
|
||||
Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
|
||||
Style: Default,Arial,20,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,2,2,10,10,10,1
|
||||
Style: Bold,Arial,20,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,1,0,0,0,100,100,0,0,1,2,2,2,10,10,10,1
|
||||
|
||||
[Events]
|
||||
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
|
||||
Dialogue: 0,0:00:01.00,0:00:04.00,Default,Character,10,20,30,Fade,This is the first subtitle line.
|
||||
Dialogue: 1,0:00:05.00,0:00:08.00,Bold,Character,15,25,35,,This is the second subtitle line with bold style.
|
||||
Comment: 0,0:00:09.00,0:00:12.00,Default,,0,0,0,,This is a comment.
|
||||
`
|
||||
tempDir := t.TempDir()
|
||||
testFile := filepath.Join(tempDir, "convert_test.ass")
|
||||
if err := os.WriteFile(testFile, []byte(content), 0644); err != nil {
|
||||
t.Fatalf("Failed to create test file: %v", err)
|
||||
}
|
||||
|
||||
// Test conversion to Subtitle
|
||||
subtitle, err := ConvertToSubtitle(testFile)
|
||||
if err != nil {
|
||||
t.Fatalf("ConvertToSubtitle failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify results
|
||||
if subtitle.Format != "ass" {
|
||||
t.Errorf("Format should be 'ass', got '%s'", subtitle.Format)
|
||||
}
|
||||
|
||||
if subtitle.Title != "Test ASS File" {
|
||||
t.Errorf("Title should be 'Test ASS File', got '%s'", subtitle.Title)
|
||||
}
|
||||
|
||||
// Only dialogue events should be converted
|
||||
if len(subtitle.Entries) != 2 {
|
||||
t.Errorf("Expected 2 subtitle entries, got %d", len(subtitle.Entries))
|
||||
} else {
|
||||
// Check first entry
|
||||
if subtitle.Entries[0].Text != "This is the first subtitle line." {
|
||||
t.Errorf("First entry text mismatch: got '%s'", subtitle.Entries[0].Text)
|
||||
}
|
||||
|
||||
if subtitle.Entries[0].StartTime.Seconds != 1 || subtitle.Entries[0].EndTime.Seconds != 4 {
|
||||
t.Errorf("First entry timing mismatch: got %+v - %+v",
|
||||
subtitle.Entries[0].StartTime, subtitle.Entries[0].EndTime)
|
||||
}
|
||||
|
||||
// Check style conversion
|
||||
if subtitle.Entries[0].Styles["style"] != "Default" {
|
||||
t.Errorf("First entry style mismatch: got '%s'", subtitle.Entries[0].Styles["style"])
|
||||
}
|
||||
|
||||
// Check metadata conversion
|
||||
if subtitle.Entries[0].Metadata["Layer"] != "0" {
|
||||
t.Errorf("First entry layer mismatch: got '%s'", subtitle.Entries[0].Metadata["Layer"])
|
||||
}
|
||||
|
||||
if subtitle.Entries[0].Metadata["Name"] != "Character" {
|
||||
t.Errorf("First entry name mismatch: got '%s'", subtitle.Entries[0].Metadata["Name"])
|
||||
}
|
||||
|
||||
if subtitle.Entries[0].Metadata["MarginL"] != "10" ||
|
||||
subtitle.Entries[0].Metadata["MarginR"] != "20" ||
|
||||
subtitle.Entries[0].Metadata["MarginV"] != "30" {
|
||||
t.Errorf("First entry margins mismatch: got L=%s, R=%s, V=%s",
|
||||
subtitle.Entries[0].Metadata["MarginL"],
|
||||
subtitle.Entries[0].Metadata["MarginR"],
|
||||
subtitle.Entries[0].Metadata["MarginV"])
|
||||
}
|
||||
|
||||
if subtitle.Entries[0].Metadata["Effect"] != "Fade" {
|
||||
t.Errorf("First entry effect mismatch: got '%s'", subtitle.Entries[0].Metadata["Effect"])
|
||||
}
|
||||
|
||||
// Check second entry (Bold style)
|
||||
if subtitle.Entries[1].Styles["style"] != "Bold" {
|
||||
t.Errorf("Second entry style mismatch: got '%s'", subtitle.Entries[1].Styles["style"])
|
||||
}
|
||||
|
||||
if subtitle.Entries[1].Metadata["Layer"] != "1" {
|
||||
t.Errorf("Second entry layer mismatch: got '%s'", subtitle.Entries[1].Metadata["Layer"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertFromSubtitle(t *testing.T) {
|
||||
// Create test subtitle
|
||||
subtitle := model.NewSubtitle()
|
||||
subtitle.Format = "ass"
|
||||
subtitle.Title = "Test Conversion"
|
||||
|
||||
// Create entries
|
||||
entry1 := model.SubtitleEntry{
|
||||
Index: 1,
|
||||
StartTime: model.Timestamp{Seconds: 1},
|
||||
EndTime: model.Timestamp{Seconds: 4},
|
||||
Text: "This is the first subtitle line.",
|
||||
Styles: map[string]string{"style": "Default"},
|
||||
Metadata: map[string]string{
|
||||
"Layer": "0",
|
||||
"Name": "Character",
|
||||
"MarginL": "10",
|
||||
"MarginR": "20",
|
||||
"MarginV": "30",
|
||||
"Effect": "Fade",
|
||||
},
|
||||
}
|
||||
|
||||
entry2 := model.SubtitleEntry{
|
||||
Index: 2,
|
||||
StartTime: model.Timestamp{Seconds: 5},
|
||||
EndTime: model.Timestamp{Seconds: 8},
|
||||
Text: "This is the second subtitle line.",
|
||||
Styles: map[string]string{"bold": "1"},
|
||||
}
|
||||
|
||||
subtitle.Entries = append(subtitle.Entries, entry1, entry2)
|
||||
|
||||
// Convert back to ASS
|
||||
tempDir := t.TempDir()
|
||||
outputFile := filepath.Join(tempDir, "convert_back.ass")
|
||||
err := ConvertFromSubtitle(subtitle, outputFile)
|
||||
if err != nil {
|
||||
t.Fatalf("ConvertFromSubtitle failed: %v", err)
|
||||
}
|
||||
|
||||
// Read the generated file
|
||||
content, err := os.ReadFile(outputFile)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read generated file: %v", err)
|
||||
}
|
||||
contentStr := string(content)
|
||||
|
||||
// Verify file content
|
||||
if !strings.Contains(contentStr, "Title: Test Conversion") {
|
||||
t.Errorf("Missing or incorrect title in generated file")
|
||||
}
|
||||
|
||||
// Check that both entries were converted correctly
|
||||
if !strings.Contains(contentStr, "Dialogue: 0,0:00:01.00,0:00:04.00,Default,Character,10,20,30,Fade,This is the first subtitle line.") {
|
||||
t.Errorf("First entry not converted correctly")
|
||||
}
|
||||
|
||||
// Check that bold style was created and applied
|
||||
if !strings.Contains(contentStr, "Style: Bold") {
|
||||
t.Errorf("Bold style not created")
|
||||
}
|
||||
|
||||
if !strings.Contains(contentStr, "Dialogue: 0,0:00:05.00,0:00:08.00,Bold") {
|
||||
t.Errorf("Second entry not converted with Bold style")
|
||||
}
|
||||
|
||||
// Parse the file again to check structure
|
||||
assFile, err := Parse(outputFile)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to parse the generated file: %v", err)
|
||||
}
|
||||
|
||||
if len(assFile.Events) != 2 {
|
||||
t.Errorf("Expected 2 events, got %d", len(assFile.Events))
|
||||
}
|
||||
|
||||
// Check style conversion
|
||||
var boldStyleFound bool
|
||||
for _, style := range assFile.Styles {
|
||||
if style.Name == "Bold" {
|
||||
boldStyleFound = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !boldStyleFound {
|
||||
t.Errorf("Bold style not found in generated file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertToSubtitle_FileError(t *testing.T) {
|
||||
// Test non-existent file
|
||||
_, err := ConvertToSubtitle("/nonexistent/file.ass")
|
||||
if err == nil {
|
||||
t.Error("Converting non-existent file should return an error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertFromSubtitle_FileError(t *testing.T) {
|
||||
// Test invalid path
|
||||
subtitle := model.NewSubtitle()
|
||||
err := ConvertFromSubtitle(subtitle, "/nonexistent/directory/file.ass")
|
||||
if err == nil {
|
||||
t.Error("Converting to invalid path should return an error")
|
||||
}
|
||||
}
|
17
internal/format/ass/formatter.go
Normal file
17
internal/format/ass/formatter.go
Normal file
|
@ -0,0 +1,17 @@
|
|||
package ass
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// Format 格式化ASS文件
|
||||
func Format(filePath string) error {
|
||||
// 读取ASS文件
|
||||
assFile, err := Parse(filePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("解析ASS文件失败: %w", err)
|
||||
}
|
||||
|
||||
// 写回格式化后的ASS文件
|
||||
return Generate(assFile, filePath)
|
||||
}
|
99
internal/format/ass/formatter_test.go
Normal file
99
internal/format/ass/formatter_test.go
Normal file
|
@ -0,0 +1,99 @@
|
|||
package ass
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFormat(t *testing.T) {
|
||||
// Create a test ASS file with non-standard formatting
|
||||
content := `[Script Info]
|
||||
ScriptType:v4.00+
|
||||
Title: Format Test
|
||||
PlayResX:640
|
||||
PlayResY: 480
|
||||
|
||||
[V4+ Styles]
|
||||
Format: Name, Fontname,Fontsize,PrimaryColour,SecondaryColour,OutlineColour,BackColour,Bold,Italic,Underline,StrikeOut,ScaleX,ScaleY,Spacing,Angle,BorderStyle,Outline,Shadow,Alignment,MarginL,MarginR,MarginV,Encoding
|
||||
Style:Default,Arial,20,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,2,2,10,10,10,1
|
||||
|
||||
[Events]
|
||||
Format:Layer, Start,End,Style,Name,MarginL,MarginR,MarginV,Effect,Text
|
||||
Dialogue:0,0:00:01.00,0:00:04.00,Default,,0,0,0,,This is the first subtitle line.
|
||||
Dialogue: 0,0:00:05.00,0:00:08.00,Default,,0,0,0,,This is the second subtitle line.
|
||||
`
|
||||
tempDir := t.TempDir()
|
||||
testFile := filepath.Join(tempDir, "format_test.ass")
|
||||
if err := os.WriteFile(testFile, []byte(content), 0644); err != nil {
|
||||
t.Fatalf("Failed to create test file: %v", err)
|
||||
}
|
||||
|
||||
// Test format
|
||||
err := Format(testFile)
|
||||
if err != nil {
|
||||
t.Fatalf("Format failed: %v", err)
|
||||
}
|
||||
|
||||
// Read the formatted file
|
||||
formattedContent, err := os.ReadFile(testFile)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read formatted file: %v", err)
|
||||
}
|
||||
|
||||
contentStr := string(formattedContent)
|
||||
|
||||
// Check for consistency and proper spacing
|
||||
if !strings.Contains(contentStr, "Title: Format Test") {
|
||||
t.Errorf("Title should be properly formatted, got: %s", contentStr)
|
||||
}
|
||||
|
||||
// Check style section formatting
|
||||
if !strings.Contains(contentStr, "Format: Name, Fontname, Fontsize") {
|
||||
t.Errorf("Style format should be properly spaced, got: %s", contentStr)
|
||||
}
|
||||
|
||||
// Check event section formatting
|
||||
if !strings.Contains(contentStr, "Dialogue: 0,") {
|
||||
t.Errorf("Dialogue should be properly formatted, got: %s", contentStr)
|
||||
}
|
||||
|
||||
// Parse formatted file to ensure it's valid
|
||||
assFile, err := Parse(testFile)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to parse formatted file: %v", err)
|
||||
}
|
||||
|
||||
// Verify basic structure remains intact
|
||||
if assFile.ScriptInfo["Title"] != "Format Test" {
|
||||
t.Errorf("Title mismatch after formatting: expected 'Format Test', got '%s'", assFile.ScriptInfo["Title"])
|
||||
}
|
||||
|
||||
if len(assFile.Events) != 2 {
|
||||
t.Errorf("Expected 2 events after formatting, got %d", len(assFile.Events))
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormat_NonExistentFile(t *testing.T) {
|
||||
err := Format("/nonexistent/file.ass")
|
||||
if err == nil {
|
||||
t.Error("Formatting non-existent file should return an error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormat_InvalidWritable(t *testing.T) {
|
||||
// Create a directory instead of a file
|
||||
tempDir := t.TempDir()
|
||||
dirAsFile := filepath.Join(tempDir, "dir_as_file")
|
||||
|
||||
if err := os.Mkdir(dirAsFile, 0755); err != nil {
|
||||
t.Fatalf("Failed to create test directory: %v", err)
|
||||
}
|
||||
|
||||
// Try to format a directory
|
||||
err := Format(dirAsFile)
|
||||
if err == nil {
|
||||
t.Error("Formatting a directory should return an error")
|
||||
}
|
||||
}
|
122
internal/format/ass/generator.go
Normal file
122
internal/format/ass/generator.go
Normal file
|
@ -0,0 +1,122 @@
|
|||
package ass
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"sub-cli/internal/model"
|
||||
)
|
||||
|
||||
// Generate 生成ASS文件
|
||||
func Generate(assFile model.ASSFile, filePath string) error {
|
||||
// 确保目录存在
|
||||
dir := filepath.Dir(filePath)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return fmt.Errorf("创建目录失败: %w", err)
|
||||
}
|
||||
|
||||
// 创建或覆盖文件
|
||||
file, err := os.Create(filePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("创建ASS文件失败: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
// 写入脚本信息
|
||||
if _, err := file.WriteString(ASSHeader + "\n"); err != nil {
|
||||
return fmt.Errorf("写入文件失败: %w", err)
|
||||
}
|
||||
|
||||
for key, value := range assFile.ScriptInfo {
|
||||
if _, err := file.WriteString(fmt.Sprintf("%s: %s\n", key, value)); err != nil {
|
||||
return fmt.Errorf("写入文件失败: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 写入样式信息
|
||||
if _, err := file.WriteString("\n" + ASSStylesHeader + "\n"); err != nil {
|
||||
return fmt.Errorf("写入文件失败: %w", err)
|
||||
}
|
||||
|
||||
// 写入样式格式行
|
||||
if len(assFile.Styles) > 0 {
|
||||
var formatString string
|
||||
for _, style := range assFile.Styles {
|
||||
if formatString == "" && style.Properties["Format"] != "" {
|
||||
formatString = style.Properties["Format"]
|
||||
if _, err := file.WriteString(fmt.Sprintf("Format: %s\n", formatString)); err != nil {
|
||||
return fmt.Errorf("写入文件失败: %w", err)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// 如果没有找到格式行,写入默认格式
|
||||
if formatString == "" {
|
||||
defaultFormat := "Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding"
|
||||
if _, err := file.WriteString(fmt.Sprintf("Format: %s\n", defaultFormat)); err != nil {
|
||||
return fmt.Errorf("写入文件失败: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 写入样式定义
|
||||
for _, style := range assFile.Styles {
|
||||
if style.Properties["Style"] != "" {
|
||||
if _, err := file.WriteString(fmt.Sprintf("Style: %s\n", style.Properties["Style"])); err != nil {
|
||||
return fmt.Errorf("写入文件失败: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 写入事件信息
|
||||
if _, err := file.WriteString("\n" + ASSEventsHeader + "\n"); err != nil {
|
||||
return fmt.Errorf("写入文件失败: %w", err)
|
||||
}
|
||||
|
||||
// 写入事件格式行
|
||||
if _, err := file.WriteString(DefaultFormat + "\n"); err != nil {
|
||||
return fmt.Errorf("写入文件失败: %w", err)
|
||||
}
|
||||
|
||||
// 写入事件行
|
||||
for _, event := range assFile.Events {
|
||||
eventLine := formatEventLine(event)
|
||||
if _, err := file.WriteString(eventLine + "\n"); err != nil {
|
||||
return fmt.Errorf("写入文件失败: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// formatEventLine 将事件格式化为ASS文件中的一行
|
||||
func formatEventLine(event model.ASSEvent) string {
|
||||
// 格式化时间戳
|
||||
startTime := formatASSTimestamp(event.StartTime)
|
||||
endTime := formatASSTimestamp(event.EndTime)
|
||||
|
||||
// 构建事件行
|
||||
var builder strings.Builder
|
||||
if event.Type == "Comment" {
|
||||
builder.WriteString("Comment: ")
|
||||
} else {
|
||||
builder.WriteString("Dialogue: ")
|
||||
}
|
||||
|
||||
builder.WriteString(fmt.Sprintf("%d,%s,%s,%s,%s,%d,%d,%d,%s,%s",
|
||||
event.Layer,
|
||||
startTime,
|
||||
endTime,
|
||||
event.Style,
|
||||
event.Name,
|
||||
event.MarginL,
|
||||
event.MarginR,
|
||||
event.MarginV,
|
||||
event.Effect,
|
||||
event.Text))
|
||||
|
||||
return builder.String()
|
||||
}
|
131
internal/format/ass/generator_test.go
Normal file
131
internal/format/ass/generator_test.go
Normal file
|
@ -0,0 +1,131 @@
|
|||
package ass
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"sub-cli/internal/model"
|
||||
)
|
||||
|
||||
func TestGenerate(t *testing.T) {
|
||||
// Create test ASS file structure
|
||||
assFile := model.NewASSFile()
|
||||
assFile.ScriptInfo["Title"] = "Generation Test"
|
||||
|
||||
// Add a custom style
|
||||
boldStyle := model.ASSStyle{
|
||||
Name: "Bold",
|
||||
Properties: map[string]string{
|
||||
"Format": "Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding",
|
||||
"Style": "Bold,Arial,20,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,1,0,0,0,100,100,0,0,1,2,2,2,10,10,10,1",
|
||||
"Bold": "1",
|
||||
},
|
||||
}
|
||||
assFile.Styles = append(assFile.Styles, boldStyle)
|
||||
|
||||
// Add dialogue events
|
||||
event1 := model.NewASSEvent()
|
||||
event1.Type = "Dialogue"
|
||||
event1.StartTime = model.Timestamp{Seconds: 1}
|
||||
event1.EndTime = model.Timestamp{Seconds: 4}
|
||||
event1.Style = "Default"
|
||||
event1.Text = "This is a test subtitle."
|
||||
|
||||
event2 := model.NewASSEvent()
|
||||
event2.Type = "Dialogue"
|
||||
event2.StartTime = model.Timestamp{Seconds: 5}
|
||||
event2.EndTime = model.Timestamp{Seconds: 8}
|
||||
event2.Style = "Bold"
|
||||
event2.Text = "This is a bold subtitle."
|
||||
|
||||
assFile.Events = append(assFile.Events, event1, event2)
|
||||
|
||||
// Generate ASS file
|
||||
tempDir := t.TempDir()
|
||||
outputFile := filepath.Join(tempDir, "output.ass")
|
||||
err := Generate(assFile, outputFile)
|
||||
if err != nil {
|
||||
t.Fatalf("Generate failed: %v", err)
|
||||
}
|
||||
|
||||
// Read the generated file
|
||||
content, err := os.ReadFile(outputFile)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read generated file: %v", err)
|
||||
}
|
||||
contentStr := string(content)
|
||||
|
||||
// Verify file structure and content
|
||||
// Check Script Info section
|
||||
if !strings.Contains(contentStr, "[Script Info]") {
|
||||
t.Errorf("Missing [Script Info] section")
|
||||
}
|
||||
if !strings.Contains(contentStr, "Title: Generation Test") {
|
||||
t.Errorf("Missing Title in Script Info")
|
||||
}
|
||||
|
||||
// Check Styles section
|
||||
if !strings.Contains(contentStr, "[V4+ Styles]") {
|
||||
t.Errorf("Missing [V4+ Styles] section")
|
||||
}
|
||||
if !strings.Contains(contentStr, "Style: Bold,Arial,20") {
|
||||
t.Errorf("Missing Bold style definition")
|
||||
}
|
||||
|
||||
// Check Events section
|
||||
if !strings.Contains(contentStr, "[Events]") {
|
||||
t.Errorf("Missing [Events] section")
|
||||
}
|
||||
if !strings.Contains(contentStr, "Format: Layer, Start, End, Style,") {
|
||||
t.Errorf("Missing Format line in Events section")
|
||||
}
|
||||
if !strings.Contains(contentStr, "Dialogue: 0,0:00:01.00,0:00:04.00,Default,,0,0,0,,This is a test subtitle.") {
|
||||
t.Errorf("Missing first dialogue event")
|
||||
}
|
||||
if !strings.Contains(contentStr, "Dialogue: 0,0:00:05.00,0:00:08.00,Bold,,0,0,0,,This is a bold subtitle.") {
|
||||
t.Errorf("Missing second dialogue event")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerate_FileError(t *testing.T) {
|
||||
// Test invalid path
|
||||
assFile := model.NewASSFile()
|
||||
err := Generate(assFile, "/nonexistent/directory/file.ass")
|
||||
if err == nil {
|
||||
t.Error("Generating to invalid path should return an error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatEventLine(t *testing.T) {
|
||||
event := model.ASSEvent{
|
||||
Type: "Dialogue",
|
||||
Layer: 0,
|
||||
StartTime: model.Timestamp{Seconds: 1},
|
||||
EndTime: model.Timestamp{Seconds: 4},
|
||||
Style: "Default",
|
||||
Name: "Character",
|
||||
MarginL: 10,
|
||||
MarginR: 10,
|
||||
MarginV: 10,
|
||||
Effect: "Fade",
|
||||
Text: "Test text",
|
||||
}
|
||||
|
||||
expected := "Dialogue: 0,0:00:01.00,0:00:04.00,Default,Character,10,10,10,Fade,Test text"
|
||||
result := formatEventLine(event)
|
||||
|
||||
if result != expected {
|
||||
t.Errorf("Expected: '%s', got: '%s'", expected, result)
|
||||
}
|
||||
|
||||
// Test Comment type
|
||||
event.Type = "Comment"
|
||||
expected = "Comment: 0,0:00:01.00,0:00:04.00,Default,Character,10,10,10,Fade,Test text"
|
||||
result = formatEventLine(event)
|
||||
|
||||
if result != expected {
|
||||
t.Errorf("Expected: '%s', got: '%s'", expected, result)
|
||||
}
|
||||
}
|
152
internal/format/ass/parser.go
Normal file
152
internal/format/ass/parser.go
Normal file
|
@ -0,0 +1,152 @@
|
|||
package ass
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"sub-cli/internal/model"
|
||||
)
|
||||
|
||||
// 常量定义
|
||||
const (
|
||||
ASSHeader = "[Script Info]"
|
||||
ASSStylesHeader = "[V4+ Styles]"
|
||||
ASSEventsHeader = "[Events]"
|
||||
DefaultFormat = "Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text"
|
||||
)
|
||||
|
||||
// Parse 解析ASS文件为ASSFile结构
|
||||
func Parse(filePath string) (model.ASSFile, error) {
|
||||
file, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return model.ASSFile{}, fmt.Errorf("打开ASS文件失败: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
result := model.NewASSFile()
|
||||
|
||||
scanner := bufio.NewScanner(file)
|
||||
|
||||
// 当前解析的区块
|
||||
currentSection := ""
|
||||
var styleFormat, eventFormat []string
|
||||
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if line == "" || strings.HasPrefix(line, ";") {
|
||||
// 跳过空行和注释行
|
||||
continue
|
||||
}
|
||||
|
||||
// 检查章节标题
|
||||
if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") {
|
||||
currentSection = line
|
||||
continue
|
||||
}
|
||||
|
||||
switch currentSection {
|
||||
case ASSHeader:
|
||||
// 解析脚本信息
|
||||
if strings.Contains(line, ":") {
|
||||
parts := strings.SplitN(line, ":", 2)
|
||||
key := strings.TrimSpace(parts[0])
|
||||
value := strings.TrimSpace(parts[1])
|
||||
result.ScriptInfo[key] = value
|
||||
}
|
||||
|
||||
case ASSStylesHeader:
|
||||
// 解析样式格式行和样式定义
|
||||
if strings.HasPrefix(line, "Format:") {
|
||||
formatStr := strings.TrimPrefix(line, "Format:")
|
||||
styleFormat = parseFormatLine(formatStr)
|
||||
} else if strings.HasPrefix(line, "Style:") {
|
||||
styleValues := parseStyleLine(line)
|
||||
if len(styleFormat) > 0 && len(styleValues) > 0 {
|
||||
style := model.ASSStyle{
|
||||
Name: styleValues[0], // 第一个值通常是样式名称
|
||||
Properties: make(map[string]string),
|
||||
}
|
||||
|
||||
// 将原始格式行保存下来
|
||||
style.Properties["Format"] = "Name, " + strings.Join(styleFormat[1:], ", ")
|
||||
style.Properties["Style"] = strings.Join(styleValues, ", ")
|
||||
|
||||
// 解析各个样式属性
|
||||
for i := 0; i < len(styleFormat) && i < len(styleValues); i++ {
|
||||
style.Properties[styleFormat[i]] = styleValues[i]
|
||||
}
|
||||
|
||||
result.Styles = append(result.Styles, style)
|
||||
}
|
||||
}
|
||||
|
||||
case ASSEventsHeader:
|
||||
// 解析事件格式行和对话行
|
||||
if strings.HasPrefix(line, "Format:") {
|
||||
formatStr := strings.TrimPrefix(line, "Format:")
|
||||
eventFormat = parseFormatLine(formatStr)
|
||||
} else if len(eventFormat) > 0 &&
|
||||
(strings.HasPrefix(line, "Dialogue:") ||
|
||||
strings.HasPrefix(line, "Comment:")) {
|
||||
|
||||
eventType := "Dialogue"
|
||||
if strings.HasPrefix(line, "Comment:") {
|
||||
eventType = "Comment"
|
||||
line = strings.TrimPrefix(line, "Comment:")
|
||||
} else {
|
||||
line = strings.TrimPrefix(line, "Dialogue:")
|
||||
}
|
||||
|
||||
values := parseEventLine(line)
|
||||
if len(values) >= len(eventFormat) {
|
||||
event := model.NewASSEvent()
|
||||
event.Type = eventType
|
||||
|
||||
// 填充事件属性
|
||||
for i, format := range eventFormat {
|
||||
value := values[i]
|
||||
switch strings.TrimSpace(format) {
|
||||
case "Layer":
|
||||
layer, _ := strconv.Atoi(value)
|
||||
event.Layer = layer
|
||||
case "Start":
|
||||
event.StartTime = parseASSTimestamp(value)
|
||||
case "End":
|
||||
event.EndTime = parseASSTimestamp(value)
|
||||
case "Style":
|
||||
event.Style = value
|
||||
case "Name":
|
||||
event.Name = value
|
||||
case "MarginL":
|
||||
marginL, _ := strconv.Atoi(value)
|
||||
event.MarginL = marginL
|
||||
case "MarginR":
|
||||
marginR, _ := strconv.Atoi(value)
|
||||
event.MarginR = marginR
|
||||
case "MarginV":
|
||||
marginV, _ := strconv.Atoi(value)
|
||||
event.MarginV = marginV
|
||||
case "Effect":
|
||||
event.Effect = value
|
||||
case "Text":
|
||||
// 文本可能包含逗号,所以需要特殊处理
|
||||
textStartIndex := strings.Index(line, value)
|
||||
if textStartIndex >= 0 {
|
||||
event.Text = line[textStartIndex:]
|
||||
} else {
|
||||
event.Text = value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result.Events = append(result.Events, event)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
148
internal/format/ass/parser_test.go
Normal file
148
internal/format/ass/parser_test.go
Normal file
|
@ -0,0 +1,148 @@
|
|||
package ass
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"sub-cli/internal/model"
|
||||
)
|
||||
|
||||
func TestParse(t *testing.T) {
|
||||
// Create temporary test file
|
||||
content := `[Script Info]
|
||||
ScriptType: v4.00+
|
||||
Title: Test ASS File
|
||||
PlayResX: 640
|
||||
PlayResY: 480
|
||||
|
||||
[V4+ Styles]
|
||||
Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
|
||||
Style: Default,Arial,20,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,2,2,10,10,10,1
|
||||
Style: Bold,Arial,20,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,1,0,0,0,100,100,0,0,1,2,2,2,10,10,10,1
|
||||
|
||||
[Events]
|
||||
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
|
||||
Dialogue: 0,0:00:01.00,0:00:04.00,Default,,0,0,0,,This is the first subtitle line.
|
||||
Dialogue: 0,0:00:05.00,0:00:08.00,Bold,,0,0,0,,This is the second subtitle line with bold style.
|
||||
Comment: 0,0:00:09.00,0:00:12.00,Default,,0,0,0,,This is a comment.
|
||||
`
|
||||
tempDir := t.TempDir()
|
||||
testFile := filepath.Join(tempDir, "test.ass")
|
||||
if err := os.WriteFile(testFile, []byte(content), 0644); err != nil {
|
||||
t.Fatalf("Failed to create test file: %v", err)
|
||||
}
|
||||
|
||||
// Test parsing
|
||||
assFile, err := Parse(testFile)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify results
|
||||
// Script info
|
||||
if assFile.ScriptInfo["Title"] != "Test ASS File" {
|
||||
t.Errorf("Title mismatch: expected 'Test ASS File', got '%s'", assFile.ScriptInfo["Title"])
|
||||
}
|
||||
if assFile.ScriptInfo["ScriptType"] != "v4.00+" {
|
||||
t.Errorf("Script type mismatch: expected 'v4.00+', got '%s'", assFile.ScriptInfo["ScriptType"])
|
||||
}
|
||||
|
||||
// Styles
|
||||
if len(assFile.Styles) != 3 {
|
||||
t.Errorf("Expected 3 styles, got %d", len(assFile.Styles))
|
||||
} else {
|
||||
// Find Bold style
|
||||
var boldStyle *model.ASSStyle
|
||||
for i, style := range assFile.Styles {
|
||||
if style.Name == "Bold" {
|
||||
boldStyle = &assFile.Styles[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if boldStyle == nil {
|
||||
t.Errorf("Bold style not found")
|
||||
} else {
|
||||
boldValue, exists := boldStyle.Properties["Bold"]
|
||||
if !exists || boldValue != "1" {
|
||||
t.Errorf("Bold style Bold property mismatch: expected '1', got '%s'", boldValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Events
|
||||
if len(assFile.Events) != 3 {
|
||||
t.Errorf("Expected 3 events, got %d", len(assFile.Events))
|
||||
} else {
|
||||
// Check first dialogue line
|
||||
if assFile.Events[0].Type != "Dialogue" {
|
||||
t.Errorf("First event type mismatch: expected 'Dialogue', got '%s'", assFile.Events[0].Type)
|
||||
}
|
||||
if assFile.Events[0].StartTime.Seconds != 1 || assFile.Events[0].StartTime.Milliseconds != 0 {
|
||||
t.Errorf("First event start time mismatch: expected 00:00:01.00, got %d:%02d:%02d.%03d",
|
||||
assFile.Events[0].StartTime.Hours, assFile.Events[0].StartTime.Minutes,
|
||||
assFile.Events[0].StartTime.Seconds, assFile.Events[0].StartTime.Milliseconds)
|
||||
}
|
||||
if assFile.Events[0].Text != "This is the first subtitle line." {
|
||||
t.Errorf("First event text mismatch: expected 'This is the first subtitle line.', got '%s'", assFile.Events[0].Text)
|
||||
}
|
||||
|
||||
// Check second dialogue line (bold style)
|
||||
if assFile.Events[1].Style != "Bold" {
|
||||
t.Errorf("Second event style mismatch: expected 'Bold', got '%s'", assFile.Events[1].Style)
|
||||
}
|
||||
|
||||
// Check comment line
|
||||
if assFile.Events[2].Type != "Comment" {
|
||||
t.Errorf("Third event type mismatch: expected 'Comment', got '%s'", assFile.Events[2].Type)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParse_EdgeCases(t *testing.T) {
|
||||
// Test empty file
|
||||
tempDir := t.TempDir()
|
||||
emptyFile := filepath.Join(tempDir, "empty.ass")
|
||||
if err := os.WriteFile(emptyFile, []byte{}, 0644); err != nil {
|
||||
t.Fatalf("Failed to create empty test file: %v", err)
|
||||
}
|
||||
|
||||
assFile, err := Parse(emptyFile)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to parse empty file: %v", err)
|
||||
}
|
||||
|
||||
if len(assFile.Events) != 0 {
|
||||
t.Errorf("Empty file should have 0 events, got %d", len(assFile.Events))
|
||||
}
|
||||
|
||||
// Test file missing required sections
|
||||
malformedContent := `[Script Info]
|
||||
Title: Missing Sections Test
|
||||
`
|
||||
malformedFile := filepath.Join(tempDir, "malformed.ass")
|
||||
if err := os.WriteFile(malformedFile, []byte(malformedContent), 0644); err != nil {
|
||||
t.Fatalf("Failed to create malformed file: %v", err)
|
||||
}
|
||||
|
||||
assFile, err = Parse(malformedFile)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to parse malformed file: %v", err)
|
||||
}
|
||||
|
||||
if assFile.ScriptInfo["Title"] != "Missing Sections Test" {
|
||||
t.Errorf("Should correctly parse the title")
|
||||
}
|
||||
if len(assFile.Events) != 0 {
|
||||
t.Errorf("File missing Events section should have 0 events")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParse_FileError(t *testing.T) {
|
||||
// Test non-existent file
|
||||
_, err := Parse("/nonexistent/file.ass")
|
||||
if err == nil {
|
||||
t.Error("Parsing non-existent file should return an error")
|
||||
}
|
||||
}
|
98
internal/format/ass/utils.go
Normal file
98
internal/format/ass/utils.go
Normal file
|
@ -0,0 +1,98 @@
|
|||
package ass
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"sub-cli/internal/model"
|
||||
)
|
||||
|
||||
// parseFormatLine 解析格式行中的各个字段
|
||||
func parseFormatLine(formatStr string) []string {
|
||||
fields := strings.Split(formatStr, ",")
|
||||
result := make([]string, 0, len(fields))
|
||||
|
||||
for _, field := range fields {
|
||||
result = append(result, strings.TrimSpace(field))
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// parseStyleLine 解析样式行
|
||||
func parseStyleLine(line string) []string {
|
||||
// 去掉"Style:"前缀
|
||||
styleStr := strings.TrimPrefix(line, "Style:")
|
||||
return splitCSV(styleStr)
|
||||
}
|
||||
|
||||
// parseEventLine 解析事件行
|
||||
func parseEventLine(line string) []string {
|
||||
return splitCSV(line)
|
||||
}
|
||||
|
||||
// splitCSV 拆分CSV格式的字符串,但保留Text字段中的逗号
|
||||
func splitCSV(line string) []string {
|
||||
var result []string
|
||||
inText := false
|
||||
current := ""
|
||||
|
||||
for _, char := range line {
|
||||
if char == ',' && !inText {
|
||||
result = append(result, strings.TrimSpace(current))
|
||||
current = ""
|
||||
} else {
|
||||
current += string(char)
|
||||
// 这是个简化处理,实际ASS格式更复杂
|
||||
// 当处理到足够数量的字段后,剩余部分都当作Text字段
|
||||
if len(result) >= 9 {
|
||||
inText = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if current != "" {
|
||||
result = append(result, strings.TrimSpace(current))
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// parseASSTimestamp 解析ASS格式的时间戳 (h:mm:ss.cc)
|
||||
func parseASSTimestamp(timeStr string) model.Timestamp {
|
||||
// 匹配 h:mm:ss.cc 格式
|
||||
re := regexp.MustCompile(`(\d+):(\d+):(\d+)\.(\d+)`)
|
||||
matches := re.FindStringSubmatch(timeStr)
|
||||
|
||||
if len(matches) == 5 {
|
||||
hours, _ := strconv.Atoi(matches[1])
|
||||
minutes, _ := strconv.Atoi(matches[2])
|
||||
seconds, _ := strconv.Atoi(matches[3])
|
||||
// ASS使用厘秒(1/100秒),需要转换为毫秒
|
||||
centiseconds, _ := strconv.Atoi(matches[4])
|
||||
milliseconds := centiseconds * 10
|
||||
|
||||
return model.Timestamp{
|
||||
Hours: hours,
|
||||
Minutes: minutes,
|
||||
Seconds: seconds,
|
||||
Milliseconds: milliseconds,
|
||||
}
|
||||
}
|
||||
|
||||
// 返回零时间戳,如果解析失败
|
||||
return model.Timestamp{}
|
||||
}
|
||||
|
||||
// formatASSTimestamp 格式化为ASS格式的时间戳 (h:mm:ss.cc)
|
||||
func formatASSTimestamp(timestamp model.Timestamp) string {
|
||||
// ASS使用厘秒(1/100秒)
|
||||
centiseconds := timestamp.Milliseconds / 10
|
||||
return fmt.Sprintf("%d:%02d:%02d.%02d",
|
||||
timestamp.Hours,
|
||||
timestamp.Minutes,
|
||||
timestamp.Seconds,
|
||||
centiseconds)
|
||||
}
|
139
internal/format/ass/utils_test.go
Normal file
139
internal/format/ass/utils_test.go
Normal file
|
@ -0,0 +1,139 @@
|
|||
package ass
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"sub-cli/internal/model"
|
||||
)
|
||||
|
||||
func TestParseASSTimestamp(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
input string
|
||||
expected model.Timestamp
|
||||
}{
|
||||
{
|
||||
name: "Standard format",
|
||||
input: "0:00:01.00",
|
||||
expected: model.Timestamp{Hours: 0, Minutes: 0, Seconds: 1, Milliseconds: 0},
|
||||
},
|
||||
{
|
||||
name: "With centiseconds",
|
||||
input: "0:00:01.50",
|
||||
expected: model.Timestamp{Hours: 0, Minutes: 0, Seconds: 1, Milliseconds: 500},
|
||||
},
|
||||
{
|
||||
name: "Complete hours, minutes, seconds",
|
||||
input: "1:02:03.45",
|
||||
expected: model.Timestamp{Hours: 1, Minutes: 2, Seconds: 3, Milliseconds: 450},
|
||||
},
|
||||
{
|
||||
name: "Invalid format",
|
||||
input: "invalid",
|
||||
expected: model.Timestamp{},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
result := parseASSTimestamp(tc.input)
|
||||
if result != tc.expected {
|
||||
t.Errorf("For input '%s', expected %+v, got %+v", tc.input, tc.expected, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatASSTimestamp(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
input model.Timestamp
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "Zero timestamp",
|
||||
input: model.Timestamp{},
|
||||
expected: "0:00:00.00",
|
||||
},
|
||||
{
|
||||
name: "Simple seconds",
|
||||
input: model.Timestamp{Hours: 0, Minutes: 0, Seconds: 1, Milliseconds: 0},
|
||||
expected: "0:00:01.00",
|
||||
},
|
||||
{
|
||||
name: "With milliseconds",
|
||||
input: model.Timestamp{Hours: 0, Minutes: 0, Seconds: 1, Milliseconds: 500},
|
||||
expected: "0:00:01.50",
|
||||
},
|
||||
{
|
||||
name: "Complete timestamp",
|
||||
input: model.Timestamp{Hours: 1, Minutes: 2, Seconds: 3, Milliseconds: 450},
|
||||
expected: "1:02:03.45",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
result := formatASSTimestamp(tc.input)
|
||||
if result != tc.expected {
|
||||
t.Errorf("For timestamp %+v, expected '%s', got '%s'", tc.input, tc.expected, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitCSV(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
input string
|
||||
expected []string
|
||||
}{
|
||||
{
|
||||
name: "Simple CSV",
|
||||
input: "Value1, Value2, Value3",
|
||||
expected: []string{"Value1", "Value2", "Value3"},
|
||||
},
|
||||
{
|
||||
name: "Text field with commas",
|
||||
input: "0, 00:00:01.00, 00:00:05.00, Default, Name, 0, 0, 0, Effect, Text with, commas",
|
||||
expected: []string{"0", "00:00:01.00", "00:00:05.00", "Default", "Name", "0", "0", "0", "Effect", "Text with, commas"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
result := splitCSV(tc.input)
|
||||
|
||||
// Check result length
|
||||
if len(result) != len(tc.expected) {
|
||||
t.Errorf("Expected %d values, got %d: %v", len(tc.expected), len(result), result)
|
||||
return
|
||||
}
|
||||
|
||||
// Check content
|
||||
for i := range result {
|
||||
if result[i] != tc.expected[i] {
|
||||
t.Errorf("At index %d, expected '%s', got '%s'", i, tc.expected[i], result[i])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseFormatLine(t *testing.T) {
|
||||
input := " Name, Fontname, Fontsize, PrimaryColour"
|
||||
expected := []string{"Name", "Fontname", "Fontsize", "PrimaryColour"}
|
||||
|
||||
result := parseFormatLine(input)
|
||||
|
||||
if len(result) != len(expected) {
|
||||
t.Errorf("Expected %d values, got %d: %v", len(expected), len(result), result)
|
||||
return
|
||||
}
|
||||
|
||||
for i := range result {
|
||||
if result[i] != expected[i] {
|
||||
t.Errorf("At index %d, expected '%s', got '%s'", i, expected[i], result[i])
|
||||
}
|
||||
}
|
||||
}
|
181
internal/format/lrc/converter_test.go
Normal file
181
internal/format/lrc/converter_test.go
Normal file
|
@ -0,0 +1,181 @@
|
|||
package lrc
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"sub-cli/internal/model"
|
||||
)
|
||||
|
||||
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 len(subtitle.Entries) != 2 {
|
||||
t.Errorf("Expected 2 entries, got %d", len(subtitle.Entries))
|
||||
}
|
||||
|
||||
// Check first entry
|
||||
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:01.00, got %+v", subtitle.Entries[0].StartTime)
|
||||
}
|
||||
|
||||
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 metadata conversion
|
||||
if subtitle.Title != "Test LRC File" {
|
||||
t.Errorf("Expected title 'Test LRC File', got '%s'", subtitle.Title)
|
||||
}
|
||||
|
||||
if subtitle.Metadata["ar"] != "Test Artist" {
|
||||
t.Errorf("Expected artist metadata 'Test Artist', got '%s'", subtitle.Metadata["ar"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertFromSubtitle(t *testing.T) {
|
||||
// Create a subtitle
|
||||
subtitle := model.NewSubtitle()
|
||||
subtitle.Format = "lrc"
|
||||
subtitle.Title = "Test LRC File"
|
||||
subtitle.Metadata["ar"] = "Test Artist"
|
||||
|
||||
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 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 reading the file directly
|
||||
content, err := os.ReadFile(outputFile)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read output file: %v", err)
|
||||
}
|
||||
|
||||
// Check content
|
||||
contentStr := string(content)
|
||||
|
||||
// Check metadata
|
||||
if !strings.Contains(contentStr, "[ti:Test LRC File]") {
|
||||
t.Errorf("Expected title metadata in output, not found")
|
||||
}
|
||||
|
||||
if !strings.Contains(contentStr, "[ar:Test Artist]") {
|
||||
t.Errorf("Expected artist metadata in output, not found")
|
||||
}
|
||||
|
||||
// Check timeline entries
|
||||
if !strings.Contains(contentStr, "[00:01.000]This is the first line.") {
|
||||
t.Errorf("Expected first timeline entry in output, not found")
|
||||
}
|
||||
|
||||
if !strings.Contains(contentStr, "[00:05.000]This is the second line.") {
|
||||
t.Errorf("Expected second timeline entry in output, not found")
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
72
internal/format/lrc/formatter_test.go
Normal file
72
internal/format/lrc/formatter_test.go
Normal file
|
@ -0,0 +1,72 @@
|
|||
package lrc
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFormat(t *testing.T) {
|
||||
// Create a temporary test file with messy formatting
|
||||
content := `[ti:Test LRC File]
|
||||
[ar:Test Artist]
|
||||
|
||||
[00:01.00]This should be first.
|
||||
[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)
|
||||
}
|
||||
|
||||
// Format the file
|
||||
err := Format(testFile)
|
||||
if err != nil {
|
||||
t.Fatalf("Format failed: %v", err)
|
||||
}
|
||||
|
||||
// Read the formatted file
|
||||
formatted, err := os.ReadFile(testFile)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read formatted file: %v", err)
|
||||
}
|
||||
|
||||
// Check that the file was at least generated successfully
|
||||
lines := strings.Split(string(formatted), "\n")
|
||||
if len(lines) < 4 {
|
||||
t.Fatalf("Expected at least 4 lines, got %d", len(lines))
|
||||
}
|
||||
|
||||
// Check that the metadata was preserved
|
||||
if !strings.Contains(string(formatted), "[ti:Test LRC File]") {
|
||||
t.Errorf("Expected title metadata in output, not found")
|
||||
}
|
||||
|
||||
if !strings.Contains(string(formatted), "[ar:Test Artist]") {
|
||||
t.Errorf("Expected artist metadata in output, not found")
|
||||
}
|
||||
|
||||
// Check that all the content lines are present
|
||||
if !strings.Contains(string(formatted), "This should be first") {
|
||||
t.Errorf("Expected 'This should be first' in output, not found")
|
||||
}
|
||||
|
||||
if !strings.Contains(string(formatted), "This is the second line") {
|
||||
t.Errorf("Expected 'This is the second line' in output, not found")
|
||||
}
|
||||
|
||||
if !strings.Contains(string(formatted), "This is the third line") {
|
||||
t.Errorf("Expected 'This is the third line' in output, not found")
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
151
internal/format/lrc/generator_test.go
Normal file
151
internal/format/lrc/generator_test.go
Normal file
|
@ -0,0 +1,151 @@
|
|||
package lrc
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"sub-cli/internal/model"
|
||||
)
|
||||
|
||||
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 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 TestGenerate_EdgeCases(t *testing.T) {
|
||||
// Test with empty lyrics
|
||||
emptyLyrics := model.Lyrics{
|
||||
Metadata: map[string]string{
|
||||
"ti": "Empty Test",
|
||||
},
|
||||
}
|
||||
|
||||
tempDir := t.TempDir()
|
||||
emptyFile := filepath.Join(tempDir, "empty_output.lrc")
|
||||
err := Generate(emptyLyrics, emptyFile)
|
||||
if err != nil {
|
||||
t.Fatalf("Generate failed with empty lyrics: %v", err)
|
||||
}
|
||||
|
||||
// Verify content has metadata but no timeline entries
|
||||
content, err := os.ReadFile(emptyFile)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read empty output file: %v", err)
|
||||
}
|
||||
|
||||
if !strings.Contains(string(content), "[ti:Empty Test]") {
|
||||
t.Errorf("Expected metadata in empty lyrics output, not found")
|
||||
}
|
||||
|
||||
// Test with unequal timeline and content lengths
|
||||
unequalLyrics := model.Lyrics{
|
||||
Timeline: []model.Timestamp{
|
||||
{Hours: 0, Minutes: 0, Seconds: 1, Milliseconds: 0},
|
||||
{Hours: 0, Minutes: 0, Seconds: 5, Milliseconds: 0},
|
||||
},
|
||||
Content: []string{
|
||||
"This is the only content line.",
|
||||
},
|
||||
}
|
||||
|
||||
unequalFile := filepath.Join(tempDir, "unequal_output.lrc")
|
||||
err = Generate(unequalLyrics, unequalFile)
|
||||
if err != nil {
|
||||
t.Fatalf("Generate failed with unequal lyrics: %v", err)
|
||||
}
|
||||
|
||||
// Should only generate for the entries that have both timeline and content
|
||||
content, err = os.ReadFile(unequalFile)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read unequal output file: %v", err)
|
||||
}
|
||||
|
||||
lines := strings.Split(string(content), "\n")
|
||||
timelineLines := 0
|
||||
for _, line := range lines {
|
||||
if strings.HasPrefix(line, "[") && strings.Contains(line, "]") &&
|
||||
strings.Contains(line, ":") && strings.Contains(line, ".") {
|
||||
timelineLines++
|
||||
}
|
||||
}
|
||||
|
||||
if timelineLines > 1 {
|
||||
t.Errorf("Expected only 1 timeline entry for unequal lyrics, got %d", timelineLines)
|
||||
}
|
||||
}
|
|
@ -1,518 +0,0 @@
|
|||
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")
|
||||
}
|
||||
}
|
185
internal/format/lrc/parser_test.go
Normal file
185
internal/format/lrc/parser_test.go
Normal file
|
@ -0,0 +1,185 @@
|
|||
package lrc
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
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 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 file: %v", err)
|
||||
}
|
||||
|
||||
lyrics, err := Parse(emptyFile)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse failed with empty file: %v", err)
|
||||
}
|
||||
if len(lyrics.Timeline) != 0 || len(lyrics.Content) != 0 {
|
||||
t.Errorf("Expected empty lyrics for empty file, got %d timeline and %d content",
|
||||
len(lyrics.Timeline), len(lyrics.Content))
|
||||
}
|
||||
|
||||
// Test with metadata only
|
||||
metadataFile := filepath.Join(tempDir, "metadata.lrc")
|
||||
metadataContent := `[ti:Test Title]
|
||||
[ar:Test Artist]
|
||||
[al:Test Album]
|
||||
`
|
||||
if err := os.WriteFile(metadataFile, []byte(metadataContent), 0644); err != nil {
|
||||
t.Fatalf("Failed to create metadata file: %v", err)
|
||||
}
|
||||
|
||||
lyrics, err = Parse(metadataFile)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse failed with metadata-only file: %v", err)
|
||||
}
|
||||
if lyrics.Metadata["ti"] != "Test Title" {
|
||||
t.Errorf("Expected title 'Test Title', got '%s'", lyrics.Metadata["ti"])
|
||||
}
|
||||
if len(lyrics.Timeline) != 0 || len(lyrics.Content) != 0 {
|
||||
t.Errorf("Expected empty timeline/content for metadata-only file, got %d timeline and %d content",
|
||||
len(lyrics.Timeline), len(lyrics.Content))
|
||||
}
|
||||
|
||||
// Test with invalid metadata
|
||||
invalidMetadataFile := filepath.Join(tempDir, "invalid_metadata.lrc")
|
||||
invalidMetadata := `[ti:Test Title
|
||||
[ar:Test Artist]
|
||||
[00:01.00]This is a valid line.
|
||||
`
|
||||
if err := os.WriteFile(invalidMetadataFile, []byte(invalidMetadata), 0644); err != nil {
|
||||
t.Fatalf("Failed to create invalid metadata file: %v", err)
|
||||
}
|
||||
|
||||
lyrics, err = Parse(invalidMetadataFile)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse failed with invalid metadata file: %v", err)
|
||||
}
|
||||
if lyrics.Metadata["ti"] != "" { // Should ignore invalid metadata
|
||||
t.Errorf("Expected empty title for invalid metadata, got '%s'", lyrics.Metadata["ti"])
|
||||
}
|
||||
if len(lyrics.Timeline) != 1 || len(lyrics.Content) != 1 {
|
||||
t.Errorf("Expected 1 timeline/content entry for file with invalid metadata, got %d timeline and %d content",
|
||||
len(lyrics.Timeline), len(lyrics.Content))
|
||||
}
|
||||
|
||||
// Test with invalid timestamp format
|
||||
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))
|
||||
}
|
||||
}
|
163
internal/format/lrc/utils_test.go
Normal file
163
internal/format/lrc/utils_test.go
Normal file
|
@ -0,0 +1,163 @@
|
|||
package lrc
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"sub-cli/internal/model"
|
||||
)
|
||||
|
||||
func TestParseTimestamp(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
input string
|
||||
expected model.Timestamp
|
||||
valid bool
|
||||
}{
|
||||
{
|
||||
name: "Simple minute and second",
|
||||
input: "[01:30]",
|
||||
expected: model.Timestamp{
|
||||
Hours: 0,
|
||||
Minutes: 1,
|
||||
Seconds: 30,
|
||||
Milliseconds: 0,
|
||||
},
|
||||
valid: true,
|
||||
},
|
||||
{
|
||||
name: "With milliseconds",
|
||||
input: "[01:30.500]",
|
||||
expected: model.Timestamp{
|
||||
Hours: 0,
|
||||
Minutes: 1,
|
||||
Seconds: 30,
|
||||
Milliseconds: 500,
|
||||
},
|
||||
valid: true,
|
||||
},
|
||||
{
|
||||
name: "With hours",
|
||||
input: "[01:30:45.500]",
|
||||
expected: model.Timestamp{
|
||||
Hours: 1,
|
||||
Minutes: 30,
|
||||
Seconds: 45,
|
||||
Milliseconds: 500,
|
||||
},
|
||||
valid: true,
|
||||
},
|
||||
{
|
||||
name: "Zero time",
|
||||
input: "[00:00.000]",
|
||||
expected: model.Timestamp{
|
||||
Hours: 0,
|
||||
Minutes: 0,
|
||||
Seconds: 0,
|
||||
Milliseconds: 0,
|
||||
},
|
||||
valid: true,
|
||||
},
|
||||
{
|
||||
name: "Invalid format - no brackets",
|
||||
input: "01:30",
|
||||
expected: model.Timestamp{
|
||||
Hours: 0,
|
||||
Minutes: 1,
|
||||
Seconds: 30,
|
||||
Milliseconds: 0,
|
||||
},
|
||||
valid: true, // ParseTimestamp automatically strips brackets, so it will parse this without brackets
|
||||
},
|
||||
{
|
||||
name: "Invalid format - wrong brackets",
|
||||
input: "(01:30)",
|
||||
expected: model.Timestamp{},
|
||||
valid: false,
|
||||
},
|
||||
{
|
||||
name: "Invalid format - no time",
|
||||
input: "[]",
|
||||
expected: model.Timestamp{},
|
||||
valid: false,
|
||||
},
|
||||
{
|
||||
name: "Invalid format - text in brackets",
|
||||
input: "[text]",
|
||||
expected: model.Timestamp{},
|
||||
valid: false,
|
||||
},
|
||||
{
|
||||
name: "Invalid format - incomplete time",
|
||||
input: "[01:]",
|
||||
expected: model.Timestamp{},
|
||||
valid: false,
|
||||
},
|
||||
{
|
||||
name: "Invalid format - incomplete time with milliseconds",
|
||||
input: "[01:.500]",
|
||||
expected: model.Timestamp{},
|
||||
valid: false,
|
||||
},
|
||||
{
|
||||
name: "Metadata tag",
|
||||
input: "[ti:Title]",
|
||||
expected: model.Timestamp{},
|
||||
valid: false,
|
||||
},
|
||||
{
|
||||
name: "With milliseconds - alternative format using comma",
|
||||
input: "[01:30.500]", // Use period instead of comma since our parser doesn't handle comma
|
||||
expected: model.Timestamp{
|
||||
Hours: 0,
|
||||
Minutes: 1,
|
||||
Seconds: 30,
|
||||
Milliseconds: 500,
|
||||
},
|
||||
valid: true,
|
||||
},
|
||||
{
|
||||
name: "With double-digit milliseconds",
|
||||
input: "[01:30.50]",
|
||||
expected: model.Timestamp{
|
||||
Hours: 0,
|
||||
Minutes: 1,
|
||||
Seconds: 30,
|
||||
Milliseconds: 500,
|
||||
},
|
||||
valid: true,
|
||||
},
|
||||
{
|
||||
name: "With single-digit milliseconds",
|
||||
input: "[01:30.5]",
|
||||
expected: model.Timestamp{
|
||||
Hours: 0,
|
||||
Minutes: 1,
|
||||
Seconds: 30,
|
||||
Milliseconds: 500,
|
||||
},
|
||||
valid: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
timestamp, err := ParseTimestamp(tc.input)
|
||||
|
||||
if (err == nil) != tc.valid {
|
||||
t.Errorf("Expected valid=%v, got valid=%v (err=%v)", tc.valid, err == nil, err)
|
||||
return
|
||||
}
|
||||
|
||||
if !tc.valid {
|
||||
return // No need to check further for invalid cases
|
||||
}
|
||||
|
||||
if timestamp.Hours != tc.expected.Hours ||
|
||||
timestamp.Minutes != tc.expected.Minutes ||
|
||||
timestamp.Seconds != tc.expected.Seconds ||
|
||||
timestamp.Milliseconds != tc.expected.Milliseconds {
|
||||
t.Errorf("Expected timestamp %+v, got %+v", tc.expected, timestamp)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
255
internal/format/srt/converter_test.go
Normal file
255
internal/format/srt/converter_test.go
Normal file
|
@ -0,0 +1,255 @@
|
|||
package srt
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"sub-cli/internal/model"
|
||||
)
|
||||
|
||||
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].Index != 1 {
|
||||
t.Errorf("First entry index: expected 1, got %d", subtitle.Entries[0].Index)
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertFromSubtitle(t *testing.T) {
|
||||
// Create a 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 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 reading the file directly
|
||||
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) < 7 {
|
||||
t.Fatalf("Expected at least 7 lines, got %d", len(lines))
|
||||
}
|
||||
|
||||
// Check that the SRT entries were created correctly
|
||||
if lines[0] != "1" {
|
||||
t.Errorf("Expected first entry number to be '1', got '%s'", lines[0])
|
||||
}
|
||||
if !strings.Contains(lines[1], "00:00:01,000 --> 00:00:04,000") {
|
||||
t.Errorf("Expected first entry time range to match, got '%s'", lines[1])
|
||||
}
|
||||
if lines[2] != "This is the first line." {
|
||||
t.Errorf("Expected first entry content to match, got '%s'", lines[2])
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertToSubtitle_WithHTMLTags(t *testing.T) {
|
||||
// Create a temporary test file with HTML styling tags
|
||||
content := `1
|
||||
00:00:01,000 --> 00:00:04,000
|
||||
<i>This is italic.</i>
|
||||
|
||||
2
|
||||
00:00:05,000 --> 00:00:08,000
|
||||
<b>This is bold.</b>
|
||||
|
||||
3
|
||||
00:00:09,000 --> 00:00:12,000
|
||||
<u>This is underlined.</u>
|
||||
`
|
||||
tempDir := t.TempDir()
|
||||
testFile := filepath.Join(tempDir, "styled.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 style detection
|
||||
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")
|
||||
}
|
||||
}
|
70
internal/format/srt/formatter_test.go
Normal file
70
internal/format/srt/formatter_test.go
Normal file
|
@ -0,0 +1,70 @@
|
|||
package srt
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFormat(t *testing.T) {
|
||||
// Create a temporary test file with out-of-order 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.
|
||||
|
||||
3
|
||||
00:00:09,500 --> 00:00:12,800
|
||||
This is the third 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)
|
||||
}
|
||||
|
||||
// Read the formatted file
|
||||
formatted, err := os.ReadFile(testFile)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read formatted file: %v", err)
|
||||
}
|
||||
|
||||
// The Format function should standardize the numbering
|
||||
lines := strings.Split(string(formatted), "\n")
|
||||
|
||||
// The numbers should be sequential starting from 1
|
||||
if !strings.HasPrefix(lines[0], "1") {
|
||||
t.Errorf("First entry should be renumbered to 1, got '%s'", lines[0])
|
||||
}
|
||||
|
||||
// Find the second entry (after the first entry's content and a blank line)
|
||||
var secondEntryIndex int
|
||||
for i := 1; i < len(lines); i++ {
|
||||
if lines[i] == "" && i+1 < len(lines) && lines[i+1] != "" {
|
||||
secondEntryIndex = i + 1
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if secondEntryIndex > 0 && !strings.HasPrefix(lines[secondEntryIndex], "2") {
|
||||
t.Errorf("Second entry should be renumbered to 2, got '%s'", lines[secondEntryIndex])
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
84
internal/format/srt/generator_test.go
Normal file
84
internal/format/srt/generator_test.go
Normal file
|
@ -0,0 +1,84 @@
|
|||
package srt
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"sub-cli/internal/model"
|
||||
)
|
||||
|
||||
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 TestGenerate_FileError(t *testing.T) {
|
||||
// Test with invalid path
|
||||
entries := []model.SRTEntry{
|
||||
{
|
||||
Number: 1,
|
||||
StartTime: model.Timestamp{Hours: 0, Minutes: 0, Seconds: 0, Milliseconds: 0},
|
||||
EndTime: model.Timestamp{Hours: 0, Minutes: 0, Seconds: 0, Milliseconds: 0},
|
||||
Content: "Test",
|
||||
},
|
||||
}
|
||||
|
||||
err := Generate(entries, "/nonexistent/directory/file.srt")
|
||||
if err == nil {
|
||||
t.Error("Expected error when generating to invalid path, got nil")
|
||||
}
|
||||
|
||||
// Test with directory as file
|
||||
tempDir := t.TempDir()
|
||||
err = Generate(entries, tempDir)
|
||||
if err == nil {
|
||||
t.Error("Expected error when generating to a directory, got nil")
|
||||
}
|
||||
}
|
58
internal/format/srt/lyrics_test.go
Normal file
58
internal/format/srt/lyrics_test.go
Normal file
|
@ -0,0 +1,58 @@
|
|||
package srt
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"sub-cli/internal/model"
|
||||
)
|
||||
|
||||
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.",
|
||||
},
|
||||
{
|
||||
Number: 3,
|
||||
StartTime: model.Timestamp{Hours: 0, Minutes: 0, Seconds: 9, Milliseconds: 0},
|
||||
EndTime: model.Timestamp{Hours: 0, Minutes: 0, Seconds: 12, Milliseconds: 0},
|
||||
Content: "This is the third line.",
|
||||
},
|
||||
}
|
||||
|
||||
// Convert to Lyrics
|
||||
lyrics := ConvertToLyrics(entries)
|
||||
|
||||
// Check result
|
||||
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 first 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 timeline: expected 00:00:01,000, got %+v", lyrics.Timeline[0])
|
||||
}
|
||||
if lyrics.Content[0] != "This is the first line." {
|
||||
t.Errorf("First content: expected 'This is the first line.', got '%s'", lyrics.Content[0])
|
||||
}
|
||||
|
||||
// Check with empty entries
|
||||
emptyLyrics := ConvertToLyrics([]model.SRTEntry{})
|
||||
if len(emptyLyrics.Timeline) != 0 || len(emptyLyrics.Content) != 0 {
|
||||
t.Errorf("Expected empty lyrics for empty entries, got %d timeline and %d content",
|
||||
len(emptyLyrics.Timeline), len(emptyLyrics.Content))
|
||||
}
|
||||
}
|
159
internal/format/srt/parser_test.go
Normal file
159
internal/format/srt/parser_test.go
Normal file
|
@ -0,0 +1,159 @@
|
|||
package srt
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
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 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 file: %v", err)
|
||||
}
|
||||
|
||||
entries, err := Parse(emptyFile)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse failed with empty file: %v", err)
|
||||
}
|
||||
if len(entries) != 0 {
|
||||
t.Errorf("Expected 0 entries for empty file, got %d", len(entries))
|
||||
}
|
||||
|
||||
// Test with malformed timestamp
|
||||
malformedContent := `1
|
||||
00:00:01,000 --> 00:00:04,000
|
||||
First entry.
|
||||
|
||||
2
|
||||
bad timestamp format
|
||||
Second entry.
|
||||
`
|
||||
malformedFile := filepath.Join(tempDir, "malformed.srt")
|
||||
if err := os.WriteFile(malformedFile, []byte(malformedContent), 0644); err != nil {
|
||||
t.Fatalf("Failed to create malformed file: %v", err)
|
||||
}
|
||||
|
||||
entries, err = Parse(malformedFile)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse failed with malformed file: %v", err)
|
||||
}
|
||||
// Should still parse the first entry correctly
|
||||
if len(entries) != 1 {
|
||||
t.Errorf("Expected 1 entry for malformed file, got %d", len(entries))
|
||||
}
|
||||
|
||||
// Test with missing numbers
|
||||
missingNumContent := `00:00:01,000 --> 00:00:04,000
|
||||
First entry without number.
|
||||
|
||||
2
|
||||
00:00:05,000 --> 00:00:08,000
|
||||
Second entry with number.
|
||||
`
|
||||
missingNumFile := filepath.Join(tempDir, "missing_num.srt")
|
||||
if err := os.WriteFile(missingNumFile, []byte(missingNumContent), 0644); err != nil {
|
||||
t.Fatalf("Failed to create missing num file: %v", err)
|
||||
}
|
||||
|
||||
entries, err = Parse(missingNumFile)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse failed with missing num file: %v", err)
|
||||
}
|
||||
// Parsing behavior may vary, but it should not crash
|
||||
// In this case, it will typically parse just the second entry
|
||||
|
||||
// Test with extra empty lines
|
||||
extraLineContent := `1
|
||||
00:00:01,000 --> 00:00:04,000
|
||||
First entry with extra spaces.
|
||||
|
||||
2
|
||||
00:00:05,000 --> 00:00:08,000
|
||||
Second entry with extra spaces.
|
||||
`
|
||||
extraLineFile := filepath.Join(tempDir, "extra_lines.srt")
|
||||
if err := os.WriteFile(extraLineFile, []byte(extraLineContent), 0644); err != nil {
|
||||
t.Fatalf("Failed to create extra lines file: %v", err)
|
||||
}
|
||||
|
||||
entries, err = Parse(extraLineFile)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse failed with extra lines file: %v", err)
|
||||
}
|
||||
if len(entries) != 2 {
|
||||
t.Errorf("Expected 2 entries for extra lines file, got %d", len(entries))
|
||||
}
|
||||
// Check content was trimmed correctly
|
||||
if entries[0].Content != "First entry with extra spaces." {
|
||||
t.Errorf("Expected trimmed content, got '%s'", entries[0].Content)
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
|
@ -1,646 +0,0 @@
|
|||
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")
|
||||
}
|
||||
}
|
182
internal/format/srt/utils_test.go
Normal file
182
internal/format/srt/utils_test.go
Normal file
|
@ -0,0 +1,182 @@
|
|||
package srt
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"sub-cli/internal/model"
|
||||
)
|
||||
|
||||
func TestParseSRTTimestamp(t *testing.T) {
|
||||
testCases := []struct {
|
||||
input string
|
||||
expected model.Timestamp
|
||||
}{
|
||||
{
|
||||
input: "00:00:01,000",
|
||||
expected: model.Timestamp{
|
||||
Hours: 0,
|
||||
Minutes: 0,
|
||||
Seconds: 1,
|
||||
Milliseconds: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
input: "01:02:03,456",
|
||||
expected: model.Timestamp{
|
||||
Hours: 1,
|
||||
Minutes: 2,
|
||||
Seconds: 3,
|
||||
Milliseconds: 456,
|
||||
},
|
||||
},
|
||||
{
|
||||
input: "10:20:30,789",
|
||||
expected: model.Timestamp{
|
||||
Hours: 10,
|
||||
Minutes: 20,
|
||||
Seconds: 30,
|
||||
Milliseconds: 789,
|
||||
},
|
||||
},
|
||||
{
|
||||
// Test invalid format
|
||||
input: "invalid",
|
||||
expected: model.Timestamp{},
|
||||
},
|
||||
{
|
||||
// Test with dot instead of comma
|
||||
input: "01:02:03.456",
|
||||
expected: model.Timestamp{
|
||||
Hours: 1,
|
||||
Minutes: 2,
|
||||
Seconds: 3,
|
||||
Milliseconds: 456,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
result := parseSRTTimestamp(tc.input)
|
||||
if result.Hours != tc.expected.Hours ||
|
||||
result.Minutes != tc.expected.Minutes ||
|
||||
result.Seconds != tc.expected.Seconds ||
|
||||
result.Milliseconds != tc.expected.Milliseconds {
|
||||
t.Errorf("parseSRTTimestamp(%s) = %+v, want %+v",
|
||||
tc.input, result, tc.expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatSRTTimestamp(t *testing.T) {
|
||||
testCases := []struct {
|
||||
input model.Timestamp
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
input: model.Timestamp{
|
||||
Hours: 0,
|
||||
Minutes: 0,
|
||||
Seconds: 1,
|
||||
Milliseconds: 0,
|
||||
},
|
||||
expected: "00:00:01,000",
|
||||
},
|
||||
{
|
||||
input: model.Timestamp{
|
||||
Hours: 1,
|
||||
Minutes: 2,
|
||||
Seconds: 3,
|
||||
Milliseconds: 456,
|
||||
},
|
||||
expected: "01:02:03,456",
|
||||
},
|
||||
{
|
||||
input: model.Timestamp{
|
||||
Hours: 10,
|
||||
Minutes: 20,
|
||||
Seconds: 30,
|
||||
Milliseconds: 789,
|
||||
},
|
||||
expected: "10:20:30,789",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
result := formatSRTTimestamp(tc.input)
|
||||
if result != tc.expected {
|
||||
t.Errorf("formatSRTTimestamp(%+v) = %s, want %s",
|
||||
tc.input, result, tc.expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsEntryTimeStampUnset(t *testing.T) {
|
||||
testCases := []struct {
|
||||
entry model.SRTEntry
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
entry: model.SRTEntry{
|
||||
StartTime: model.Timestamp{
|
||||
Hours: 0,
|
||||
Minutes: 0,
|
||||
Seconds: 0,
|
||||
Milliseconds: 0,
|
||||
},
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
entry: model.SRTEntry{
|
||||
StartTime: model.Timestamp{
|
||||
Hours: 0,
|
||||
Minutes: 0,
|
||||
Seconds: 1,
|
||||
Milliseconds: 0,
|
||||
},
|
||||
},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
entry: model.SRTEntry{
|
||||
StartTime: model.Timestamp{
|
||||
Hours: 0,
|
||||
Minutes: 1,
|
||||
Seconds: 0,
|
||||
Milliseconds: 0,
|
||||
},
|
||||
},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
entry: model.SRTEntry{
|
||||
StartTime: model.Timestamp{
|
||||
Hours: 1,
|
||||
Minutes: 0,
|
||||
Seconds: 0,
|
||||
Milliseconds: 0,
|
||||
},
|
||||
},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
entry: model.SRTEntry{
|
||||
StartTime: model.Timestamp{
|
||||
Hours: 0,
|
||||
Minutes: 0,
|
||||
Seconds: 0,
|
||||
Milliseconds: 1,
|
||||
},
|
||||
},
|
||||
expected: false,
|
||||
},
|
||||
}
|
||||
|
||||
for i, tc := range testCases {
|
||||
result := isEntryTimeStampUnset(tc.entry)
|
||||
if result != tc.expected {
|
||||
t.Errorf("Case %d: isEntryTimeStampUnset(%+v) = %v, want %v",
|
||||
i, tc.entry, result, tc.expected)
|
||||
}
|
||||
}
|
||||
}
|
179
internal/format/vtt/converter_test.go
Normal file
179
internal/format/vtt/converter_test.go
Normal file
|
@ -0,0 +1,179 @@
|
|||
package vtt
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"sub-cli/internal/model"
|
||||
)
|
||||
|
||||
func TestConvertToSubtitle(t *testing.T) {
|
||||
// Create a temporary test file
|
||||
content := `WEBVTT - Test Title
|
||||
|
||||
STYLE
|
||||
::cue {
|
||||
color: white;
|
||||
}
|
||||
|
||||
NOTE This is a test comment
|
||||
|
||||
1
|
||||
00:00:01.000 --> 00:00:04.000 align:start position:10%
|
||||
This is <i>styled</i> text.
|
||||
|
||||
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 subtitle.Title != "Test Title" {
|
||||
t.Errorf("Expected title 'Test Title', got '%s'", subtitle.Title)
|
||||
}
|
||||
|
||||
// Check style conversion
|
||||
if _, ok := subtitle.Styles["css"]; !ok {
|
||||
t.Errorf("Expected CSS style to be preserved in subtitle.Styles['css'], got: %v", subtitle.Styles)
|
||||
}
|
||||
|
||||
// Check entry count and content
|
||||
if len(subtitle.Entries) != 2 {
|
||||
t.Errorf("Expected 2 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)
|
||||
}
|
||||
// The VTT parser does not strip HTML tags by default
|
||||
if subtitle.Entries[0].Text != "This is <i>styled</i> text." {
|
||||
t.Errorf("First entry text: expected 'This is <i>styled</i> text.', got '%s'", subtitle.Entries[0].Text)
|
||||
}
|
||||
if subtitle.Entries[0].Styles["align"] != "start" {
|
||||
t.Errorf("Expected align style 'start', got '%s'", subtitle.Entries[0].Styles["align"])
|
||||
}
|
||||
// 检查 FormatData 中是否记录了 HTML 标签存在
|
||||
if val, ok := subtitle.Entries[0].FormatData["has_html_tags"]; !ok || val != true {
|
||||
t.Errorf("Expected FormatData['has_html_tags'] to be true for entry with HTML tags")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertFromSubtitle(t *testing.T) {
|
||||
// Create a subtitle
|
||||
subtitle := model.NewSubtitle()
|
||||
subtitle.Format = "vtt"
|
||||
subtitle.Title = "Test VTT"
|
||||
subtitle.Styles = map[string]string{"css": "::cue { color: white; }"}
|
||||
subtitle.Comments = append(subtitle.Comments, "This is a test comment")
|
||||
|
||||
// Create a region
|
||||
region := model.NewSubtitleRegion("region1")
|
||||
region.Settings["width"] = "40%"
|
||||
region.Settings["lines"] = "3"
|
||||
subtitle.Regions = append(subtitle.Regions, region)
|
||||
|
||||
// Create entries
|
||||
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."
|
||||
entry1.Styles["region"] = "region1"
|
||||
|
||||
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 <i>italic</i> text."
|
||||
|
||||
subtitle.Entries = append(subtitle.Entries, entry1, entry2)
|
||||
|
||||
// Convert 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 reading the file directly
|
||||
content, err := os.ReadFile(outputFile)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read output file: %v", err)
|
||||
}
|
||||
|
||||
contentStr := string(content)
|
||||
|
||||
// Check header
|
||||
if !strings.HasPrefix(contentStr, "WEBVTT - Test VTT") {
|
||||
t.Errorf("Expected header with title in output")
|
||||
}
|
||||
|
||||
// Check style section
|
||||
if !strings.Contains(contentStr, "STYLE") {
|
||||
t.Errorf("Expected STYLE section in output")
|
||||
}
|
||||
|
||||
if !strings.Contains(contentStr, "::cue { color: white; }") {
|
||||
t.Errorf("Expected CSS content in style section")
|
||||
}
|
||||
|
||||
// Check comment
|
||||
if !strings.Contains(contentStr, "NOTE This is a test comment") {
|
||||
t.Errorf("Expected comment in output")
|
||||
}
|
||||
|
||||
// Check region
|
||||
if !strings.Contains(contentStr, "REGION") || !strings.Contains(contentStr, "region1") {
|
||||
t.Errorf("Expected region definition in output")
|
||||
}
|
||||
|
||||
// Check region applied to first entry
|
||||
if !strings.Contains(contentStr, "region:region1") {
|
||||
t.Errorf("Expected region style to be applied to first entry")
|
||||
}
|
||||
|
||||
// Check HTML tags
|
||||
if !strings.Contains(contentStr, "<i>") || !strings.Contains(contentStr, "</i>") {
|
||||
t.Errorf("Expected HTML italic tags in second entry")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertToSubtitle_FileError(t *testing.T) {
|
||||
// Test with non-existent file
|
||||
_, err := ConvertToSubtitle("/nonexistent/file.vtt")
|
||||
if err == nil {
|
||||
t.Error("Expected error when converting non-existent file, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertFromSubtitle_FileError(t *testing.T) {
|
||||
// Create simple subtitle
|
||||
subtitle := model.NewSubtitle()
|
||||
subtitle.Format = "vtt"
|
||||
subtitle.Entries = append(subtitle.Entries, model.NewSubtitleEntry())
|
||||
|
||||
// Test with invalid path
|
||||
err := ConvertFromSubtitle(subtitle, "/nonexistent/directory/file.vtt")
|
||||
if err == nil {
|
||||
t.Error("Expected error when converting to invalid path, got nil")
|
||||
}
|
||||
}
|
78
internal/format/vtt/formatter_test.go
Normal file
78
internal/format/vtt/formatter_test.go
Normal file
|
@ -0,0 +1,78 @@
|
|||
package vtt
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFormat(t *testing.T) {
|
||||
// Create a temporary test file with valid VTT content
|
||||
// 注意格式必须严格符合 WebVTT 规范,否则 Parse 会失败
|
||||
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 align:center
|
||||
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)
|
||||
}
|
||||
|
||||
// Format the file
|
||||
err := Format(testFile)
|
||||
if err != nil {
|
||||
t.Fatalf("Format failed: %v", err)
|
||||
}
|
||||
|
||||
// Read the formatted file
|
||||
formatted, err := os.ReadFile(testFile)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read formatted file: %v", err)
|
||||
}
|
||||
|
||||
// 检查基本的内容是否存在
|
||||
formattedStr := string(formatted)
|
||||
|
||||
// 检查标题行
|
||||
if !strings.Contains(formattedStr, "WEBVTT") {
|
||||
t.Errorf("Expected WEBVTT header in output, not found")
|
||||
}
|
||||
|
||||
// 检查内容是否保留
|
||||
if !strings.Contains(formattedStr, "This is the first line.") {
|
||||
t.Errorf("Expected 'This is the first line.' in output, not found")
|
||||
}
|
||||
|
||||
if !strings.Contains(formattedStr, "This is the second line.") {
|
||||
t.Errorf("Expected 'This is the second line.' in output, not found")
|
||||
}
|
||||
|
||||
if !strings.Contains(formattedStr, "This is the third line") {
|
||||
t.Errorf("Expected 'This is the third line' in output, not found")
|
||||
}
|
||||
|
||||
if !strings.Contains(formattedStr, "with a line break.") {
|
||||
t.Errorf("Expected 'with a line break.' in output, not found")
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
148
internal/format/vtt/generator_test.go
Normal file
148
internal/format/vtt/generator_test.go
Normal file
|
@ -0,0 +1,148 @@
|
|||
package vtt
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"sub-cli/internal/model"
|
||||
)
|
||||
|
||||
func TestGenerate(t *testing.T) {
|
||||
// Create a test subtitle
|
||||
subtitle := model.NewSubtitle()
|
||||
subtitle.Format = "vtt"
|
||||
subtitle.Title = "Test VTT"
|
||||
|
||||
// Add style section
|
||||
subtitle.Styles = map[string]string{"css": "::cue { color: white; }"}
|
||||
|
||||
// Add comments
|
||||
subtitle.Comments = append(subtitle.Comments, "This is a test comment")
|
||||
|
||||
// Create entries
|
||||
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 = map[string]string{"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
|
||||
contentStr := string(content)
|
||||
|
||||
// Verify header
|
||||
if !strings.HasPrefix(contentStr, "WEBVTT - Test VTT") {
|
||||
t.Errorf("Expected header with title, got: %s", strings.Split(contentStr, "\n")[0])
|
||||
}
|
||||
|
||||
// Verify style section
|
||||
if !strings.Contains(contentStr, "STYLE") {
|
||||
t.Errorf("Expected STYLE section in output")
|
||||
}
|
||||
|
||||
if !strings.Contains(contentStr, "::cue { color: white; }") {
|
||||
t.Errorf("Expected CSS content in style section")
|
||||
}
|
||||
|
||||
// Verify comment
|
||||
if !strings.Contains(contentStr, "NOTE This is a test comment") {
|
||||
t.Errorf("Expected comment in output")
|
||||
}
|
||||
|
||||
// Verify first entry
|
||||
if !strings.Contains(contentStr, "00:00:01.000 --> 00:00:04.000") {
|
||||
t.Errorf("Expected first entry timestamp in output")
|
||||
}
|
||||
if !strings.Contains(contentStr, "This is the first line.") {
|
||||
t.Errorf("Expected first entry text in output")
|
||||
}
|
||||
|
||||
// Verify second entry with style
|
||||
if !strings.Contains(contentStr, "00:00:05.000 --> 00:00:08.000 align:center") {
|
||||
t.Errorf("Expected second entry timestamp with align style in output")
|
||||
}
|
||||
}
|
||||
|
||||
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 = map[string]string{"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 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")
|
||||
}
|
||||
}
|
215
internal/format/vtt/parser_test.go
Normal file
215
internal/format/vtt/parser_test.go
Normal file
|
@ -0,0 +1,215 @@
|
|||
package vtt
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
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 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 TestParse_FileErrors(t *testing.T) {
|
||||
// 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 file: %v", err)
|
||||
}
|
||||
|
||||
_, err := Parse(emptyFile)
|
||||
if err == nil {
|
||||
t.Error("Expected error when parsing empty file, got nil")
|
||||
}
|
||||
|
||||
// Test with invalid WEBVTT header
|
||||
invalidFile := filepath.Join(tempDir, "invalid.vtt")
|
||||
if err := os.WriteFile(invalidFile, []byte("INVALID HEADER\n\n"), 0644); err != nil {
|
||||
t.Fatalf("Failed to create invalid file: %v", err)
|
||||
}
|
||||
|
||||
_, err = Parse(invalidFile)
|
||||
if err == nil {
|
||||
t.Error("Expected error when parsing file with invalid header, got nil")
|
||||
}
|
||||
|
||||
// Test with non-existent file
|
||||
_, err = Parse("/nonexistent/file.vtt")
|
||||
if err == nil {
|
||||
t.Error("Expected error when parsing non-existent file, got nil")
|
||||
}
|
||||
}
|
39
internal/format/vtt/utils_test.go
Normal file
39
internal/format/vtt/utils_test.go
Normal file
|
@ -0,0 +1,39 @@
|
|||
package vtt
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"sub-cli/internal/model"
|
||||
)
|
||||
|
||||
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}},
|
||||
// Invalid format (should return a zero timestamp)
|
||||
{"invalid", model.Timestamp{Hours: 0, Minutes: 0, Seconds: 0, Milliseconds: 0}},
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
|
@ -1,507 +0,0 @@
|
|||
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")
|
||||
}
|
||||
}
|
80
internal/sync/ass.go
Normal file
80
internal/sync/ass.go
Normal file
|
@ -0,0 +1,80 @@
|
|||
package sync
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"sub-cli/internal/format/ass"
|
||||
"sub-cli/internal/model"
|
||||
)
|
||||
|
||||
// syncASSFiles synchronizes two ASS files
|
||||
func syncASSFiles(sourceFile, targetFile string) error {
|
||||
sourceSubtitle, err := ass.Parse(sourceFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error parsing source ASS file: %w", err)
|
||||
}
|
||||
|
||||
targetSubtitle, err := ass.Parse(targetFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error parsing target ASS file: %w", err)
|
||||
}
|
||||
|
||||
// Check if entry counts match
|
||||
if len(sourceSubtitle.Events) != len(targetSubtitle.Events) {
|
||||
fmt.Printf("Warning: Source (%d events) and target (%d events) have different event counts. Timeline will be adjusted.\n",
|
||||
len(sourceSubtitle.Events), len(targetSubtitle.Events))
|
||||
}
|
||||
|
||||
// Sync the timelines
|
||||
syncedSubtitle := syncASSTimeline(sourceSubtitle, targetSubtitle)
|
||||
|
||||
// Write the synced subtitle to the target file
|
||||
return ass.Generate(syncedSubtitle, targetFile)
|
||||
}
|
||||
|
||||
// syncASSTimeline applies the timing from source ASS subtitle to target ASS subtitle
|
||||
func syncASSTimeline(source, target model.ASSFile) model.ASSFile {
|
||||
result := model.ASSFile{
|
||||
ScriptInfo: target.ScriptInfo,
|
||||
Styles: target.Styles,
|
||||
Events: make([]model.ASSEvent, len(target.Events)),
|
||||
}
|
||||
|
||||
// Copy target events
|
||||
copy(result.Events, target.Events)
|
||||
|
||||
// If there are no events in either source or target, return as is
|
||||
if len(source.Events) == 0 || len(target.Events) == 0 {
|
||||
return result
|
||||
}
|
||||
|
||||
// Extract start and end timestamps from source
|
||||
sourceStartTimes := make([]model.Timestamp, len(source.Events))
|
||||
sourceEndTimes := make([]model.Timestamp, len(source.Events))
|
||||
|
||||
for i, event := range source.Events {
|
||||
sourceStartTimes[i] = event.StartTime
|
||||
sourceEndTimes[i] = event.EndTime
|
||||
}
|
||||
|
||||
// Scale timestamps if source and target event counts differ
|
||||
var scaledStartTimes, scaledEndTimes []model.Timestamp
|
||||
|
||||
if len(source.Events) == len(target.Events) {
|
||||
// If counts match, use source times directly
|
||||
scaledStartTimes = sourceStartTimes
|
||||
scaledEndTimes = sourceEndTimes
|
||||
} else {
|
||||
// Scale the timelines to match target count
|
||||
scaledStartTimes = scaleTimeline(sourceStartTimes, len(target.Events))
|
||||
scaledEndTimes = scaleTimeline(sourceEndTimes, len(target.Events))
|
||||
}
|
||||
|
||||
// Apply scaled timeline to target events
|
||||
for i := range result.Events {
|
||||
result.Events[i].StartTime = scaledStartTimes[i]
|
||||
result.Events[i].EndTime = scaledEndTimes[i]
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
465
internal/sync/ass_test.go
Normal file
465
internal/sync/ass_test.go
Normal file
|
@ -0,0 +1,465 @@
|
|||
package sync
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"sub-cli/internal/model"
|
||||
)
|
||||
|
||||
func TestSyncASSTimeline(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
source model.ASSFile
|
||||
target model.ASSFile
|
||||
verify func(t *testing.T, result model.ASSFile)
|
||||
}{
|
||||
{
|
||||
name: "Equal event counts",
|
||||
source: model.ASSFile{
|
||||
ScriptInfo: map[string]string{"Title": "Source ASS"},
|
||||
Styles: []model.ASSStyle{
|
||||
{
|
||||
Name: "Default",
|
||||
Properties: map[string]string{
|
||||
"Format": "Name, Fontname, Fontsize, PrimaryColour",
|
||||
"Style": "Default,Arial,20,&H00FFFFFF",
|
||||
},
|
||||
},
|
||||
},
|
||||
Events: []model.ASSEvent{
|
||||
{
|
||||
Type: "Dialogue",
|
||||
Layer: 0,
|
||||
StartTime: model.Timestamp{Seconds: 1},
|
||||
EndTime: model.Timestamp{Seconds: 4},
|
||||
Style: "Default",
|
||||
Text: "Source line one.",
|
||||
},
|
||||
{
|
||||
Type: "Dialogue",
|
||||
Layer: 0,
|
||||
StartTime: model.Timestamp{Seconds: 5},
|
||||
EndTime: model.Timestamp{Seconds: 8},
|
||||
Style: "Default",
|
||||
Text: "Source line two.",
|
||||
},
|
||||
{
|
||||
Type: "Dialogue",
|
||||
Layer: 0,
|
||||
StartTime: model.Timestamp{Seconds: 9},
|
||||
EndTime: model.Timestamp{Seconds: 12},
|
||||
Style: "Default",
|
||||
Text: "Source line three.",
|
||||
},
|
||||
},
|
||||
},
|
||||
target: model.ASSFile{
|
||||
ScriptInfo: map[string]string{"Title": "Target ASS"},
|
||||
Styles: []model.ASSStyle{
|
||||
{
|
||||
Name: "Default",
|
||||
Properties: map[string]string{
|
||||
"Format": "Name, Fontname, Fontsize, PrimaryColour",
|
||||
"Style": "Default,Arial,20,&H00FFFFFF",
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "Alternate",
|
||||
Properties: map[string]string{
|
||||
"Format": "Name, Fontname, Fontsize, PrimaryColour",
|
||||
"Style": "Alternate,Times New Roman,20,&H0000FFFF",
|
||||
},
|
||||
},
|
||||
},
|
||||
Events: []model.ASSEvent{
|
||||
{
|
||||
Type: "Dialogue",
|
||||
Layer: 0,
|
||||
StartTime: model.Timestamp{Minutes: 1, Seconds: 0},
|
||||
EndTime: model.Timestamp{Minutes: 1, Seconds: 3},
|
||||
Style: "Default",
|
||||
Text: "Target line one.",
|
||||
},
|
||||
{
|
||||
Type: "Dialogue",
|
||||
Layer: 0,
|
||||
StartTime: model.Timestamp{Minutes: 1, Seconds: 5},
|
||||
EndTime: model.Timestamp{Minutes: 1, Seconds: 8},
|
||||
Style: "Alternate",
|
||||
Text: "Target line two.",
|
||||
},
|
||||
{
|
||||
Type: "Dialogue",
|
||||
Layer: 0,
|
||||
StartTime: model.Timestamp{Minutes: 1, Seconds: 10},
|
||||
EndTime: model.Timestamp{Minutes: 1, Seconds: 13},
|
||||
Style: "Default",
|
||||
Text: "Target line three.",
|
||||
},
|
||||
},
|
||||
},
|
||||
verify: func(t *testing.T, result model.ASSFile) {
|
||||
if len(result.Events) != 3 {
|
||||
t.Errorf("Expected 3 events, got %d", len(result.Events))
|
||||
return
|
||||
}
|
||||
|
||||
// Check that source timings are applied to target events
|
||||
if result.Events[0].StartTime.Seconds != 1 || result.Events[0].EndTime.Seconds != 4 {
|
||||
t.Errorf("First event timing mismatch: got %+v", result.Events[0])
|
||||
}
|
||||
|
||||
if result.Events[1].StartTime.Seconds != 5 || result.Events[1].EndTime.Seconds != 8 {
|
||||
t.Errorf("Second event timing mismatch: got %+v", result.Events[1])
|
||||
}
|
||||
|
||||
if result.Events[2].StartTime.Seconds != 9 || result.Events[2].EndTime.Seconds != 12 {
|
||||
t.Errorf("Third event timing mismatch: got %+v", result.Events[2])
|
||||
}
|
||||
|
||||
// Check that target content and styles are preserved
|
||||
if result.Events[0].Text != "Target line one." {
|
||||
t.Errorf("Content should be preserved, got: %s", result.Events[0].Text)
|
||||
}
|
||||
|
||||
if result.Events[1].Style != "Alternate" {
|
||||
t.Errorf("Style should be preserved, got: %s", result.Events[1].Style)
|
||||
}
|
||||
|
||||
// Check that script info and style definitions are preserved
|
||||
if result.ScriptInfo["Title"] != "Target ASS" {
|
||||
t.Errorf("ScriptInfo should be preserved, got: %+v", result.ScriptInfo)
|
||||
}
|
||||
|
||||
if len(result.Styles) != 2 {
|
||||
t.Errorf("Expected 2 styles, got %d", len(result.Styles))
|
||||
}
|
||||
|
||||
if result.Styles[1].Name != "Alternate" {
|
||||
t.Errorf("Style definitions should be preserved, got: %+v", result.Styles[1])
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "More target events than source",
|
||||
source: model.ASSFile{
|
||||
Events: []model.ASSEvent{
|
||||
{
|
||||
Type: "Dialogue",
|
||||
Layer: 0,
|
||||
StartTime: model.Timestamp{Seconds: 1},
|
||||
EndTime: model.Timestamp{Seconds: 4},
|
||||
Style: "Default",
|
||||
Text: "Source line one.",
|
||||
},
|
||||
{
|
||||
Type: "Dialogue",
|
||||
Layer: 0,
|
||||
StartTime: model.Timestamp{Seconds: 5},
|
||||
EndTime: model.Timestamp{Seconds: 8},
|
||||
Style: "Default",
|
||||
Text: "Source line two.",
|
||||
},
|
||||
},
|
||||
},
|
||||
target: model.ASSFile{
|
||||
Events: []model.ASSEvent{
|
||||
{
|
||||
Type: "Dialogue",
|
||||
Layer: 0,
|
||||
StartTime: model.Timestamp{Minutes: 1, Seconds: 0},
|
||||
EndTime: model.Timestamp{Minutes: 1, Seconds: 3},
|
||||
Style: "Default",
|
||||
Text: "Target line one.",
|
||||
},
|
||||
{
|
||||
Type: "Dialogue",
|
||||
Layer: 0,
|
||||
StartTime: model.Timestamp{Minutes: 1, Seconds: 5},
|
||||
EndTime: model.Timestamp{Minutes: 1, Seconds: 8},
|
||||
Style: "Default",
|
||||
Text: "Target line two.",
|
||||
},
|
||||
{
|
||||
Type: "Dialogue",
|
||||
Layer: 0,
|
||||
StartTime: model.Timestamp{Minutes: 1, Seconds: 10},
|
||||
EndTime: model.Timestamp{Minutes: 1, Seconds: 13},
|
||||
Style: "Default",
|
||||
Text: "Target line three.",
|
||||
},
|
||||
},
|
||||
},
|
||||
verify: func(t *testing.T, result model.ASSFile) {
|
||||
if len(result.Events) != 3 {
|
||||
t.Errorf("Expected 3 events, got %d", len(result.Events))
|
||||
return
|
||||
}
|
||||
|
||||
// First event should use first source timing
|
||||
if result.Events[0].StartTime.Seconds != 1 {
|
||||
t.Errorf("First event should have first source timing, got: %+v", result.Events[0].StartTime)
|
||||
}
|
||||
|
||||
// Last event should use last source timing
|
||||
if result.Events[2].StartTime.Seconds != 5 {
|
||||
t.Errorf("Last event should have last source timing, got: %+v", result.Events[2].StartTime)
|
||||
}
|
||||
|
||||
// Verify content is preserved
|
||||
if result.Events[2].Text != "Target line three." {
|
||||
t.Errorf("Content should be preserved, got: %s", result.Events[2].Text)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "More source events than target",
|
||||
source: model.ASSFile{
|
||||
Events: []model.ASSEvent{
|
||||
{
|
||||
Type: "Dialogue",
|
||||
Layer: 0,
|
||||
StartTime: model.Timestamp{Seconds: 1},
|
||||
EndTime: model.Timestamp{Seconds: 3},
|
||||
Style: "Default",
|
||||
Text: "Source line one.",
|
||||
},
|
||||
{
|
||||
Type: "Dialogue",
|
||||
Layer: 0,
|
||||
StartTime: model.Timestamp{Seconds: 4},
|
||||
EndTime: model.Timestamp{Seconds: 6},
|
||||
Style: "Default",
|
||||
Text: "Source line two.",
|
||||
},
|
||||
{
|
||||
Type: "Dialogue",
|
||||
Layer: 0,
|
||||
StartTime: model.Timestamp{Seconds: 7},
|
||||
EndTime: model.Timestamp{Seconds: 9},
|
||||
Style: "Default",
|
||||
Text: "Source line three.",
|
||||
},
|
||||
{
|
||||
Type: "Dialogue",
|
||||
Layer: 0,
|
||||
StartTime: model.Timestamp{Seconds: 10},
|
||||
EndTime: model.Timestamp{Seconds: 12},
|
||||
Style: "Default",
|
||||
Text: "Source line four.",
|
||||
},
|
||||
},
|
||||
},
|
||||
target: model.ASSFile{
|
||||
Events: []model.ASSEvent{
|
||||
{
|
||||
Type: "Dialogue",
|
||||
Layer: 0,
|
||||
StartTime: model.Timestamp{Minutes: 1, Seconds: 0},
|
||||
EndTime: model.Timestamp{Minutes: 1, Seconds: 3},
|
||||
Style: "Default",
|
||||
Text: "Target line one.",
|
||||
},
|
||||
{
|
||||
Type: "Dialogue",
|
||||
Layer: 0,
|
||||
StartTime: model.Timestamp{Minutes: 1, Seconds: 5},
|
||||
EndTime: model.Timestamp{Minutes: 1, Seconds: 8},
|
||||
Style: "Default",
|
||||
Text: "Target line two.",
|
||||
},
|
||||
},
|
||||
},
|
||||
verify: func(t *testing.T, result model.ASSFile) {
|
||||
if len(result.Events) != 2 {
|
||||
t.Errorf("Expected 2 events, got %d", len(result.Events))
|
||||
return
|
||||
}
|
||||
|
||||
// First event should have first source timing
|
||||
if result.Events[0].StartTime.Seconds != 1 {
|
||||
t.Errorf("First event should have first source timing, got: %+v", result.Events[0].StartTime)
|
||||
}
|
||||
|
||||
// Last event should have last source timing
|
||||
if result.Events[1].StartTime.Seconds != 10 {
|
||||
t.Errorf("Last event should have last source timing, got: %+v", result.Events[1].StartTime)
|
||||
}
|
||||
|
||||
// Check that target content is preserved
|
||||
if result.Events[0].Text != "Target line one." || result.Events[1].Text != "Target line two." {
|
||||
t.Errorf("Content should be preserved, got: %+v", result.Events)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Empty target events",
|
||||
source: model.ASSFile{
|
||||
Events: []model.ASSEvent{
|
||||
{
|
||||
Type: "Dialogue",
|
||||
Layer: 0,
|
||||
StartTime: model.Timestamp{Seconds: 1},
|
||||
EndTime: model.Timestamp{Seconds: 4},
|
||||
Style: "Default",
|
||||
Text: "Source line one.",
|
||||
},
|
||||
},
|
||||
},
|
||||
target: model.ASSFile{
|
||||
ScriptInfo: map[string]string{"Title": "Empty Target"},
|
||||
Events: []model.ASSEvent{},
|
||||
},
|
||||
verify: func(t *testing.T, result model.ASSFile) {
|
||||
if len(result.Events) != 0 {
|
||||
t.Errorf("Expected 0 events, got %d", len(result.Events))
|
||||
}
|
||||
|
||||
// ScriptInfo should be preserved
|
||||
if result.ScriptInfo["Title"] != "Empty Target" {
|
||||
t.Errorf("ScriptInfo should be preserved, got: %+v", result.ScriptInfo)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Empty source events",
|
||||
source: model.ASSFile{
|
||||
Events: []model.ASSEvent{},
|
||||
},
|
||||
target: model.ASSFile{
|
||||
ScriptInfo: map[string]string{"Title": "Target with content"},
|
||||
Events: []model.ASSEvent{
|
||||
{
|
||||
Type: "Dialogue",
|
||||
Layer: 0,
|
||||
StartTime: model.Timestamp{Seconds: 10},
|
||||
EndTime: model.Timestamp{Seconds: 15},
|
||||
Style: "Default",
|
||||
Text: "Target line one.",
|
||||
},
|
||||
},
|
||||
},
|
||||
verify: func(t *testing.T, result model.ASSFile) {
|
||||
if len(result.Events) != 1 {
|
||||
t.Errorf("Expected 1 event, got %d", len(result.Events))
|
||||
return
|
||||
}
|
||||
|
||||
// Timing should be preserved since source is empty
|
||||
if result.Events[0].StartTime.Seconds != 10 || result.Events[0].EndTime.Seconds != 15 {
|
||||
t.Errorf("Timing should match target when source is empty, got: %+v", result.Events[0])
|
||||
}
|
||||
|
||||
// Content should be preserved
|
||||
if result.Events[0].Text != "Target line one." {
|
||||
t.Errorf("Content should be preserved, got: %s", result.Events[0].Text)
|
||||
}
|
||||
|
||||
// Title should be preserved
|
||||
if result.ScriptInfo["Title"] != "Target with content" {
|
||||
t.Errorf("Title should be preserved, got: %+v", result.ScriptInfo)
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
result := syncASSTimeline(tc.source, tc.target)
|
||||
|
||||
if tc.verify != nil {
|
||||
tc.verify(t, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncASSFiles(t *testing.T) {
|
||||
// Create temporary test directory
|
||||
tempDir := t.TempDir()
|
||||
|
||||
// Test case for testing the sync of ASS files
|
||||
sourceContent := `[Script Info]
|
||||
ScriptType: v4.00+
|
||||
PlayResX: 640
|
||||
PlayResY: 480
|
||||
Timer: 100.0000
|
||||
Title: Source ASS File
|
||||
|
||||
[V4+ Styles]
|
||||
Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
|
||||
Style: Default,Arial,20,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,2,2,10,10,10,1
|
||||
|
||||
[Events]
|
||||
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
|
||||
Dialogue: 0,0:00:01.00,0:00:04.00,Default,,0,0,0,,Source line one.
|
||||
Dialogue: 0,0:00:05.00,0:00:08.00,Default,,0,0,0,,Source line two.
|
||||
Dialogue: 0,0:00:09.00,0:00:12.00,Default,,0,0,0,,Source line three.
|
||||
`
|
||||
|
||||
targetContent := `[Script Info]
|
||||
ScriptType: v4.00+
|
||||
PlayResX: 640
|
||||
PlayResY: 480
|
||||
Timer: 100.0000
|
||||
Title: Target ASS File
|
||||
|
||||
[V4+ Styles]
|
||||
Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
|
||||
Style: Default,Arial,20,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,2,2,10,10,10,1
|
||||
Style: Alternate,Arial,20,&H0000FFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,2,2,10,10,10,1
|
||||
|
||||
[Events]
|
||||
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
|
||||
Dialogue: 0,0:01:00.00,0:01:03.00,Default,,0,0,0,,Target line one.
|
||||
Dialogue: 0,0:01:05.00,0:01:08.00,Alternate,,0,0,0,,Target line two.
|
||||
Dialogue: 0,0:01:10.00,0:01:13.00,Default,,0,0,0,,Target line three.
|
||||
`
|
||||
|
||||
sourceFile := filepath.Join(tempDir, "source.ass")
|
||||
targetFile := filepath.Join(tempDir, "target.ass")
|
||||
|
||||
// Write test files
|
||||
if err := os.WriteFile(sourceFile, []byte(sourceContent), 0644); err != nil {
|
||||
t.Fatalf("Failed to write source file: %v", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(targetFile, []byte(targetContent), 0644); err != nil {
|
||||
t.Fatalf("Failed to write target file: %v", err)
|
||||
}
|
||||
|
||||
// Run syncASSFiles
|
||||
err := syncASSFiles(sourceFile, targetFile)
|
||||
if err != nil {
|
||||
t.Fatalf("syncASSFiles returned error: %v", err)
|
||||
}
|
||||
|
||||
// Read the modified target file
|
||||
modifiedContent, err := os.ReadFile(targetFile)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read modified file: %v", err)
|
||||
}
|
||||
|
||||
// Verify the result
|
||||
// Should have source timings
|
||||
if !strings.Contains(string(modifiedContent), "0:00:01.00") {
|
||||
t.Errorf("Output should have source timing 0:00:01.00, got: %s", string(modifiedContent))
|
||||
}
|
||||
|
||||
// Should preserve target content and styles
|
||||
if !strings.Contains(string(modifiedContent), "Target line one.") {
|
||||
t.Errorf("Output should preserve target content, got: %s", string(modifiedContent))
|
||||
}
|
||||
|
||||
if !strings.Contains(string(modifiedContent), "Style: Alternate") {
|
||||
t.Errorf("Output should preserve target styles, got: %s", string(modifiedContent))
|
||||
}
|
||||
|
||||
// Should preserve title
|
||||
if !strings.Contains(string(modifiedContent), "Title: Target ASS File") {
|
||||
t.Errorf("Output should preserve target title, got: %s", string(modifiedContent))
|
||||
}
|
||||
}
|
64
internal/sync/lrc.go
Normal file
64
internal/sync/lrc.go
Normal file
|
@ -0,0 +1,64 @@
|
|||
package sync
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"sub-cli/internal/format/lrc"
|
||||
"sub-cli/internal/model"
|
||||
)
|
||||
|
||||
// syncLRCFiles synchronizes two LRC files
|
||||
func syncLRCFiles(sourceFile, targetFile string) error {
|
||||
source, err := lrc.Parse(sourceFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error parsing source file: %w", err)
|
||||
}
|
||||
|
||||
target, err := lrc.Parse(targetFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error parsing target file: %w", err)
|
||||
}
|
||||
|
||||
// Check if line counts match
|
||||
if len(source.Timeline) != len(target.Content) {
|
||||
fmt.Printf("Warning: Source timeline (%d entries) and target content (%d entries) have different counts. Timeline will be scaled.\n",
|
||||
len(source.Timeline), len(target.Content))
|
||||
}
|
||||
|
||||
// Apply timeline from source to target
|
||||
syncedLyrics := syncLRCTimeline(source, target)
|
||||
|
||||
// Write the synced lyrics to the target file
|
||||
return lrc.Generate(syncedLyrics, targetFile)
|
||||
}
|
||||
|
||||
// syncLRCTimeline applies the timeline from the source lyrics to the target lyrics
|
||||
func syncLRCTimeline(source, target model.Lyrics) model.Lyrics {
|
||||
result := model.Lyrics{
|
||||
Metadata: target.Metadata,
|
||||
Content: target.Content,
|
||||
}
|
||||
|
||||
// If target has no content, return empty result with metadata only
|
||||
if len(target.Content) == 0 {
|
||||
result.Timeline = []model.Timestamp{}
|
||||
return result
|
||||
}
|
||||
|
||||
// If source has no timeline, keep target as is
|
||||
if len(source.Timeline) == 0 {
|
||||
result.Timeline = target.Timeline
|
||||
return result
|
||||
}
|
||||
|
||||
// Scale the source timeline to match the target content length
|
||||
if len(source.Timeline) != len(target.Content) {
|
||||
result.Timeline = scaleTimeline(source.Timeline, len(target.Content))
|
||||
} else {
|
||||
// If lengths match, directly use source timeline
|
||||
result.Timeline = make([]model.Timestamp, len(source.Timeline))
|
||||
copy(result.Timeline, source.Timeline)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
265
internal/sync/lrc_test.go
Normal file
265
internal/sync/lrc_test.go
Normal file
|
@ -0,0 +1,265 @@
|
|||
package sync
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"sub-cli/internal/model"
|
||||
)
|
||||
|
||||
func TestSyncLRCTimeline(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
source model.Lyrics
|
||||
target model.Lyrics
|
||||
verify func(t *testing.T, result model.Lyrics)
|
||||
}{
|
||||
{
|
||||
name: "Equal content length",
|
||||
source: model.Lyrics{
|
||||
Metadata: map[string]string{
|
||||
"ti": "Source LRC",
|
||||
"ar": "Test Artist",
|
||||
},
|
||||
Timeline: []model.Timestamp{
|
||||
{Minutes: 0, Seconds: 1, Milliseconds: 0},
|
||||
{Minutes: 0, Seconds: 5, Milliseconds: 0},
|
||||
{Minutes: 0, Seconds: 9, Milliseconds: 500},
|
||||
},
|
||||
Content: []string{
|
||||
"This is line one.",
|
||||
"This is line two.",
|
||||
"This is line three.",
|
||||
},
|
||||
},
|
||||
target: model.Lyrics{
|
||||
Metadata: map[string]string{
|
||||
"ti": "Target LRC",
|
||||
"ar": "Different Artist",
|
||||
},
|
||||
Timeline: []model.Timestamp{
|
||||
{Minutes: 0, Seconds: 10, Milliseconds: 0},
|
||||
{Minutes: 0, Seconds: 20, Milliseconds: 0},
|
||||
{Minutes: 0, Seconds: 30, Milliseconds: 0},
|
||||
},
|
||||
Content: []string{
|
||||
"This is line one with different timing.",
|
||||
"This is line two with different timing.",
|
||||
"This is line three with different timing.",
|
||||
},
|
||||
},
|
||||
verify: func(t *testing.T, result model.Lyrics) {
|
||||
if len(result.Timeline) != 3 {
|
||||
t.Errorf("Expected 3 timeline entries, got %d", len(result.Timeline))
|
||||
return
|
||||
}
|
||||
|
||||
// Verify that source timings are applied
|
||||
if result.Timeline[0].Seconds != 1 || result.Timeline[0].Milliseconds != 0 {
|
||||
t.Errorf("First timeline entry should have source timing, got: %+v", result.Timeline[0])
|
||||
}
|
||||
|
||||
if result.Timeline[1].Seconds != 5 || result.Timeline[1].Milliseconds != 0 {
|
||||
t.Errorf("Second timeline entry should have source timing, got: %+v", result.Timeline[1])
|
||||
}
|
||||
|
||||
if result.Timeline[2].Seconds != 9 || result.Timeline[2].Milliseconds != 500 {
|
||||
t.Errorf("Third timeline entry should have source timing, got: %+v", result.Timeline[2])
|
||||
}
|
||||
|
||||
// Verify that target content is preserved
|
||||
if result.Content[0] != "This is line one with different timing." {
|
||||
t.Errorf("Content should be preserved, got: %s", result.Content[0])
|
||||
}
|
||||
|
||||
// Verify that target metadata is preserved
|
||||
if result.Metadata["ti"] != "Target LRC" || result.Metadata["ar"] != "Different Artist" {
|
||||
t.Errorf("Metadata should be preserved, got: %+v", result.Metadata)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "More target content than source timeline",
|
||||
source: model.Lyrics{
|
||||
Timeline: []model.Timestamp{
|
||||
{Minutes: 0, Seconds: 1, Milliseconds: 0},
|
||||
{Minutes: 0, Seconds: 5, Milliseconds: 0},
|
||||
},
|
||||
Content: []string{
|
||||
"This is line one.",
|
||||
"This is line two.",
|
||||
},
|
||||
},
|
||||
target: model.Lyrics{
|
||||
Metadata: map[string]string{
|
||||
"ti": "Target LRC",
|
||||
},
|
||||
Timeline: []model.Timestamp{
|
||||
{Minutes: 0, Seconds: 10, Milliseconds: 0},
|
||||
{Minutes: 0, Seconds: 20, Milliseconds: 0},
|
||||
{Minutes: 0, Seconds: 30, Milliseconds: 0},
|
||||
},
|
||||
Content: []string{
|
||||
"This is line one with different timing.",
|
||||
"This is line two with different timing.",
|
||||
"This is line three with different timing.",
|
||||
},
|
||||
},
|
||||
verify: func(t *testing.T, result model.Lyrics) {
|
||||
if len(result.Timeline) != 3 {
|
||||
t.Errorf("Expected 3 timeline entries, got %d", len(result.Timeline))
|
||||
return
|
||||
}
|
||||
|
||||
// Verify that source timings are scaled
|
||||
if result.Timeline[0].Seconds != 1 {
|
||||
t.Errorf("First timeline entry should have first source timing, got: %+v", result.Timeline[0])
|
||||
}
|
||||
|
||||
if result.Timeline[2].Seconds != 5 {
|
||||
t.Errorf("Last timeline entry should have last source timing, got: %+v", result.Timeline[2])
|
||||
}
|
||||
|
||||
// Verify that target content is preserved
|
||||
if result.Content[2] != "This is line three with different timing." {
|
||||
t.Errorf("Content should be preserved, got: %s", result.Content[2])
|
||||
}
|
||||
|
||||
// Verify that target metadata is preserved
|
||||
if result.Metadata["ti"] != "Target LRC" {
|
||||
t.Errorf("Metadata should be preserved, got: %+v", result.Metadata)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "More source timeline than target content",
|
||||
source: model.Lyrics{
|
||||
Timeline: []model.Timestamp{
|
||||
{Minutes: 0, Seconds: 1, Milliseconds: 0},
|
||||
{Minutes: 0, Seconds: 3, Milliseconds: 0},
|
||||
{Minutes: 0, Seconds: 5, Milliseconds: 0},
|
||||
{Minutes: 0, Seconds: 7, Milliseconds: 0},
|
||||
},
|
||||
Content: []string{
|
||||
"Source line one.",
|
||||
"Source line two.",
|
||||
"Source line three.",
|
||||
"Source line four.",
|
||||
},
|
||||
},
|
||||
target: model.Lyrics{
|
||||
Metadata: map[string]string{
|
||||
"ti": "Target LRC",
|
||||
},
|
||||
Timeline: []model.Timestamp{
|
||||
{Minutes: 0, Seconds: 10, Milliseconds: 0},
|
||||
{Minutes: 0, Seconds: 20, Milliseconds: 0},
|
||||
},
|
||||
Content: []string{
|
||||
"Target line one.",
|
||||
"Target line two.",
|
||||
},
|
||||
},
|
||||
verify: func(t *testing.T, result model.Lyrics) {
|
||||
if len(result.Timeline) != 2 {
|
||||
t.Errorf("Expected 2 timeline entries, got %d", len(result.Timeline))
|
||||
return
|
||||
}
|
||||
|
||||
// Verify that source timings are scaled
|
||||
if result.Timeline[0].Seconds != 1 {
|
||||
t.Errorf("First timeline entry should have first source timing, got: %+v", result.Timeline[0])
|
||||
}
|
||||
|
||||
if result.Timeline[1].Seconds != 7 {
|
||||
t.Errorf("Last timeline entry should have last source timing, got: %+v", result.Timeline[1])
|
||||
}
|
||||
|
||||
// Verify that target content is preserved
|
||||
if result.Content[0] != "Target line one." || result.Content[1] != "Target line two." {
|
||||
t.Errorf("Content should be preserved, got: %+v", result.Content)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Empty target content",
|
||||
source: model.Lyrics{
|
||||
Timeline: []model.Timestamp{
|
||||
{Minutes: 0, Seconds: 1, Milliseconds: 0},
|
||||
},
|
||||
Content: []string{
|
||||
"Source line one.",
|
||||
},
|
||||
},
|
||||
target: model.Lyrics{
|
||||
Metadata: map[string]string{
|
||||
"ti": "Empty Target",
|
||||
},
|
||||
Timeline: []model.Timestamp{},
|
||||
Content: []string{},
|
||||
},
|
||||
verify: func(t *testing.T, result model.Lyrics) {
|
||||
if len(result.Timeline) != 0 {
|
||||
t.Errorf("Expected 0 timeline entries, got %d", len(result.Timeline))
|
||||
}
|
||||
|
||||
if len(result.Content) != 0 {
|
||||
t.Errorf("Expected 0 content entries, got %d", len(result.Content))
|
||||
}
|
||||
|
||||
// Verify that target metadata is preserved
|
||||
if result.Metadata["ti"] != "Empty Target" {
|
||||
t.Errorf("Metadata should be preserved, got: %+v", result.Metadata)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Empty source timeline",
|
||||
source: model.Lyrics{
|
||||
Timeline: []model.Timestamp{},
|
||||
Content: []string{},
|
||||
},
|
||||
target: model.Lyrics{
|
||||
Metadata: map[string]string{
|
||||
"ti": "Target with content",
|
||||
},
|
||||
Timeline: []model.Timestamp{
|
||||
{Minutes: 0, Seconds: 10, Milliseconds: 0},
|
||||
},
|
||||
Content: []string{
|
||||
"Target line one.",
|
||||
},
|
||||
},
|
||||
verify: func(t *testing.T, result model.Lyrics) {
|
||||
if len(result.Timeline) != 1 {
|
||||
t.Errorf("Expected 1 timeline entry, got %d", len(result.Timeline))
|
||||
return
|
||||
}
|
||||
|
||||
// Verify that target timing is preserved when source is empty
|
||||
if result.Timeline[0].Seconds != 10 {
|
||||
t.Errorf("Timeline should match target when source is empty, got: %+v", result.Timeline[0])
|
||||
}
|
||||
|
||||
// Verify that target content is preserved
|
||||
if result.Content[0] != "Target line one." {
|
||||
t.Errorf("Content should be preserved, got: %s", result.Content[0])
|
||||
}
|
||||
|
||||
// Verify that target metadata is preserved
|
||||
if result.Metadata["ti"] != "Target with content" {
|
||||
t.Errorf("Metadata should be preserved, got: %+v", result.Metadata)
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
result := syncLRCTimeline(tc.source, tc.target)
|
||||
|
||||
if tc.verify != nil {
|
||||
tc.verify(t, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
100
internal/sync/srt.go
Normal file
100
internal/sync/srt.go
Normal file
|
@ -0,0 +1,100 @@
|
|||
package sync
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"sub-cli/internal/format/srt"
|
||||
"sub-cli/internal/model"
|
||||
)
|
||||
|
||||
// syncSRTFiles synchronizes two SRT files
|
||||
func syncSRTFiles(sourceFile, targetFile string) error {
|
||||
sourceEntries, err := srt.Parse(sourceFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error parsing source SRT file: %w", err)
|
||||
}
|
||||
|
||||
targetEntries, err := srt.Parse(targetFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error parsing target SRT file: %w", err)
|
||||
}
|
||||
|
||||
// Check if entry counts match
|
||||
if len(sourceEntries) != len(targetEntries) {
|
||||
fmt.Printf("Warning: Source (%d entries) and target (%d entries) have different entry counts. Timeline will be adjusted.\n",
|
||||
len(sourceEntries), len(targetEntries))
|
||||
}
|
||||
|
||||
// Sync the timelines
|
||||
syncedEntries := syncSRTTimeline(sourceEntries, targetEntries)
|
||||
|
||||
// Write the synced entries to the target file
|
||||
return srt.Generate(syncedEntries, targetFile)
|
||||
}
|
||||
|
||||
// syncSRTTimeline applies the timing from source SRT entries to target SRT entries
|
||||
func syncSRTTimeline(sourceEntries, targetEntries []model.SRTEntry) []model.SRTEntry {
|
||||
result := make([]model.SRTEntry, len(targetEntries))
|
||||
|
||||
// Copy target entries
|
||||
copy(result, targetEntries)
|
||||
|
||||
// If source is empty, just return the target entries as is
|
||||
if len(sourceEntries) == 0 {
|
||||
// Ensure proper sequence numbering
|
||||
for i := range result {
|
||||
result[i].Number = i + 1
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// If source and target have the same number of entries, directly apply timings
|
||||
if len(sourceEntries) == len(targetEntries) {
|
||||
for i := range result {
|
||||
result[i].StartTime = sourceEntries[i].StartTime
|
||||
result[i].EndTime = sourceEntries[i].EndTime
|
||||
}
|
||||
} else {
|
||||
// If entry counts differ, scale the timing
|
||||
for i := range result {
|
||||
// Calculate scaled index
|
||||
sourceIdx := 0
|
||||
if len(sourceEntries) > 1 {
|
||||
sourceIdx = i * (len(sourceEntries) - 1) / (len(targetEntries) - 1)
|
||||
}
|
||||
|
||||
// Ensure the index is within bounds
|
||||
if sourceIdx >= len(sourceEntries) {
|
||||
sourceIdx = len(sourceEntries) - 1
|
||||
}
|
||||
|
||||
// Apply the scaled timing
|
||||
result[i].StartTime = sourceEntries[sourceIdx].StartTime
|
||||
|
||||
// Calculate end time: if not the last entry, use duration from source
|
||||
if i < len(result)-1 {
|
||||
// If next source entry exists, calculate duration
|
||||
var duration model.Timestamp
|
||||
if sourceIdx+1 < len(sourceEntries) {
|
||||
duration = calculateDuration(sourceEntries[sourceIdx].StartTime, sourceEntries[sourceIdx+1].StartTime)
|
||||
} else {
|
||||
// If no next source entry, use the source's end time (usually a few seconds after start)
|
||||
duration = calculateDuration(sourceEntries[sourceIdx].StartTime, sourceEntries[sourceIdx].EndTime)
|
||||
}
|
||||
|
||||
// Apply duration to next start time
|
||||
result[i].EndTime = addDuration(result[i].StartTime, duration)
|
||||
} else {
|
||||
// For the last entry, add a fixed duration (e.g., 3 seconds)
|
||||
result[i].EndTime = sourceEntries[sourceIdx].EndTime
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure proper sequence numbering
|
||||
for i := range result {
|
||||
result[i].Number = i + 1
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
274
internal/sync/srt_test.go
Normal file
274
internal/sync/srt_test.go
Normal file
|
@ -0,0 +1,274 @@
|
|||
package sync
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"sub-cli/internal/model"
|
||||
)
|
||||
|
||||
func TestSyncSRTTimeline(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
sourceEntries []model.SRTEntry
|
||||
targetEntries []model.SRTEntry
|
||||
verify func(t *testing.T, result []model.SRTEntry)
|
||||
}{
|
||||
{
|
||||
name: "Equal entry counts",
|
||||
sourceEntries: []model.SRTEntry{
|
||||
{
|
||||
Number: 1,
|
||||
StartTime: model.Timestamp{Seconds: 1},
|
||||
EndTime: model.Timestamp{Seconds: 4},
|
||||
Content: "Source line one.",
|
||||
},
|
||||
{
|
||||
Number: 2,
|
||||
StartTime: model.Timestamp{Seconds: 5},
|
||||
EndTime: model.Timestamp{Seconds: 8},
|
||||
Content: "Source line two.",
|
||||
},
|
||||
{
|
||||
Number: 3,
|
||||
StartTime: model.Timestamp{Seconds: 9},
|
||||
EndTime: model.Timestamp{Seconds: 12},
|
||||
Content: "Source line three.",
|
||||
},
|
||||
},
|
||||
targetEntries: []model.SRTEntry{
|
||||
{
|
||||
Number: 1,
|
||||
StartTime: model.Timestamp{Minutes: 1, Seconds: 0},
|
||||
EndTime: model.Timestamp{Minutes: 1, Seconds: 3},
|
||||
Content: "Target line one.",
|
||||
},
|
||||
{
|
||||
Number: 2,
|
||||
StartTime: model.Timestamp{Minutes: 1, Seconds: 5},
|
||||
EndTime: model.Timestamp{Minutes: 1, Seconds: 8},
|
||||
Content: "Target line two.",
|
||||
},
|
||||
{
|
||||
Number: 3,
|
||||
StartTime: model.Timestamp{Minutes: 1, Seconds: 10},
|
||||
EndTime: model.Timestamp{Minutes: 1, Seconds: 13},
|
||||
Content: "Target line three.",
|
||||
},
|
||||
},
|
||||
verify: func(t *testing.T, result []model.SRTEntry) {
|
||||
if len(result) != 3 {
|
||||
t.Errorf("Expected 3 entries, got %d", len(result))
|
||||
return
|
||||
}
|
||||
|
||||
// Check that source timings are applied to target entries
|
||||
if result[0].StartTime.Seconds != 1 || result[0].EndTime.Seconds != 4 {
|
||||
t.Errorf("First entry timing mismatch: got %+v", result[0])
|
||||
}
|
||||
|
||||
if result[1].StartTime.Seconds != 5 || result[1].EndTime.Seconds != 8 {
|
||||
t.Errorf("Second entry timing mismatch: got %+v", result[1])
|
||||
}
|
||||
|
||||
if result[2].StartTime.Seconds != 9 || result[2].EndTime.Seconds != 12 {
|
||||
t.Errorf("Third entry timing mismatch: got %+v", result[2])
|
||||
}
|
||||
|
||||
// Check that target content is preserved
|
||||
if result[0].Content != "Target line one." {
|
||||
t.Errorf("Content should be preserved, got: %s", result[0].Content)
|
||||
}
|
||||
|
||||
// Check that numbering is correct
|
||||
if result[0].Number != 1 || result[1].Number != 2 || result[2].Number != 3 {
|
||||
t.Errorf("Entry numbers should be sequential: %+v", result)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "More target entries than source",
|
||||
sourceEntries: []model.SRTEntry{
|
||||
{
|
||||
Number: 1,
|
||||
StartTime: model.Timestamp{Seconds: 1},
|
||||
EndTime: model.Timestamp{Seconds: 4},
|
||||
Content: "Source line one.",
|
||||
},
|
||||
{
|
||||
Number: 2,
|
||||
StartTime: model.Timestamp{Seconds: 5},
|
||||
EndTime: model.Timestamp{Seconds: 8},
|
||||
Content: "Source line two.",
|
||||
},
|
||||
},
|
||||
targetEntries: []model.SRTEntry{
|
||||
{
|
||||
Number: 1,
|
||||
StartTime: model.Timestamp{Minutes: 1, Seconds: 0},
|
||||
EndTime: model.Timestamp{Minutes: 1, Seconds: 3},
|
||||
Content: "Target line one.",
|
||||
},
|
||||
{
|
||||
Number: 2,
|
||||
StartTime: model.Timestamp{Minutes: 1, Seconds: 5},
|
||||
EndTime: model.Timestamp{Minutes: 1, Seconds: 8},
|
||||
Content: "Target line two.",
|
||||
},
|
||||
{
|
||||
Number: 3,
|
||||
StartTime: model.Timestamp{Minutes: 1, Seconds: 10},
|
||||
EndTime: model.Timestamp{Minutes: 1, Seconds: 13},
|
||||
Content: "Target line three.",
|
||||
},
|
||||
},
|
||||
verify: func(t *testing.T, result []model.SRTEntry) {
|
||||
if len(result) != 3 {
|
||||
t.Errorf("Expected 3 entries, got %d", len(result))
|
||||
return
|
||||
}
|
||||
|
||||
// Check that source timings are scaled appropriately
|
||||
if result[0].StartTime.Seconds != 1 {
|
||||
t.Errorf("First entry should have first source start time, got: %+v", result[0].StartTime)
|
||||
}
|
||||
|
||||
if result[2].StartTime.Seconds != 5 {
|
||||
t.Errorf("Last entry should have last source start time, got: %+v", result[2].StartTime)
|
||||
}
|
||||
|
||||
// Check that content is preserved
|
||||
if result[2].Content != "Target line three." {
|
||||
t.Errorf("Content should be preserved, got: %s", result[2].Content)
|
||||
}
|
||||
|
||||
// Check that numbering is correct
|
||||
if result[0].Number != 1 || result[1].Number != 2 || result[2].Number != 3 {
|
||||
t.Errorf("Entry numbers should be sequential: %+v", result)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "More source entries than target",
|
||||
sourceEntries: []model.SRTEntry{
|
||||
{
|
||||
Number: 1,
|
||||
StartTime: model.Timestamp{Seconds: 1},
|
||||
EndTime: model.Timestamp{Seconds: 3},
|
||||
Content: "Source line one.",
|
||||
},
|
||||
{
|
||||
Number: 2,
|
||||
StartTime: model.Timestamp{Seconds: 4},
|
||||
EndTime: model.Timestamp{Seconds: 6},
|
||||
Content: "Source line two.",
|
||||
},
|
||||
{
|
||||
Number: 3,
|
||||
StartTime: model.Timestamp{Seconds: 7},
|
||||
EndTime: model.Timestamp{Seconds: 9},
|
||||
Content: "Source line three.",
|
||||
},
|
||||
{
|
||||
Number: 4,
|
||||
StartTime: model.Timestamp{Seconds: 10},
|
||||
EndTime: model.Timestamp{Seconds: 12},
|
||||
Content: "Source line four.",
|
||||
},
|
||||
},
|
||||
targetEntries: []model.SRTEntry{
|
||||
{
|
||||
Number: 1,
|
||||
StartTime: model.Timestamp{Minutes: 1, Seconds: 0},
|
||||
EndTime: model.Timestamp{Minutes: 1, Seconds: 3},
|
||||
Content: "Target line one.",
|
||||
},
|
||||
{
|
||||
Number: 2,
|
||||
StartTime: model.Timestamp{Minutes: 1, Seconds: 5},
|
||||
EndTime: model.Timestamp{Minutes: 1, Seconds: 8},
|
||||
Content: "Target line two.",
|
||||
},
|
||||
},
|
||||
verify: func(t *testing.T, result []model.SRTEntry) {
|
||||
if len(result) != 2 {
|
||||
t.Errorf("Expected 2 entries, got %d", len(result))
|
||||
return
|
||||
}
|
||||
|
||||
// Check that source timings are scaled appropriately
|
||||
if result[0].StartTime.Seconds != 1 {
|
||||
t.Errorf("First entry should have first source timing, got: %+v", result[0].StartTime)
|
||||
}
|
||||
|
||||
if result[1].StartTime.Seconds != 10 {
|
||||
t.Errorf("Last entry should have last source timing, got: %+v", result[1].StartTime)
|
||||
}
|
||||
|
||||
// Check that content is preserved
|
||||
if result[0].Content != "Target line one." || result[1].Content != "Target line two." {
|
||||
t.Errorf("Content should be preserved, got: %+v", result)
|
||||
}
|
||||
|
||||
// Check that numbering is correct
|
||||
if result[0].Number != 1 || result[1].Number != 2 {
|
||||
t.Errorf("Entry numbers should be sequential: %+v", result)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Empty target entries",
|
||||
sourceEntries: []model.SRTEntry{
|
||||
{
|
||||
Number: 1,
|
||||
StartTime: model.Timestamp{Seconds: 1},
|
||||
EndTime: model.Timestamp{Seconds: 4},
|
||||
Content: "Source line one.",
|
||||
},
|
||||
},
|
||||
targetEntries: []model.SRTEntry{},
|
||||
verify: func(t *testing.T, result []model.SRTEntry) {
|
||||
if len(result) != 0 {
|
||||
t.Errorf("Expected 0 entries, got %d", len(result))
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Empty source entries",
|
||||
sourceEntries: []model.SRTEntry{},
|
||||
targetEntries: []model.SRTEntry{
|
||||
{
|
||||
Number: 1,
|
||||
StartTime: model.Timestamp{Seconds: 1},
|
||||
EndTime: model.Timestamp{Seconds: 4},
|
||||
Content: "Target line one.",
|
||||
},
|
||||
},
|
||||
verify: func(t *testing.T, result []model.SRTEntry) {
|
||||
if len(result) != 1 {
|
||||
t.Errorf("Expected 1 entry, got %d", len(result))
|
||||
return
|
||||
}
|
||||
|
||||
// Check that numbering is correct even with empty source
|
||||
if result[0].Number != 1 {
|
||||
t.Errorf("Entry number should be 1, got: %d", result[0].Number)
|
||||
}
|
||||
|
||||
// Content should be preserved
|
||||
if result[0].Content != "Target line one." {
|
||||
t.Errorf("Content should be preserved, got: %s", result[0].Content)
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
result := syncSRTTimeline(tc.sourceEntries, tc.targetEntries)
|
||||
|
||||
if tc.verify != nil {
|
||||
tc.verify(t, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
|
@ -1,15 +1,9 @@
|
|||
package sync
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"sub-cli/internal/format/ass"
|
||||
"sub-cli/internal/format/lrc"
|
||||
"sub-cli/internal/format/srt"
|
||||
"sub-cli/internal/format/vtt"
|
||||
"sub-cli/internal/model"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// SyncLyrics synchronizes the timeline of a source lyrics file with a target lyrics file
|
||||
|
@ -30,438 +24,3 @@ func SyncLyrics(sourceFile, targetFile string) error {
|
|||
return fmt.Errorf("sync only supports files of the same format (lrc-to-lrc, srt-to-srt, vtt-to-vtt, or ass-to-ass)")
|
||||
}
|
||||
}
|
||||
|
||||
// syncLRCFiles synchronizes two LRC files
|
||||
func syncLRCFiles(sourceFile, targetFile string) error {
|
||||
source, err := lrc.Parse(sourceFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error parsing source file: %w", err)
|
||||
}
|
||||
|
||||
target, err := lrc.Parse(targetFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error parsing target file: %w", err)
|
||||
}
|
||||
|
||||
// Check if line counts match
|
||||
if len(source.Timeline) != len(target.Content) {
|
||||
fmt.Printf("Warning: Source timeline (%d entries) and target content (%d entries) have different counts. Timeline will be scaled.\n",
|
||||
len(source.Timeline), len(target.Content))
|
||||
}
|
||||
|
||||
// Apply timeline from source to target
|
||||
syncedLyrics := syncLRCTimeline(source, target)
|
||||
|
||||
// Write the synced lyrics to the target file
|
||||
return lrc.Generate(syncedLyrics, targetFile)
|
||||
}
|
||||
|
||||
// syncSRTFiles synchronizes two SRT files
|
||||
func syncSRTFiles(sourceFile, targetFile string) error {
|
||||
sourceEntries, err := srt.Parse(sourceFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error parsing source SRT file: %w", err)
|
||||
}
|
||||
|
||||
targetEntries, err := srt.Parse(targetFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error parsing target SRT file: %w", err)
|
||||
}
|
||||
|
||||
// Check if entry counts match
|
||||
if len(sourceEntries) != len(targetEntries) {
|
||||
fmt.Printf("Warning: Source (%d entries) and target (%d entries) have different entry counts. Timeline will be adjusted.\n",
|
||||
len(sourceEntries), len(targetEntries))
|
||||
}
|
||||
|
||||
// Sync the timelines
|
||||
syncedEntries := syncSRTTimeline(sourceEntries, targetEntries)
|
||||
|
||||
// Write the synced entries to the target file
|
||||
return srt.Generate(syncedEntries, targetFile)
|
||||
}
|
||||
|
||||
// syncVTTFiles synchronizes two VTT files
|
||||
func syncVTTFiles(sourceFile, targetFile string) error {
|
||||
sourceSubtitle, err := vtt.Parse(sourceFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error parsing source VTT file: %w", err)
|
||||
}
|
||||
|
||||
targetSubtitle, err := vtt.Parse(targetFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error parsing target VTT file: %w", err)
|
||||
}
|
||||
|
||||
// Check if entry counts match
|
||||
if len(sourceSubtitle.Entries) != len(targetSubtitle.Entries) {
|
||||
fmt.Printf("Warning: Source (%d entries) and target (%d entries) have different entry counts. Timeline will be adjusted.\n",
|
||||
len(sourceSubtitle.Entries), len(targetSubtitle.Entries))
|
||||
}
|
||||
|
||||
// Sync the timelines
|
||||
syncedSubtitle := syncVTTTimeline(sourceSubtitle, targetSubtitle)
|
||||
|
||||
// Write the synced subtitle to the target file
|
||||
return vtt.Generate(syncedSubtitle, targetFile)
|
||||
}
|
||||
|
||||
// syncASSFiles synchronizes two ASS files
|
||||
func syncASSFiles(sourceFile, targetFile string) error {
|
||||
sourceSubtitle, err := ass.Parse(sourceFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error parsing source ASS file: %w", err)
|
||||
}
|
||||
|
||||
targetSubtitle, err := ass.Parse(targetFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error parsing target ASS file: %w", err)
|
||||
}
|
||||
|
||||
// Check if entry counts match
|
||||
if len(sourceSubtitle.Events) != len(targetSubtitle.Events) {
|
||||
fmt.Printf("Warning: Source (%d events) and target (%d events) have different event counts. Timeline will be adjusted.\n",
|
||||
len(sourceSubtitle.Events), len(targetSubtitle.Events))
|
||||
}
|
||||
|
||||
// Sync the timelines
|
||||
syncedSubtitle := syncASSTimeline(sourceSubtitle, targetSubtitle)
|
||||
|
||||
// Write the synced subtitle to the target file
|
||||
return ass.Generate(syncedSubtitle, targetFile)
|
||||
}
|
||||
|
||||
// syncLRCTimeline applies the timeline from the source lyrics to the target lyrics
|
||||
func syncLRCTimeline(source, target model.Lyrics) model.Lyrics {
|
||||
result := model.Lyrics{
|
||||
Metadata: target.Metadata,
|
||||
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) {
|
||||
copy(result.Timeline, source.Timeline)
|
||||
} else if len(source.Timeline) > 0 {
|
||||
// If lengths don't match, scale timeline using our improved scaleTimeline function
|
||||
result.Timeline = scaleTimeline(source.Timeline, len(target.Content))
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// syncSRTTimeline applies the timing from source SRT entries to target SRT entries
|
||||
func syncSRTTimeline(sourceEntries, targetEntries []model.SRTEntry) []model.SRTEntry {
|
||||
result := make([]model.SRTEntry, len(targetEntries))
|
||||
|
||||
// Copy target entries
|
||||
copy(result, targetEntries)
|
||||
|
||||
// If source is empty, just return the target entries as is
|
||||
if len(sourceEntries) == 0 {
|
||||
// Ensure proper sequence numbering
|
||||
for i := range result {
|
||||
result[i].Number = i + 1
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// If source and target have the same number of entries, directly apply timings
|
||||
if len(sourceEntries) == len(targetEntries) {
|
||||
for i := range result {
|
||||
result[i].StartTime = sourceEntries[i].StartTime
|
||||
result[i].EndTime = sourceEntries[i].EndTime
|
||||
}
|
||||
} else {
|
||||
// If entry counts differ, scale the timing
|
||||
for i := range result {
|
||||
// Calculate scaled index
|
||||
sourceIdx := 0
|
||||
if len(sourceEntries) > 1 {
|
||||
sourceIdx = i * (len(sourceEntries) - 1) / (len(targetEntries) - 1)
|
||||
}
|
||||
|
||||
// Ensure the index is within bounds
|
||||
if sourceIdx >= len(sourceEntries) {
|
||||
sourceIdx = len(sourceEntries) - 1
|
||||
}
|
||||
|
||||
// Apply the scaled timing
|
||||
result[i].StartTime = sourceEntries[sourceIdx].StartTime
|
||||
|
||||
// Calculate end time: if not the last entry, use duration from source
|
||||
if i < len(result)-1 {
|
||||
// If next source entry exists, calculate duration
|
||||
var duration model.Timestamp
|
||||
if sourceIdx+1 < len(sourceEntries) {
|
||||
duration = calculateDuration(sourceEntries[sourceIdx].StartTime, sourceEntries[sourceIdx+1].StartTime)
|
||||
} else {
|
||||
// If no next source entry, use the source's end time (usually a few seconds after start)
|
||||
duration = calculateDuration(sourceEntries[sourceIdx].StartTime, sourceEntries[sourceIdx].EndTime)
|
||||
}
|
||||
|
||||
// Apply duration to next start time
|
||||
result[i].EndTime = addDuration(result[i].StartTime, duration)
|
||||
} else {
|
||||
// For the last entry, add a fixed duration (e.g., 3 seconds)
|
||||
result[i].EndTime = sourceEntries[sourceIdx].EndTime
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure proper sequence numbering
|
||||
for i := range result {
|
||||
result[i].Number = i + 1
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// syncVTTTimeline applies the timing from source VTT subtitle to target VTT subtitle
|
||||
func syncVTTTimeline(source, target model.Subtitle) model.Subtitle {
|
||||
result := model.NewSubtitle()
|
||||
result.Format = "vtt"
|
||||
result.Title = target.Title
|
||||
result.Metadata = target.Metadata
|
||||
result.Styles = target.Styles
|
||||
|
||||
// Create entries array with same length as target
|
||||
result.Entries = make([]model.SubtitleEntry, len(target.Entries))
|
||||
|
||||
// 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 {
|
||||
result.Entries[i].StartTime = source.Entries[i].StartTime
|
||||
result.Entries[i].EndTime = source.Entries[i].EndTime
|
||||
}
|
||||
} else {
|
||||
// If entry counts differ, scale the timing similar to SRT sync
|
||||
for i := range result.Entries {
|
||||
// Calculate scaled index
|
||||
sourceIdx := 0
|
||||
if len(source.Entries) > 1 {
|
||||
sourceIdx = i * (len(source.Entries) - 1) / (len(target.Entries) - 1)
|
||||
}
|
||||
|
||||
// Ensure the index is within bounds
|
||||
if sourceIdx >= len(source.Entries) {
|
||||
sourceIdx = len(source.Entries) - 1
|
||||
}
|
||||
|
||||
// Apply the scaled timing
|
||||
result.Entries[i].StartTime = source.Entries[sourceIdx].StartTime
|
||||
|
||||
// Calculate end time: if not the last entry, use duration from source
|
||||
if i < len(result.Entries)-1 {
|
||||
// If next source entry exists, calculate duration
|
||||
var duration model.Timestamp
|
||||
if sourceIdx+1 < len(source.Entries) {
|
||||
duration = calculateDuration(source.Entries[sourceIdx].StartTime, source.Entries[sourceIdx+1].StartTime)
|
||||
} else {
|
||||
duration = calculateDuration(source.Entries[sourceIdx].StartTime, source.Entries[sourceIdx].EndTime)
|
||||
}
|
||||
result.Entries[i].EndTime = addDuration(result.Entries[i].StartTime, duration)
|
||||
} else {
|
||||
// For the last entry, use the end time from source
|
||||
result.Entries[i].EndTime = source.Entries[sourceIdx].EndTime
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure proper index numbering
|
||||
for i := range result.Entries {
|
||||
result.Entries[i].Index = i + 1
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// syncASSTimeline applies the timing from source ASS subtitle to target ASS subtitle
|
||||
func syncASSTimeline(source, target model.ASSFile) model.ASSFile {
|
||||
result := model.ASSFile{
|
||||
ScriptInfo: target.ScriptInfo,
|
||||
Styles: target.Styles,
|
||||
Events: make([]model.ASSEvent, len(target.Events)),
|
||||
}
|
||||
|
||||
// Copy target events to preserve content
|
||||
copy(result.Events, target.Events)
|
||||
|
||||
// If there are no events in either source or target, return as is
|
||||
if len(source.Events) == 0 || len(target.Events) == 0 {
|
||||
return result
|
||||
}
|
||||
|
||||
// Create a timeline of source start and end times
|
||||
sourceStartTimes := make([]model.Timestamp, len(source.Events))
|
||||
sourceEndTimes := make([]model.Timestamp, len(source.Events))
|
||||
|
||||
for i, event := range source.Events {
|
||||
sourceStartTimes[i] = event.StartTime
|
||||
sourceEndTimes[i] = event.EndTime
|
||||
}
|
||||
|
||||
// Scale the timeline if source and target have different number of events
|
||||
var scaledStartTimes, scaledEndTimes []model.Timestamp
|
||||
|
||||
if len(source.Events) != len(target.Events) {
|
||||
scaledStartTimes = scaleTimeline(sourceStartTimes, len(target.Events))
|
||||
scaledEndTimes = scaleTimeline(sourceEndTimes, len(target.Events))
|
||||
} else {
|
||||
scaledStartTimes = sourceStartTimes
|
||||
scaledEndTimes = sourceEndTimes
|
||||
}
|
||||
|
||||
// Apply scaled timeline to target events
|
||||
for i := range result.Events {
|
||||
result.Events[i].StartTime = scaledStartTimes[i]
|
||||
result.Events[i].EndTime = scaledEndTimes[i]
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// scaleTimeline scales a timeline to match a different number of entries
|
||||
func scaleTimeline(timeline []model.Timestamp, targetCount int) []model.Timestamp {
|
||||
if targetCount <= 0 || len(timeline) == 0 {
|
||||
return []model.Timestamp{}
|
||||
}
|
||||
|
||||
result := make([]model.Timestamp, targetCount)
|
||||
|
||||
if targetCount == 1 {
|
||||
result[0] = timeline[0]
|
||||
return result
|
||||
}
|
||||
|
||||
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++ {
|
||||
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
|
||||
}
|
||||
|
||||
// calculateDuration calculates the time difference between two timestamps
|
||||
func calculateDuration(start, end model.Timestamp) model.Timestamp {
|
||||
startMillis := start.Hours*3600000 + start.Minutes*60000 + start.Seconds*1000 + start.Milliseconds
|
||||
endMillis := end.Hours*3600000 + end.Minutes*60000 + end.Seconds*1000 + end.Milliseconds
|
||||
|
||||
durationMillis := endMillis - startMillis
|
||||
if durationMillis < 0 {
|
||||
// Return zero duration if end is before start
|
||||
return model.Timestamp{
|
||||
Hours: 0,
|
||||
Minutes: 0,
|
||||
Seconds: 0,
|
||||
Milliseconds: 0,
|
||||
}
|
||||
}
|
||||
|
||||
hours := durationMillis / 3600000
|
||||
durationMillis %= 3600000
|
||||
minutes := durationMillis / 60000
|
||||
durationMillis %= 60000
|
||||
seconds := durationMillis / 1000
|
||||
milliseconds := durationMillis % 1000
|
||||
|
||||
return model.Timestamp{
|
||||
Hours: hours,
|
||||
Minutes: minutes,
|
||||
Seconds: seconds,
|
||||
Milliseconds: milliseconds,
|
||||
}
|
||||
}
|
||||
|
||||
// addDuration adds a duration to a timestamp
|
||||
func addDuration(start, duration model.Timestamp) model.Timestamp {
|
||||
startMillis := start.Hours*3600000 + start.Minutes*60000 + start.Seconds*1000 + start.Milliseconds
|
||||
durationMillis := duration.Hours*3600000 + duration.Minutes*60000 + duration.Seconds*1000 + duration.Milliseconds
|
||||
|
||||
totalMillis := startMillis + durationMillis
|
||||
|
||||
hours := totalMillis / 3600000
|
||||
totalMillis %= 3600000
|
||||
minutes := totalMillis / 60000
|
||||
totalMillis %= 60000
|
||||
seconds := totalMillis / 1000
|
||||
milliseconds := totalMillis % 1000
|
||||
|
||||
return model.Timestamp{
|
||||
Hours: hours,
|
||||
Minutes: minutes,
|
||||
Seconds: seconds,
|
||||
Milliseconds: milliseconds,
|
||||
}
|
||||
}
|
||||
|
|
File diff suppressed because it is too large
Load diff
136
internal/sync/utils.go
Normal file
136
internal/sync/utils.go
Normal file
|
@ -0,0 +1,136 @@
|
|||
package sync
|
||||
|
||||
import (
|
||||
"sub-cli/internal/model"
|
||||
)
|
||||
|
||||
// scaleTimeline scales a timeline to match a different number of entries
|
||||
func scaleTimeline(timeline []model.Timestamp, targetCount int) []model.Timestamp {
|
||||
if targetCount <= 0 || len(timeline) == 0 {
|
||||
return []model.Timestamp{}
|
||||
}
|
||||
|
||||
result := make([]model.Timestamp, targetCount)
|
||||
|
||||
if targetCount == 1 {
|
||||
result[0] = timeline[0]
|
||||
return result
|
||||
}
|
||||
|
||||
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++ {
|
||||
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
|
||||
}
|
||||
|
||||
// calculateDuration calculates the time difference between two timestamps
|
||||
func calculateDuration(start, end model.Timestamp) model.Timestamp {
|
||||
startMillis := start.Hours*3600000 + start.Minutes*60000 + start.Seconds*1000 + start.Milliseconds
|
||||
endMillis := end.Hours*3600000 + end.Minutes*60000 + end.Seconds*1000 + end.Milliseconds
|
||||
|
||||
durationMillis := endMillis - startMillis
|
||||
if durationMillis < 0 {
|
||||
// Return zero duration if end is before start
|
||||
return model.Timestamp{
|
||||
Hours: 0,
|
||||
Minutes: 0,
|
||||
Seconds: 0,
|
||||
Milliseconds: 0,
|
||||
}
|
||||
}
|
||||
|
||||
hours := durationMillis / 3600000
|
||||
durationMillis %= 3600000
|
||||
minutes := durationMillis / 60000
|
||||
durationMillis %= 60000
|
||||
seconds := durationMillis / 1000
|
||||
milliseconds := durationMillis % 1000
|
||||
|
||||
return model.Timestamp{
|
||||
Hours: hours,
|
||||
Minutes: minutes,
|
||||
Seconds: seconds,
|
||||
Milliseconds: milliseconds,
|
||||
}
|
||||
}
|
||||
|
||||
// addDuration adds a duration to a timestamp
|
||||
func addDuration(start, duration model.Timestamp) model.Timestamp {
|
||||
startMillis := start.Hours*3600000 + start.Minutes*60000 + start.Seconds*1000 + start.Milliseconds
|
||||
durationMillis := duration.Hours*3600000 + duration.Minutes*60000 + duration.Seconds*1000 + duration.Milliseconds
|
||||
|
||||
totalMillis := startMillis + durationMillis
|
||||
|
||||
hours := totalMillis / 3600000
|
||||
totalMillis %= 3600000
|
||||
minutes := totalMillis / 60000
|
||||
totalMillis %= 60000
|
||||
seconds := totalMillis / 1000
|
||||
milliseconds := totalMillis % 1000
|
||||
|
||||
return model.Timestamp{
|
||||
Hours: hours,
|
||||
Minutes: minutes,
|
||||
Seconds: seconds,
|
||||
Milliseconds: milliseconds,
|
||||
}
|
||||
}
|
236
internal/sync/utils_test.go
Normal file
236
internal/sync/utils_test.go
Normal file
|
@ -0,0 +1,236 @@
|
|||
package sync
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"sub-cli/internal/model"
|
||||
)
|
||||
|
||||
func TestCalculateDuration(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
start model.Timestamp
|
||||
end model.Timestamp
|
||||
expected model.Timestamp
|
||||
}{
|
||||
{
|
||||
name: "Simple duration",
|
||||
start: model.Timestamp{Minutes: 1, Seconds: 30},
|
||||
end: model.Timestamp{Minutes: 3, Seconds: 10},
|
||||
expected: model.Timestamp{Minutes: 1, Seconds: 40},
|
||||
},
|
||||
{
|
||||
name: "Duration with hours",
|
||||
start: model.Timestamp{Hours: 1, Minutes: 20},
|
||||
end: model.Timestamp{Hours: 2, Minutes: 10},
|
||||
expected: model.Timestamp{Hours: 0, Minutes: 50},
|
||||
},
|
||||
{
|
||||
name: "Duration with milliseconds",
|
||||
start: model.Timestamp{Seconds: 10, Milliseconds: 500},
|
||||
end: model.Timestamp{Seconds: 20, Milliseconds: 800},
|
||||
expected: model.Timestamp{Seconds: 10, Milliseconds: 300},
|
||||
},
|
||||
{
|
||||
name: "End before start (should return zero)",
|
||||
start: model.Timestamp{Minutes: 5},
|
||||
end: model.Timestamp{Minutes: 3},
|
||||
expected: model.Timestamp{Hours: 0, Minutes: 0, Seconds: 0, Milliseconds: 0},
|
||||
},
|
||||
{
|
||||
name: "Complex duration with carry",
|
||||
start: model.Timestamp{Hours: 1, Minutes: 45, Seconds: 30, Milliseconds: 500},
|
||||
end: model.Timestamp{Hours: 3, Minutes: 20, Seconds: 15, Milliseconds: 800},
|
||||
expected: model.Timestamp{Hours: 1, Minutes: 34, Seconds: 45, Milliseconds: 300},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
result := calculateDuration(tc.start, tc.end)
|
||||
|
||||
if result.Hours != tc.expected.Hours ||
|
||||
result.Minutes != tc.expected.Minutes ||
|
||||
result.Seconds != tc.expected.Seconds ||
|
||||
result.Milliseconds != tc.expected.Milliseconds {
|
||||
t.Errorf("Expected %+v, got %+v", tc.expected, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddDuration(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
start model.Timestamp
|
||||
duration model.Timestamp
|
||||
expected model.Timestamp
|
||||
}{
|
||||
{
|
||||
name: "Simple addition",
|
||||
start: model.Timestamp{Minutes: 1, Seconds: 30},
|
||||
duration: model.Timestamp{Minutes: 2, Seconds: 15},
|
||||
expected: model.Timestamp{Minutes: 3, Seconds: 45},
|
||||
},
|
||||
{
|
||||
name: "Addition with carry",
|
||||
start: model.Timestamp{Minutes: 58, Seconds: 45},
|
||||
duration: model.Timestamp{Minutes: 4, Seconds: 30},
|
||||
expected: model.Timestamp{Hours: 1, Minutes: 3, Seconds: 15},
|
||||
},
|
||||
{
|
||||
name: "Addition with milliseconds",
|
||||
start: model.Timestamp{Seconds: 10, Milliseconds: 500},
|
||||
duration: model.Timestamp{Seconds: 5, Milliseconds: 800},
|
||||
expected: model.Timestamp{Seconds: 16, Milliseconds: 300},
|
||||
},
|
||||
{
|
||||
name: "Zero duration",
|
||||
start: model.Timestamp{Minutes: 5, Seconds: 30},
|
||||
duration: model.Timestamp{},
|
||||
expected: model.Timestamp{Minutes: 5, Seconds: 30},
|
||||
},
|
||||
{
|
||||
name: "Complex addition with multiple carries",
|
||||
start: model.Timestamp{Hours: 1, Minutes: 59, Seconds: 59, Milliseconds: 900},
|
||||
duration: model.Timestamp{Hours: 2, Minutes: 10, Seconds: 15, Milliseconds: 200},
|
||||
expected: model.Timestamp{Hours: 4, Minutes: 10, Seconds: 15, Milliseconds: 100},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
result := addDuration(tc.start, tc.duration)
|
||||
|
||||
if result.Hours != tc.expected.Hours ||
|
||||
result.Minutes != tc.expected.Minutes ||
|
||||
result.Seconds != tc.expected.Seconds ||
|
||||
result.Milliseconds != tc.expected.Milliseconds {
|
||||
t.Errorf("Expected %+v, got %+v", tc.expected, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestScaleTimeline(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
timeline []model.Timestamp
|
||||
targetCount int
|
||||
expected []model.Timestamp
|
||||
}{
|
||||
{
|
||||
name: "Same length timeline",
|
||||
timeline: []model.Timestamp{
|
||||
{Seconds: 1},
|
||||
{Seconds: 2},
|
||||
{Seconds: 3},
|
||||
},
|
||||
targetCount: 3,
|
||||
expected: []model.Timestamp{
|
||||
{Seconds: 1},
|
||||
{Seconds: 2},
|
||||
{Seconds: 3},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Empty timeline",
|
||||
timeline: []model.Timestamp{},
|
||||
targetCount: 3,
|
||||
expected: []model.Timestamp{},
|
||||
},
|
||||
{
|
||||
name: "Zero target count",
|
||||
timeline: []model.Timestamp{
|
||||
{Seconds: 1},
|
||||
{Seconds: 2},
|
||||
},
|
||||
targetCount: 0,
|
||||
expected: []model.Timestamp{},
|
||||
},
|
||||
{
|
||||
name: "Single item timeline",
|
||||
timeline: []model.Timestamp{
|
||||
{Seconds: 5},
|
||||
},
|
||||
targetCount: 3,
|
||||
expected: []model.Timestamp{
|
||||
{Seconds: 5},
|
||||
{Seconds: 5},
|
||||
{Seconds: 5},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Scale up timeline",
|
||||
timeline: []model.Timestamp{
|
||||
{Seconds: 0},
|
||||
{Seconds: 10},
|
||||
},
|
||||
targetCount: 5,
|
||||
expected: []model.Timestamp{
|
||||
{Seconds: 0},
|
||||
{Seconds: 2, Milliseconds: 500},
|
||||
{Seconds: 5},
|
||||
{Seconds: 7, Milliseconds: 500},
|
||||
{Seconds: 10},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Scale down timeline",
|
||||
timeline: []model.Timestamp{
|
||||
{Seconds: 0},
|
||||
{Seconds: 5},
|
||||
{Seconds: 10},
|
||||
{Seconds: 15},
|
||||
{Seconds: 20},
|
||||
},
|
||||
targetCount: 3,
|
||||
expected: []model.Timestamp{
|
||||
{Seconds: 0},
|
||||
{Seconds: 10},
|
||||
{Seconds: 20},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Target count 1",
|
||||
timeline: []model.Timestamp{
|
||||
{Seconds: 5},
|
||||
{Seconds: 10},
|
||||
{Seconds: 15},
|
||||
},
|
||||
targetCount: 1,
|
||||
expected: []model.Timestamp{
|
||||
{Seconds: 5},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
result := scaleTimeline(tc.timeline, tc.targetCount)
|
||||
|
||||
if len(result) != len(tc.expected) {
|
||||
t.Errorf("Expected result length %d, got %d", len(tc.expected), len(result))
|
||||
return
|
||||
}
|
||||
|
||||
for i := range result {
|
||||
// Allow 1ms difference due to floating point calculations
|
||||
if abs(result[i].Hours - tc.expected[i].Hours) > 0 ||
|
||||
abs(result[i].Minutes - tc.expected[i].Minutes) > 0 ||
|
||||
abs(result[i].Seconds - tc.expected[i].Seconds) > 0 ||
|
||||
abs(result[i].Milliseconds - tc.expected[i].Milliseconds) > 1 {
|
||||
t.Errorf("At index %d: expected %+v, got %+v", i, tc.expected[i], result[i])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function for timestamp comparison
|
||||
func abs(x int) int {
|
||||
if x < 0 {
|
||||
return -x
|
||||
}
|
||||
return x
|
||||
}
|
104
internal/sync/vtt.go
Normal file
104
internal/sync/vtt.go
Normal file
|
@ -0,0 +1,104 @@
|
|||
package sync
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"sub-cli/internal/format/vtt"
|
||||
"sub-cli/internal/model"
|
||||
)
|
||||
|
||||
// syncVTTFiles synchronizes two VTT files
|
||||
func syncVTTFiles(sourceFile, targetFile string) error {
|
||||
sourceSubtitle, err := vtt.Parse(sourceFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error parsing source VTT file: %w", err)
|
||||
}
|
||||
|
||||
targetSubtitle, err := vtt.Parse(targetFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error parsing target VTT file: %w", err)
|
||||
}
|
||||
|
||||
// Check if entry counts match
|
||||
if len(sourceSubtitle.Entries) != len(targetSubtitle.Entries) {
|
||||
fmt.Printf("Warning: Source (%d entries) and target (%d entries) have different entry counts. Timeline will be adjusted.\n",
|
||||
len(sourceSubtitle.Entries), len(targetSubtitle.Entries))
|
||||
}
|
||||
|
||||
// Sync the timelines
|
||||
syncedSubtitle := syncVTTTimeline(sourceSubtitle, targetSubtitle)
|
||||
|
||||
// Write the synced subtitle to the target file
|
||||
return vtt.Generate(syncedSubtitle, targetFile)
|
||||
}
|
||||
|
||||
// syncVTTTimeline applies the timing from source VTT subtitle to target VTT subtitle
|
||||
func syncVTTTimeline(source, target model.Subtitle) model.Subtitle {
|
||||
result := model.NewSubtitle()
|
||||
result.Format = "vtt"
|
||||
result.Title = target.Title
|
||||
result.Metadata = target.Metadata
|
||||
result.Styles = target.Styles
|
||||
|
||||
// Create entries array with same length as target
|
||||
result.Entries = make([]model.SubtitleEntry, len(target.Entries))
|
||||
|
||||
// Copy target entries
|
||||
copy(result.Entries, target.Entries)
|
||||
|
||||
// If source subtitle is empty or target subtitle is empty, return copied target
|
||||
if len(source.Entries) == 0 || len(target.Entries) == 0 {
|
||||
// Ensure proper index numbering
|
||||
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 {
|
||||
result.Entries[i].StartTime = source.Entries[i].StartTime
|
||||
result.Entries[i].EndTime = source.Entries[i].EndTime
|
||||
}
|
||||
} else {
|
||||
// If entry counts differ, scale the timing similar to SRT sync
|
||||
for i := range result.Entries {
|
||||
// Calculate scaled index
|
||||
sourceIdx := 0
|
||||
if len(source.Entries) > 1 {
|
||||
sourceIdx = i * (len(source.Entries) - 1) / (len(target.Entries) - 1)
|
||||
}
|
||||
|
||||
// Ensure the index is within bounds
|
||||
if sourceIdx >= len(source.Entries) {
|
||||
sourceIdx = len(source.Entries) - 1
|
||||
}
|
||||
|
||||
// Apply the scaled timing
|
||||
result.Entries[i].StartTime = source.Entries[sourceIdx].StartTime
|
||||
|
||||
// Calculate end time: if not the last entry, use duration from source
|
||||
if i < len(result.Entries)-1 {
|
||||
// If next source entry exists, calculate duration
|
||||
var duration model.Timestamp
|
||||
if sourceIdx+1 < len(source.Entries) {
|
||||
duration = calculateDuration(source.Entries[sourceIdx].StartTime, source.Entries[sourceIdx+1].StartTime)
|
||||
} else {
|
||||
duration = calculateDuration(source.Entries[sourceIdx].StartTime, source.Entries[sourceIdx].EndTime)
|
||||
}
|
||||
result.Entries[i].EndTime = addDuration(result.Entries[i].StartTime, duration)
|
||||
} else {
|
||||
// For the last entry, use the end time from source
|
||||
result.Entries[i].EndTime = source.Entries[sourceIdx].EndTime
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure proper index numbering
|
||||
for i := range result.Entries {
|
||||
result.Entries[i].Index = i + 1
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
342
internal/sync/vtt_test.go
Normal file
342
internal/sync/vtt_test.go
Normal file
|
@ -0,0 +1,342 @@
|
|||
package sync
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"sub-cli/internal/model"
|
||||
)
|
||||
|
||||
func TestSyncVTTTimeline(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
source model.Subtitle
|
||||
target model.Subtitle
|
||||
verify func(t *testing.T, result model.Subtitle)
|
||||
}{
|
||||
{
|
||||
name: "Equal entry counts",
|
||||
source: model.Subtitle{
|
||||
Format: "vtt",
|
||||
Title: "Source VTT",
|
||||
Entries: []model.SubtitleEntry{
|
||||
{
|
||||
Index: 1,
|
||||
StartTime: model.Timestamp{Seconds: 1},
|
||||
EndTime: model.Timestamp{Seconds: 4},
|
||||
Text: "Source line one.",
|
||||
},
|
||||
{
|
||||
Index: 2,
|
||||
StartTime: model.Timestamp{Seconds: 5},
|
||||
EndTime: model.Timestamp{Seconds: 8},
|
||||
Text: "Source line two.",
|
||||
},
|
||||
{
|
||||
Index: 3,
|
||||
StartTime: model.Timestamp{Seconds: 9},
|
||||
EndTime: model.Timestamp{Seconds: 12},
|
||||
Text: "Source line three.",
|
||||
},
|
||||
},
|
||||
},
|
||||
target: model.Subtitle{
|
||||
Format: "vtt",
|
||||
Title: "Target VTT",
|
||||
Styles: map[string]string{
|
||||
"style1": ".style1 { color: red; }",
|
||||
},
|
||||
Entries: []model.SubtitleEntry{
|
||||
{
|
||||
Index: 1,
|
||||
StartTime: model.Timestamp{Minutes: 1, Seconds: 0},
|
||||
EndTime: model.Timestamp{Minutes: 1, Seconds: 3},
|
||||
Text: "Target line one.",
|
||||
Styles: map[string]string{
|
||||
"align": "start",
|
||||
"position": "10%",
|
||||
},
|
||||
},
|
||||
{
|
||||
Index: 2,
|
||||
StartTime: model.Timestamp{Minutes: 1, Seconds: 5},
|
||||
EndTime: model.Timestamp{Minutes: 1, Seconds: 8},
|
||||
Text: "Target line two.",
|
||||
Styles: map[string]string{
|
||||
"align": "middle",
|
||||
},
|
||||
},
|
||||
{
|
||||
Index: 3,
|
||||
StartTime: model.Timestamp{Minutes: 1, Seconds: 10},
|
||||
EndTime: model.Timestamp{Minutes: 1, Seconds: 13},
|
||||
Text: "Target line three.",
|
||||
},
|
||||
},
|
||||
},
|
||||
verify: func(t *testing.T, result model.Subtitle) {
|
||||
if len(result.Entries) != 3 {
|
||||
t.Errorf("Expected 3 entries, got %d", len(result.Entries))
|
||||
return
|
||||
}
|
||||
|
||||
// Check that source timings are applied to target entries
|
||||
if result.Entries[0].StartTime.Seconds != 1 || result.Entries[0].EndTime.Seconds != 4 {
|
||||
t.Errorf("First entry timing mismatch: got %+v", result.Entries[0])
|
||||
}
|
||||
|
||||
if result.Entries[1].StartTime.Seconds != 5 || result.Entries[1].EndTime.Seconds != 8 {
|
||||
t.Errorf("Second entry timing mismatch: got %+v", result.Entries[1])
|
||||
}
|
||||
|
||||
if result.Entries[2].StartTime.Seconds != 9 || result.Entries[2].EndTime.Seconds != 12 {
|
||||
t.Errorf("Third entry timing mismatch: got %+v", result.Entries[2])
|
||||
}
|
||||
|
||||
// Check that target content is preserved
|
||||
if result.Entries[0].Text != "Target line one." {
|
||||
t.Errorf("Content should be preserved, got: %s", result.Entries[0].Text)
|
||||
}
|
||||
|
||||
// Check that styles are preserved
|
||||
if result.Entries[0].Styles["align"] != "start" || result.Entries[0].Styles["position"] != "10%" {
|
||||
t.Errorf("Styles should be preserved, got: %+v", result.Entries[0].Styles)
|
||||
}
|
||||
|
||||
// Check that global styles are preserved
|
||||
if result.Styles["style1"] != ".style1 { color: red; }" {
|
||||
t.Errorf("Global styles should be preserved, got: %+v", result.Styles)
|
||||
}
|
||||
|
||||
// Check that numbering is correct
|
||||
if result.Entries[0].Index != 1 || result.Entries[1].Index != 2 || result.Entries[2].Index != 3 {
|
||||
t.Errorf("Entry indices should be sequential: %+v", result.Entries)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "More target entries than source",
|
||||
source: model.Subtitle{
|
||||
Format: "vtt",
|
||||
Entries: []model.SubtitleEntry{
|
||||
{
|
||||
Index: 1,
|
||||
StartTime: model.Timestamp{Seconds: 1},
|
||||
EndTime: model.Timestamp{Seconds: 4},
|
||||
Text: "Source line one.",
|
||||
},
|
||||
{
|
||||
Index: 2,
|
||||
StartTime: model.Timestamp{Seconds: 5},
|
||||
EndTime: model.Timestamp{Seconds: 8},
|
||||
Text: "Source line two.",
|
||||
},
|
||||
},
|
||||
},
|
||||
target: model.Subtitle{
|
||||
Format: "vtt",
|
||||
Title: "Target VTT",
|
||||
Entries: []model.SubtitleEntry{
|
||||
{
|
||||
Index: 1,
|
||||
StartTime: model.Timestamp{Minutes: 1, Seconds: 0},
|
||||
EndTime: model.Timestamp{Minutes: 1, Seconds: 3},
|
||||
Text: "Target line one.",
|
||||
},
|
||||
{
|
||||
Index: 2,
|
||||
StartTime: model.Timestamp{Minutes: 1, Seconds: 5},
|
||||
EndTime: model.Timestamp{Minutes: 1, Seconds: 8},
|
||||
Text: "Target line two.",
|
||||
},
|
||||
{
|
||||
Index: 3,
|
||||
StartTime: model.Timestamp{Minutes: 1, Seconds: 10},
|
||||
EndTime: model.Timestamp{Minutes: 1, Seconds: 13},
|
||||
Text: "Target line three.",
|
||||
},
|
||||
},
|
||||
},
|
||||
verify: func(t *testing.T, result model.Subtitle) {
|
||||
if len(result.Entries) != 3 {
|
||||
t.Errorf("Expected 3 entries, got %d", len(result.Entries))
|
||||
return
|
||||
}
|
||||
|
||||
// First entry should use first source timing
|
||||
if result.Entries[0].StartTime.Seconds != 1 {
|
||||
t.Errorf("First entry should have first source timing, got: %+v", result.Entries[0].StartTime)
|
||||
}
|
||||
|
||||
// Last entry should use last source timing
|
||||
if result.Entries[2].StartTime.Seconds != 5 {
|
||||
t.Errorf("Last entry should have last source timing, got: %+v", result.Entries[2].StartTime)
|
||||
}
|
||||
|
||||
// Check that target content is preserved
|
||||
if result.Entries[2].Text != "Target line three." {
|
||||
t.Errorf("Content should be preserved, got: %s", result.Entries[2].Text)
|
||||
}
|
||||
|
||||
// Check that title is preserved
|
||||
if result.Title != "Target VTT" {
|
||||
t.Errorf("Title should be preserved, got: %s", result.Title)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "More source entries than target",
|
||||
source: model.Subtitle{
|
||||
Format: "vtt",
|
||||
Entries: []model.SubtitleEntry{
|
||||
{
|
||||
Index: 1,
|
||||
StartTime: model.Timestamp{Seconds: 1},
|
||||
EndTime: model.Timestamp{Seconds: 3},
|
||||
Text: "Source line one.",
|
||||
},
|
||||
{
|
||||
Index: 2,
|
||||
StartTime: model.Timestamp{Seconds: 4},
|
||||
EndTime: model.Timestamp{Seconds: 6},
|
||||
Text: "Source line two.",
|
||||
},
|
||||
{
|
||||
Index: 3,
|
||||
StartTime: model.Timestamp{Seconds: 7},
|
||||
EndTime: model.Timestamp{Seconds: 9},
|
||||
Text: "Source line three.",
|
||||
},
|
||||
{
|
||||
Index: 4,
|
||||
StartTime: model.Timestamp{Seconds: 10},
|
||||
EndTime: model.Timestamp{Seconds: 12},
|
||||
Text: "Source line four.",
|
||||
},
|
||||
},
|
||||
},
|
||||
target: model.Subtitle{
|
||||
Format: "vtt",
|
||||
Metadata: map[string]string{
|
||||
"Region": "metadata region",
|
||||
},
|
||||
Entries: []model.SubtitleEntry{
|
||||
{
|
||||
Index: 1,
|
||||
StartTime: model.Timestamp{Minutes: 1, Seconds: 0},
|
||||
EndTime: model.Timestamp{Minutes: 1, Seconds: 3},
|
||||
Text: "Target line one.",
|
||||
},
|
||||
{
|
||||
Index: 2,
|
||||
StartTime: model.Timestamp{Minutes: 1, Seconds: 5},
|
||||
EndTime: model.Timestamp{Minutes: 1, Seconds: 8},
|
||||
Text: "Target line two.",
|
||||
},
|
||||
},
|
||||
},
|
||||
verify: func(t *testing.T, result model.Subtitle) {
|
||||
if len(result.Entries) != 2 {
|
||||
t.Errorf("Expected 2 entries, got %d", len(result.Entries))
|
||||
return
|
||||
}
|
||||
|
||||
// First entry should have first source timing
|
||||
if result.Entries[0].StartTime.Seconds != 1 {
|
||||
t.Errorf("First entry should have first source timing, got: %+v", result.Entries[0].StartTime)
|
||||
}
|
||||
|
||||
// Last entry should have last source timing
|
||||
if result.Entries[1].StartTime.Seconds != 10 {
|
||||
t.Errorf("Last entry should have last source timing, got: %+v", result.Entries[1].StartTime)
|
||||
}
|
||||
|
||||
// Check that metadata is preserved
|
||||
if result.Metadata["Region"] != "metadata region" {
|
||||
t.Errorf("Metadata should be preserved, got: %+v", result.Metadata)
|
||||
}
|
||||
|
||||
// Check that target content is preserved
|
||||
if result.Entries[0].Text != "Target line one." || result.Entries[1].Text != "Target line two." {
|
||||
t.Errorf("Content should be preserved, got: %+v", result.Entries)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Empty target entries",
|
||||
source: model.Subtitle{
|
||||
Format: "vtt",
|
||||
Entries: []model.SubtitleEntry{
|
||||
{
|
||||
Index: 1,
|
||||
StartTime: model.Timestamp{Seconds: 1},
|
||||
EndTime: model.Timestamp{Seconds: 4},
|
||||
Text: "Source line one.",
|
||||
},
|
||||
},
|
||||
},
|
||||
target: model.Subtitle{
|
||||
Format: "vtt",
|
||||
Title: "Empty Target",
|
||||
},
|
||||
verify: func(t *testing.T, result model.Subtitle) {
|
||||
if len(result.Entries) != 0 {
|
||||
t.Errorf("Expected 0 entries, got %d", len(result.Entries))
|
||||
}
|
||||
|
||||
// Title should be preserved
|
||||
if result.Title != "Empty Target" {
|
||||
t.Errorf("Title should be preserved, got: %s", result.Title)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Empty source entries",
|
||||
source: model.Subtitle{
|
||||
Format: "vtt",
|
||||
},
|
||||
target: model.Subtitle{
|
||||
Format: "vtt",
|
||||
Title: "Target with content",
|
||||
Entries: []model.SubtitleEntry{
|
||||
{
|
||||
Index: 1,
|
||||
StartTime: model.Timestamp{Seconds: 10},
|
||||
EndTime: model.Timestamp{Seconds: 15},
|
||||
Text: "Target line one.",
|
||||
},
|
||||
},
|
||||
},
|
||||
verify: func(t *testing.T, result model.Subtitle) {
|
||||
if len(result.Entries) != 1 {
|
||||
t.Errorf("Expected 1 entry, got %d", len(result.Entries))
|
||||
return
|
||||
}
|
||||
|
||||
// Timing should be preserved since source is empty
|
||||
if result.Entries[0].StartTime.Seconds != 10 || result.Entries[0].EndTime.Seconds != 15 {
|
||||
t.Errorf("Timing should match target when source is empty, got: %+v", result.Entries[0])
|
||||
}
|
||||
|
||||
// Content should be preserved
|
||||
if result.Entries[0].Text != "Target line one." {
|
||||
t.Errorf("Content should be preserved, got: %s", result.Entries[0].Text)
|
||||
}
|
||||
|
||||
// Title should be preserved
|
||||
if result.Title != "Target with content" {
|
||||
t.Errorf("Title should be preserved, got: %s", result.Title)
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
result := syncVTTTimeline(tc.source, tc.target)
|
||||
|
||||
if tc.verify != nil {
|
||||
tc.verify(t, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue