Yattee v2 rewrite

This commit is contained in:
Arkadiusz Fal
2026-02-08 18:31:16 +01:00
parent 20d0cfc0c7
commit 05f921d605
1043 changed files with 163875 additions and 68430 deletions

View File

@@ -0,0 +1,513 @@
//
// 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")
}
}