mirror of
https://github.com/yattee/yattee.git
synced 2026-02-19 17:29:45 +00:00
514 lines
16 KiB
Swift
514 lines
16 KiB
Swift
//
|
||
// ChapterParserTests.swift
|
||
// YatteeTests
|
||
//
|
||
// Unit tests for ChapterParser.
|
||
//
|
||
|
||
import Foundation
|
||
import Testing
|
||
|
||
@testable import Yattee
|
||
|
||
@Suite("ChapterParser")
|
||
struct ChapterParserTests {
|
||
|
||
// MARK: - Timestamp Format Tests
|
||
|
||
@Test("parses M:SS format")
|
||
func parseMSSFormat() {
|
||
let description = """
|
||
0:00 Intro
|
||
5:30 Topic One
|
||
"""
|
||
let chapters = ChapterParser.parse(description: description, videoDuration: 600)
|
||
|
||
#expect(chapters.count == 2)
|
||
#expect(chapters[0].startTime == 0)
|
||
#expect(chapters[0].title == "Intro")
|
||
#expect(chapters[1].startTime == 330) // 5:30 = 330 seconds
|
||
#expect(chapters[1].title == "Topic One")
|
||
}
|
||
|
||
@Test("parses MM:SS format")
|
||
func parseMMSSFormat() {
|
||
let description = """
|
||
00:00 Intro
|
||
05:30 Topic One
|
||
12:45 Topic Two
|
||
"""
|
||
let chapters = ChapterParser.parse(description: description, videoDuration: 900)
|
||
|
||
#expect(chapters.count == 3)
|
||
#expect(chapters[0].startTime == 0)
|
||
#expect(chapters[1].startTime == 330)
|
||
#expect(chapters[2].startTime == 765) // 12:45 = 765 seconds
|
||
}
|
||
|
||
@Test("parses H:MM:SS format")
|
||
func parseHMMSSFormat() {
|
||
let description = """
|
||
0:00:00 Intro
|
||
1:23:45 Deep Dive
|
||
"""
|
||
let chapters = ChapterParser.parse(description: description, videoDuration: 7200)
|
||
|
||
#expect(chapters.count == 2)
|
||
#expect(chapters[0].startTime == 0)
|
||
#expect(chapters[1].startTime == 5025) // 1*3600 + 23*60 + 45 = 5025
|
||
}
|
||
|
||
@Test("parses HH:MM:SS format")
|
||
func parseHHMMSSFormat() {
|
||
let description = """
|
||
00:00:00 Intro
|
||
01:23:45 Deep Dive
|
||
"""
|
||
let chapters = ChapterParser.parse(description: description, videoDuration: 7200)
|
||
|
||
#expect(chapters.count == 2)
|
||
#expect(chapters[0].startTime == 0)
|
||
#expect(chapters[1].startTime == 5025)
|
||
}
|
||
|
||
// MARK: - Prefix Stripping Tests
|
||
|
||
@Test("strips prefix characters")
|
||
func stripPrefixCharacters() {
|
||
let description = """
|
||
▶ 0:00 Intro
|
||
► 1:00 First Topic
|
||
• 2:00 Second Topic
|
||
- 3:00 Third Topic
|
||
* 4:00 Fourth Topic
|
||
→ 5:00 Fifth Topic
|
||
➤ 6:00 Sixth Topic
|
||
"""
|
||
let chapters = ChapterParser.parse(description: description, videoDuration: 600)
|
||
|
||
#expect(chapters.count == 7)
|
||
#expect(chapters[0].title == "Intro")
|
||
#expect(chapters[1].title == "First Topic")
|
||
#expect(chapters[2].title == "Second Topic")
|
||
#expect(chapters[3].title == "Third Topic")
|
||
#expect(chapters[4].title == "Fourth Topic")
|
||
#expect(chapters[5].title == "Fifth Topic")
|
||
#expect(chapters[6].title == "Sixth Topic")
|
||
}
|
||
|
||
// MARK: - Separator Stripping Tests
|
||
|
||
@Test("strips separators between timestamp and title")
|
||
func stripSeparators() {
|
||
let description = """
|
||
0:00 - Intro
|
||
1:00 | First Topic
|
||
2:00 : Second Topic
|
||
3:00 – Third Topic
|
||
4:00 — Fourth Topic
|
||
"""
|
||
let chapters = ChapterParser.parse(description: description, videoDuration: 600)
|
||
|
||
#expect(chapters.count == 5)
|
||
#expect(chapters[0].title == "Intro")
|
||
#expect(chapters[1].title == "First Topic")
|
||
#expect(chapters[2].title == "Second Topic")
|
||
#expect(chapters[3].title == "Third Topic")
|
||
#expect(chapters[4].title == "Fourth Topic")
|
||
}
|
||
|
||
// MARK: - Timestamp Position Tests
|
||
|
||
@Test("requires timestamp at line start")
|
||
func timestampMustBeFirst() {
|
||
let description = """
|
||
0:00 Intro
|
||
Check out 5:30 moment
|
||
Intro - 1:00
|
||
10:00 Outro
|
||
"""
|
||
let chapters = ChapterParser.parse(description: description, videoDuration: 900)
|
||
|
||
// "Check out 5:30 moment" doesn't start with a timestamp, so it breaks the block.
|
||
// First block has only 1 chapter (0:00 Intro), which is less than minimum 2.
|
||
// Result: empty array
|
||
#expect(chapters.isEmpty)
|
||
}
|
||
|
||
// MARK: - Bracket Tests
|
||
|
||
@Test("ignores bracketed timestamps")
|
||
func ignoreBracketedTimestamps() {
|
||
let description = """
|
||
0:00 Intro
|
||
[1:00] Should Be Ignored
|
||
(2:00) Also Ignored
|
||
3:00 Valid Chapter
|
||
"""
|
||
let chapters = ChapterParser.parse(description: description, videoDuration: 600)
|
||
|
||
#expect(chapters.count == 2)
|
||
#expect(chapters[0].startTime == 0)
|
||
#expect(chapters[1].startTime == 180) // 3:00
|
||
}
|
||
|
||
// MARK: - Empty Title Tests
|
||
|
||
@Test("skips chapters without titles")
|
||
func skipEmptyTitles() {
|
||
let description = """
|
||
0:00 Intro
|
||
1:00
|
||
2:00
|
||
3:00 Valid Chapter
|
||
"""
|
||
let chapters = ChapterParser.parse(description: description, videoDuration: 600)
|
||
|
||
#expect(chapters.count == 2)
|
||
#expect(chapters[0].title == "Intro")
|
||
#expect(chapters[1].title == "Valid Chapter")
|
||
}
|
||
|
||
// MARK: - Minimum Chapters Tests
|
||
|
||
@Test("requires minimum 2 chapters")
|
||
func minimumChaptersRequired() {
|
||
let description = """
|
||
0:00 Only One Chapter
|
||
"""
|
||
let chapters = ChapterParser.parse(description: description, videoDuration: 600)
|
||
|
||
#expect(chapters.isEmpty)
|
||
}
|
||
|
||
@Test("returns chapters when exactly 2 exist")
|
||
func exactlyTwoChapters() {
|
||
let description = """
|
||
0:00 First
|
||
5:00 Second
|
||
"""
|
||
let chapters = ChapterParser.parse(description: description, videoDuration: 600)
|
||
|
||
#expect(chapters.count == 2)
|
||
}
|
||
|
||
// MARK: - Block Detection Tests
|
||
|
||
@Test("detects first contiguous block only")
|
||
func firstContiguousBlockOnly() {
|
||
let description = """
|
||
Some intro text
|
||
|
||
0:00 Intro
|
||
1:00 Topic A
|
||
2:00 Topic B
|
||
|
||
Check my other video:
|
||
0:00 Other Video Intro
|
||
1:00 Other Topic
|
||
"""
|
||
let chapters = ChapterParser.parse(description: description, videoDuration: 600)
|
||
|
||
#expect(chapters.count == 3)
|
||
#expect(chapters[0].title == "Intro")
|
||
#expect(chapters[1].title == "Topic A")
|
||
#expect(chapters[2].title == "Topic B")
|
||
}
|
||
|
||
@Test("empty lines don't break block")
|
||
func emptyLinesDontBreakBlock() {
|
||
let description = """
|
||
0:00 Intro
|
||
|
||
1:00 Topic A
|
||
|
||
|
||
2:00 Topic B
|
||
"""
|
||
let chapters = ChapterParser.parse(description: description, videoDuration: 600)
|
||
|
||
#expect(chapters.count == 3)
|
||
#expect(chapters[0].title == "Intro")
|
||
#expect(chapters[1].title == "Topic A")
|
||
#expect(chapters[2].title == "Topic B")
|
||
}
|
||
|
||
// MARK: - Sorting Tests
|
||
|
||
@Test("auto-sorts chronologically")
|
||
func autoSortChronologically() {
|
||
let description = """
|
||
5:00 Middle
|
||
0:00 Start
|
||
10:00 End
|
||
"""
|
||
let chapters = ChapterParser.parse(description: description, videoDuration: 900)
|
||
|
||
#expect(chapters.count == 3)
|
||
#expect(chapters[0].startTime == 0)
|
||
#expect(chapters[0].title == "Start")
|
||
#expect(chapters[1].startTime == 300)
|
||
#expect(chapters[1].title == "Middle")
|
||
#expect(chapters[2].startTime == 600)
|
||
#expect(chapters[2].title == "End")
|
||
}
|
||
|
||
// MARK: - Synthetic Intro Tests
|
||
|
||
@Test("inserts synthetic intro at 0:00")
|
||
func insertSyntheticIntro() {
|
||
let description = """
|
||
1:00 First Real Chapter
|
||
5:00 Second Chapter
|
||
"""
|
||
let chapters = ChapterParser.parse(description: description, videoDuration: 600, introTitle: "Intro")
|
||
|
||
#expect(chapters.count == 3)
|
||
#expect(chapters[0].startTime == 0)
|
||
#expect(chapters[0].title == "Intro")
|
||
#expect(chapters[1].startTime == 60)
|
||
#expect(chapters[1].title == "First Real Chapter")
|
||
}
|
||
|
||
@Test("does not insert intro if first chapter starts at 0:00")
|
||
func noSyntheticIntroWhenStartsAtZero() {
|
||
let description = """
|
||
0:00 Actual Intro
|
||
5:00 Next Chapter
|
||
"""
|
||
let chapters = ChapterParser.parse(description: description, videoDuration: 600, introTitle: "Intro")
|
||
|
||
#expect(chapters.count == 2)
|
||
#expect(chapters[0].title == "Actual Intro")
|
||
}
|
||
|
||
@Test("uses custom intro title")
|
||
func customIntroTitle() {
|
||
let description = """
|
||
1:00 First Chapter
|
||
5:00 Second Chapter
|
||
"""
|
||
let chapters = ChapterParser.parse(description: description, videoDuration: 600, introTitle: "Einleitung")
|
||
|
||
#expect(chapters[0].title == "Einleitung")
|
||
}
|
||
|
||
// MARK: - Duplicate Timestamp Tests
|
||
|
||
@Test("merges duplicate timestamps")
|
||
func mergeDuplicateTimestamps() {
|
||
let description = """
|
||
0:00 Intro
|
||
5:00 Topic A
|
||
5:00 Topic B
|
||
10:00 Outro
|
||
"""
|
||
let chapters = ChapterParser.parse(description: description, videoDuration: 900)
|
||
|
||
#expect(chapters.count == 3)
|
||
#expect(chapters[1].startTime == 300)
|
||
#expect(chapters[1].title == "Topic A / Topic B")
|
||
}
|
||
|
||
// MARK: - Duration Validation Tests
|
||
|
||
@Test("discards timestamps beyond duration")
|
||
func discardBeyondDuration() {
|
||
let description = """
|
||
0:00 Intro
|
||
5:00 Middle
|
||
20:00 Beyond Duration
|
||
"""
|
||
let chapters = ChapterParser.parse(description: description, videoDuration: 600) // 10 minutes
|
||
|
||
#expect(chapters.count == 2)
|
||
#expect(chapters[0].title == "Intro")
|
||
#expect(chapters[1].title == "Middle")
|
||
}
|
||
|
||
@Test("returns empty for nil description")
|
||
func nilDescription() {
|
||
let chapters = ChapterParser.parse(description: nil, videoDuration: 600)
|
||
|
||
#expect(chapters.isEmpty)
|
||
}
|
||
|
||
@Test("returns empty for empty description")
|
||
func emptyDescription() {
|
||
let chapters = ChapterParser.parse(description: "", videoDuration: 600)
|
||
|
||
#expect(chapters.isEmpty)
|
||
}
|
||
|
||
@Test("returns empty for zero duration")
|
||
func zeroDuration() {
|
||
let description = """
|
||
0:00 Intro
|
||
5:00 Topic
|
||
"""
|
||
let chapters = ChapterParser.parse(description: description, videoDuration: 0)
|
||
|
||
#expect(chapters.isEmpty)
|
||
}
|
||
|
||
@Test("returns empty for negative duration")
|
||
func negativeDuration() {
|
||
let description = """
|
||
0:00 Intro
|
||
5:00 Topic
|
||
"""
|
||
let chapters = ChapterParser.parse(description: description, videoDuration: -100)
|
||
|
||
#expect(chapters.isEmpty)
|
||
}
|
||
|
||
// MARK: - End Time Tests
|
||
|
||
@Test("calculates correct end times")
|
||
func correctEndTimes() {
|
||
let description = """
|
||
0:00 Intro
|
||
1:00 Middle
|
||
5:00 Outro
|
||
"""
|
||
let chapters = ChapterParser.parse(description: description, videoDuration: 600)
|
||
|
||
#expect(chapters[0].endTime == 60) // Ends when Middle starts
|
||
#expect(chapters[1].endTime == 300) // Ends when Outro starts
|
||
#expect(chapters[2].endTime == 600) // Ends at video duration
|
||
}
|
||
|
||
// MARK: - Real World Examples
|
||
|
||
@Test("parses real world MKBHD-style description")
|
||
func realWorldMKBHDStyle() {
|
||
let description = """
|
||
Mac Studio is here! Plus, a new display.
|
||
|
||
MKBHD Merch: http://shop.MKBHD.com
|
||
|
||
0:00 Intro
|
||
1:52 The Design/Ports
|
||
4:00 Display XDR
|
||
6:00 M1 Ultra Chip
|
||
8:06 Real World Performance
|
||
11:09 Who should buy this?
|
||
13:07 My Thoughts
|
||
|
||
Tech I'm using right now: https://www.example.com
|
||
"""
|
||
let chapters = ChapterParser.parse(description: description, videoDuration: 900)
|
||
|
||
#expect(chapters.count == 7)
|
||
#expect(chapters[0].title == "Intro")
|
||
#expect(chapters[1].title == "The Design/Ports")
|
||
#expect(chapters[2].title == "Display XDR")
|
||
#expect(chapters[3].title == "M1 Ultra Chip")
|
||
#expect(chapters[4].title == "Real World Performance")
|
||
#expect(chapters[5].title == "Who should buy this?")
|
||
#expect(chapters[6].title == "My Thoughts")
|
||
}
|
||
|
||
@Test("parses real world Linus Tech Tips style description")
|
||
func realWorldLTTStyle() {
|
||
let description = """
|
||
Get exclusive NordVPN deal here ➼ https://nordvpn.com/ltt
|
||
|
||
Timestamps:
|
||
► 0:00 - Intro
|
||
► 2:15 - Unboxing
|
||
► 5:30 - Build Quality
|
||
► 8:45 - Performance Tests
|
||
► 12:00 - Conclusion
|
||
|
||
BUY: GPU at Amazon: https://amazon.com
|
||
"""
|
||
let chapters = ChapterParser.parse(description: description, videoDuration: 900)
|
||
|
||
#expect(chapters.count == 5)
|
||
#expect(chapters[0].title == "Intro")
|
||
#expect(chapters[1].title == "Unboxing")
|
||
#expect(chapters[2].title == "Build Quality")
|
||
#expect(chapters[3].title == "Performance Tests")
|
||
#expect(chapters[4].title == "Conclusion")
|
||
}
|
||
|
||
@Test("parses description with indented timestamps")
|
||
func indentedTimestamps() {
|
||
let description = """
|
||
Video chapters:
|
||
|
||
0:00 Introduction
|
||
3:00 Main Topic
|
||
10:00 Conclusion
|
||
"""
|
||
let chapters = ChapterParser.parse(description: description, videoDuration: 900)
|
||
|
||
#expect(chapters.count == 3)
|
||
#expect(chapters[0].title == "Introduction")
|
||
}
|
||
|
||
@Test("handles timestamps with special characters in titles")
|
||
func specialCharactersInTitles() {
|
||
let description = """
|
||
0:00 Introduction & Overview
|
||
5:00 Q&A Session
|
||
10:00 What's Next?
|
||
15:00 C++ vs Rust
|
||
"""
|
||
let chapters = ChapterParser.parse(description: description, videoDuration: 1200)
|
||
|
||
#expect(chapters.count == 4)
|
||
#expect(chapters[0].title == "Introduction & Overview")
|
||
#expect(chapters[1].title == "Q&A Session")
|
||
#expect(chapters[2].title == "What's Next?")
|
||
#expect(chapters[3].title == "C++ vs Rust")
|
||
}
|
||
|
||
// MARK: - Edge Cases
|
||
|
||
@Test("handles invalid seconds value")
|
||
func invalidSecondsValue() {
|
||
let description = """
|
||
0:00 Intro
|
||
1:99 Invalid Seconds
|
||
2:00 Valid
|
||
"""
|
||
let chapters = ChapterParser.parse(description: description, videoDuration: 600)
|
||
|
||
// 1:99 should be rejected, leaving only 2 valid chapters
|
||
#expect(chapters.count == 2)
|
||
#expect(chapters[0].startTime == 0)
|
||
#expect(chapters[1].startTime == 120)
|
||
}
|
||
|
||
@Test("handles timestamp at exact duration boundary")
|
||
func timestampAtDurationBoundary() {
|
||
let description = """
|
||
0:00 Intro
|
||
5:00 Middle
|
||
10:00 At Boundary
|
||
"""
|
||
let chapters = ChapterParser.parse(description: description, videoDuration: 600) // 10:00 = 600s
|
||
|
||
// 10:00 (600s) is NOT < 600, so it should be filtered out
|
||
#expect(chapters.count == 2)
|
||
}
|
||
|
||
@Test("handles very long video with many chapters")
|
||
func manyChapters() {
|
||
var lines: [String] = []
|
||
for i in 0..<50 {
|
||
let minutes = i * 5
|
||
lines.append("\(minutes):00 Chapter \(i + 1)")
|
||
}
|
||
let description = lines.joined(separator: "\n")
|
||
|
||
let chapters = ChapterParser.parse(description: description, videoDuration: 15000) // ~4 hours
|
||
|
||
#expect(chapters.count == 50)
|
||
#expect(chapters.first?.title == "Chapter 1")
|
||
#expect(chapters.last?.title == "Chapter 50")
|
||
}
|
||
}
|