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

File diff suppressed because it is too large Load Diff

272
YatteeTests/APITests.swift Normal file
View File

@@ -0,0 +1,272 @@
//
// APITests.swift
// YatteeTests
//
// Tests for API-related types and structures.
//
import Testing
import Foundation
@testable import Yattee
// MARK: - SearchResult Tests
@Suite("SearchResult Tests")
@MainActor
struct SearchResultTests {
@Test("Empty SearchResult")
func emptyResult() {
let result = SearchResult.empty
#expect(result.videos.isEmpty)
#expect(result.channels.isEmpty)
#expect(result.playlists.isEmpty)
#expect(result.nextPage == nil)
}
@Test("SearchResult with content")
func resultWithContent() {
let videos = [
makeVideo(title: "Video 1"),
makeVideo(title: "Video 2"),
]
let channels = [
Channel(id: .global("ch1"), name: "Channel 1"),
]
let result = SearchResult(
videos: videos,
channels: channels,
playlists: [],
orderedItems: [],
nextPage: 2
)
#expect(result.videos.count == 2)
#expect(result.channels.count == 1)
#expect(result.playlists.isEmpty)
#expect(result.nextPage == 2)
}
private func makeVideo(title: String) -> Video {
Video(
id: .global("test"),
title: title,
description: nil,
author: Author(id: "channel", name: "Test Channel"),
duration: 100,
publishedAt: nil,
publishedText: nil,
viewCount: nil,
likeCount: nil,
thumbnails: [],
isLive: false,
isUpcoming: false,
scheduledStartTime: nil
)
}
}
// MARK: - API Endpoint Routing Tests
@Suite("API Endpoint Routing Tests")
@MainActor
struct APIEndpointRoutingTests {
@Test("Invidious instance uses correct API")
func invidiousRouting() {
let instance = Instance(type: .invidious, url: URL(string: "https://inv.example.com")!)
#expect(instance.type == .invidious)
#expect(instance.isYouTubeInstance == true)
}
@Test("Piped instance uses correct API")
func pipedRouting() {
let instance = Instance(type: .piped, url: URL(string: "https://piped.example.com")!)
#expect(instance.type == .piped)
#expect(instance.isYouTubeInstance == true)
}
@Test("PeerTube instance uses correct API")
func peerTubeRouting() {
let instance = Instance(type: .peertube, url: URL(string: "https://pt.example.com")!)
#expect(instance.type == .peertube)
#expect(instance.isPeerTubeInstance == true)
}
}
// MARK: - Author Tests
@Suite("Author Tests")
@MainActor
struct AuthorTests {
@Test("Author creation")
func creation() {
let author = Author(id: "UC123", name: "Test Channel")
#expect(author.id == "UC123")
#expect(author.name == "Test Channel")
}
@Test("Author with optional fields")
func optionalFields() {
let author = Author(
id: "UC123",
name: "Test Channel",
thumbnailURL: URL(string: "https://example.com/thumb.jpg"),
instance: URL(string: "https://peertube.example.com")
)
#expect(author.thumbnailURL != nil)
#expect(author.instance != nil)
}
}
// MARK: - Thumbnail Tests
@Suite("Thumbnail Tests")
@MainActor
struct ThumbnailTests {
@Test("Thumbnail quality ordering")
func qualityOrdering() {
// Comparable implementation - lower quality < higher quality
#expect(Thumbnail.Quality.default < Thumbnail.Quality.medium)
#expect(Thumbnail.Quality.medium < Thumbnail.Quality.high)
#expect(Thumbnail.Quality.high < Thumbnail.Quality.standard)
#expect(Thumbnail.Quality.standard < Thumbnail.Quality.maxres)
}
@Test("Best thumbnail selection")
func bestThumbnailSelection() {
let thumbnails = [
Thumbnail(url: URL(string: "https://example.com/default.jpg")!, quality: .default),
Thumbnail(url: URL(string: "https://example.com/high.jpg")!, quality: .high),
Thumbnail(url: URL(string: "https://example.com/medium.jpg")!, quality: .medium),
]
// Using Comparable - max returns highest quality
let best = thumbnails.max(by: { $0.quality < $1.quality })
#expect(best?.quality == .high)
}
@Test("Thumbnail with dimensions")
func thumbnailWithDimensions() {
let thumbnail = Thumbnail(
url: URL(string: "https://example.com/thumb.jpg")!,
quality: .high,
width: 1280,
height: 720
)
#expect(thumbnail.width == 1280)
#expect(thumbnail.height == 720)
}
}
// MARK: - Channel Search Tests
@Suite("ChannelSearchPage Tests")
@MainActor
struct ChannelSearchPageTests {
@Test("Empty ChannelSearchPage")
func emptyPage() {
let page = ChannelSearchPage.empty
#expect(page.items.isEmpty)
#expect(page.nextPage == nil)
}
@Test("ChannelSearchPage with videos")
func pageWithVideos() {
let video = makeVideo(title: "Test Video")
let items: [ChannelSearchItem] = [.video(video)]
let page = ChannelSearchPage(items: items, nextPage: 2)
#expect(page.items.count == 1)
#expect(page.nextPage == 2)
if case .video(let v) = page.items[0] {
#expect(v.title == "Test Video")
} else {
Issue.record("Expected video item")
}
}
@Test("ChannelSearchPage with playlists")
func pageWithPlaylists() {
let playlist = Playlist(
id: .global("PL123"),
title: "Test Playlist",
author: Author(id: "ch1", name: "Channel"),
videoCount: 10
)
let items: [ChannelSearchItem] = [.playlist(playlist)]
let page = ChannelSearchPage(items: items, nextPage: nil)
#expect(page.items.count == 1)
#expect(page.nextPage == nil)
if case .playlist(let p) = page.items[0] {
#expect(p.title == "Test Playlist")
} else {
Issue.record("Expected playlist item")
}
}
@Test("ChannelSearchPage with mixed content")
func pageWithMixedContent() {
let video = makeVideo(title: "Video 1")
let playlist = Playlist(
id: .global("PL456"),
title: "Playlist 1",
author: Author(id: "ch1", name: "Channel"),
videoCount: 5
)
let items: [ChannelSearchItem] = [
.video(video),
.playlist(playlist),
]
let page = ChannelSearchPage(items: items, nextPage: 3)
#expect(page.items.count == 2)
#expect(page.nextPage == 3)
}
@Test("ChannelSearchItem identifiers are unique")
func uniqueIdentifiers() {
let video = makeVideo(title: "Video")
let playlist = Playlist(
id: .global("PL789"),
title: "Playlist",
author: Author(id: "ch1", name: "Channel"),
videoCount: 3
)
let videoItem = ChannelSearchItem.video(video)
let playlistItem = ChannelSearchItem.playlist(playlist)
#expect(videoItem.id != playlistItem.id)
#expect(videoItem.id.hasPrefix("video-"))
#expect(playlistItem.id.hasPrefix("playlist-"))
}
private func makeVideo(title: String) -> Video {
Video(
id: .global("test-\(title)"),
title: title,
description: nil,
author: Author(id: "channel", name: "Test Channel"),
duration: 100,
publishedAt: nil,
publishedText: nil,
viewCount: nil,
likeCount: nil,
thumbnails: [],
isLive: false,
isUpcoming: false,
scheduledStartTime: nil
)
}
}

View File

@@ -0,0 +1,129 @@
//
// ChapterIntegrationTests.swift
// YatteeTests
//
// Integration tests for chapter resolution from SponsorBlock and description parsing.
//
import Foundation
import Testing
@testable import Yattee
@Suite("Chapter Integration")
struct ChapterIntegrationTests {
// MARK: - SponsorBlock Chapter Extraction
@Test("extracts chapters from SponsorBlock segments")
func sponsorBlockChapterExtraction() {
let segments: [SponsorBlockSegment] = [
makeSponsorBlockChapter(startTime: 0, description: "Introduction"),
makeSponsorBlockChapter(startTime: 60, description: "Main Topic"),
makeSponsorBlockChapter(startTime: 300, description: "Conclusion"),
]
let chapters = segments.extractChapters(videoDuration: 600)
#expect(chapters.count == 3)
#expect(chapters[0].title == "Introduction")
#expect(chapters[0].startTime == 0)
#expect(chapters[0].endTime == 60)
#expect(chapters[1].title == "Main Topic")
#expect(chapters[1].startTime == 60)
#expect(chapters[1].endTime == 300)
#expect(chapters[2].title == "Conclusion")
#expect(chapters[2].startTime == 300)
#expect(chapters[2].endTime == 600)
}
@Test("requires minimum 2 SponsorBlock chapters")
func sponsorBlockMinimumChapters() {
let segments: [SponsorBlockSegment] = [
makeSponsorBlockChapter(startTime: 0, description: "Only One"),
]
let chapters = segments.extractChapters(videoDuration: 600)
#expect(chapters.isEmpty)
}
@Test("filters non-chapter SponsorBlock segments")
func sponsorBlockFiltersNonChapters() {
let segments: [SponsorBlockSegment] = [
makeSponsorBlockChapter(startTime: 0, description: "Intro"),
makeSponsorBlockSegment(startTime: 30, endTime: 60, actionType: .skip, category: .sponsor),
makeSponsorBlockChapter(startTime: 120, description: "Content"),
makeSponsorBlockSegment(startTime: 180, endTime: 200, actionType: .mute, category: .musicOfftopic),
]
let chapters = segments.extractChapters(videoDuration: 600)
#expect(chapters.count == 2)
#expect(chapters[0].title == "Intro")
#expect(chapters[1].title == "Content")
}
@Test("uses fallback title for chapters without description")
func sponsorBlockFallbackTitle() {
let segments: [SponsorBlockSegment] = [
makeSponsorBlockChapter(startTime: 0, description: nil),
makeSponsorBlockChapter(startTime: 60, description: nil),
]
let chapters = segments.extractChapters(videoDuration: 600)
#expect(chapters.count == 2)
#expect(chapters[0].title == "Chapter 1")
#expect(chapters[1].title == "Chapter 2")
}
@Test("sorts SponsorBlock chapters by start time")
func sponsorBlockSorting() {
let segments: [SponsorBlockSegment] = [
makeSponsorBlockChapter(startTime: 300, description: "Third"),
makeSponsorBlockChapter(startTime: 0, description: "First"),
makeSponsorBlockChapter(startTime: 120, description: "Second"),
]
let chapters = segments.extractChapters(videoDuration: 600)
#expect(chapters.count == 3)
#expect(chapters[0].title == "First")
#expect(chapters[1].title == "Second")
#expect(chapters[2].title == "Third")
}
// MARK: - Helpers
private func makeSponsorBlockChapter(startTime: Double, description: String?) -> SponsorBlockSegment {
makeSponsorBlockSegment(
startTime: startTime,
endTime: startTime, // Chapters have same start/end
actionType: .chapter,
category: .sponsor, // Category doesn't matter for chapters
description: description
)
}
private func makeSponsorBlockSegment(
startTime: Double,
endTime: Double,
actionType: SponsorBlockActionType,
category: SponsorBlockCategory,
description: String? = nil
) -> SponsorBlockSegment {
// Create segment using JSON decoding since init is not public
let json: [String: Any] = [
"UUID": UUID().uuidString,
"category": category.rawValue,
"actionType": actionType.rawValue,
"segment": [startTime, endTime],
"videoDuration": 600.0,
"description": description as Any
]
let data = try! JSONSerialization.data(withJSONObject: json)
return try! JSONDecoder().decode(SponsorBlockSegment.self, from: data)
}
}

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

View File

@@ -0,0 +1,338 @@
//
// CredentialsTests.swift
// YatteeTests
//
// Tests for credential managers (Invidious and Piped).
//
import Testing
import Foundation
@testable import Yattee
// MARK: - PipedCredentialsManager Tests
@Suite("PipedCredentialsManager Tests")
@MainActor
struct PipedCredentialsManagerTests {
private func createTestInstance() -> Instance {
Instance(type: .piped, url: URL(string: "https://piped.test.example")!)
}
@Test("setCredential stores token and updates loggedInInstanceIDs")
func setCredentialStoresToken() {
let manager = PipedCredentialsManager()
let instance = createTestInstance()
let token = "test-auth-token-\(UUID().uuidString)"
manager.setCredential(token, for: instance)
#expect(manager.loggedInInstanceIDs.contains(instance.id))
// Cleanup
manager.deleteCredential(for: instance)
}
@Test("credential retrieves stored token")
func credentialRetrievesToken() {
let manager = PipedCredentialsManager()
let instance = createTestInstance()
let token = "test-auth-token-\(UUID().uuidString)"
manager.setCredential(token, for: instance)
let retrieved = manager.credential(for: instance)
#expect(retrieved == token)
// Cleanup
manager.deleteCredential(for: instance)
}
@Test("credential returns nil for unknown instance")
func credentialReturnsNilForUnknown() {
let manager = PipedCredentialsManager()
let instance = createTestInstance()
let retrieved = manager.credential(for: instance)
#expect(retrieved == nil)
}
@Test("deleteCredential removes token and updates loggedInInstanceIDs")
func deleteCredentialRemovesToken() {
let manager = PipedCredentialsManager()
let instance = createTestInstance()
let token = "test-auth-token-\(UUID().uuidString)"
manager.setCredential(token, for: instance)
#expect(manager.loggedInInstanceIDs.contains(instance.id))
manager.deleteCredential(for: instance)
#expect(!manager.loggedInInstanceIDs.contains(instance.id))
#expect(manager.credential(for: instance) == nil)
}
@Test("isLoggedIn returns true when logged in")
func isLoggedInReturnsTrue() {
let manager = PipedCredentialsManager()
let instance = createTestInstance()
let token = "test-auth-token-\(UUID().uuidString)"
manager.setCredential(token, for: instance)
#expect(manager.isLoggedIn(for: instance) == true)
// Cleanup
manager.deleteCredential(for: instance)
}
@Test("isLoggedIn returns false when not logged in")
func isLoggedInReturnsFalse() {
let manager = PipedCredentialsManager()
let instance = createTestInstance()
#expect(manager.isLoggedIn(for: instance) == false)
}
@Test("refreshLoginStatus syncs loggedInInstanceIDs with Keychain")
func refreshLoginStatusSyncs() {
let manager = PipedCredentialsManager()
let instance = createTestInstance()
let token = "test-auth-token-\(UUID().uuidString)"
// Store credential
manager.setCredential(token, for: instance)
// Simulate stale state by manually removing from tracked set
// (This tests the refresh mechanism)
let freshManager = PipedCredentialsManager()
#expect(!freshManager.loggedInInstanceIDs.contains(instance.id))
freshManager.refreshLoginStatus(for: instance)
#expect(freshManager.loggedInInstanceIDs.contains(instance.id))
// Cleanup
manager.deleteCredential(for: instance)
}
@Test("setCredential updates existing token")
func setCredentialUpdatesExisting() {
let manager = PipedCredentialsManager()
let instance = createTestInstance()
let token1 = "test-auth-token-1-\(UUID().uuidString)"
let token2 = "test-auth-token-2-\(UUID().uuidString)"
manager.setCredential(token1, for: instance)
#expect(manager.credential(for: instance) == token1)
manager.setCredential(token2, for: instance)
#expect(manager.credential(for: instance) == token2)
// Cleanup
manager.deleteCredential(for: instance)
}
@Test("Multiple instances have separate credentials")
func multipleInstancesSeparateCredentials() {
let manager = PipedCredentialsManager()
let instance1 = Instance(type: .piped, url: URL(string: "https://piped1.test.example")!)
let instance2 = Instance(type: .piped, url: URL(string: "https://piped2.test.example")!)
let token1 = "token-1-\(UUID().uuidString)"
let token2 = "token-2-\(UUID().uuidString)"
manager.setCredential(token1, for: instance1)
manager.setCredential(token2, for: instance2)
#expect(manager.credential(for: instance1) == token1)
#expect(manager.credential(for: instance2) == token2)
#expect(manager.loggedInInstanceIDs.count >= 2)
// Cleanup
manager.deleteCredential(for: instance1)
manager.deleteCredential(for: instance2)
}
}
// MARK: - InvidiousCredentialsManager Tests
@Suite("InvidiousCredentialsManager Tests")
@MainActor
struct InvidiousCredentialsManagerTests {
private func createTestInstance() -> Instance {
Instance(type: .invidious, url: URL(string: "https://invidious.test.example")!)
}
@Test("setSID stores session and updates loggedInInstanceIDs")
func setSIDStoresSession() {
let manager = InvidiousCredentialsManager()
let instance = createTestInstance()
let sid = "test-session-id-\(UUID().uuidString)"
manager.setSID(sid, for: instance)
#expect(manager.loggedInInstanceIDs.contains(instance.id))
// Cleanup
manager.deleteSID(for: instance)
}
@Test("sid retrieves stored session")
func sidRetrievesSession() {
let manager = InvidiousCredentialsManager()
let instance = createTestInstance()
let sid = "test-session-id-\(UUID().uuidString)"
manager.setSID(sid, for: instance)
let retrieved = manager.sid(for: instance)
#expect(retrieved == sid)
// Cleanup
manager.deleteSID(for: instance)
}
@Test("sid returns nil for unknown instance")
func sidReturnsNilForUnknown() {
let manager = InvidiousCredentialsManager()
let instance = createTestInstance()
let retrieved = manager.sid(for: instance)
#expect(retrieved == nil)
}
@Test("deleteSID removes session and updates loggedInInstanceIDs")
func deleteSIDRemovesSession() {
let manager = InvidiousCredentialsManager()
let instance = createTestInstance()
let sid = "test-session-id-\(UUID().uuidString)"
manager.setSID(sid, for: instance)
#expect(manager.loggedInInstanceIDs.contains(instance.id))
manager.deleteSID(for: instance)
#expect(!manager.loggedInInstanceIDs.contains(instance.id))
#expect(manager.sid(for: instance) == nil)
}
@Test("Protocol methods delegate correctly")
func protocolMethodsDelegate() {
let manager = InvidiousCredentialsManager()
let instance = createTestInstance()
let sid = "test-session-id-\(UUID().uuidString)"
// Test setCredential -> setSID
manager.setCredential(sid, for: instance)
#expect(manager.sid(for: instance) == sid)
// Test credential -> sid
#expect(manager.credential(for: instance) == sid)
// Test deleteCredential -> deleteSID
manager.deleteCredential(for: instance)
#expect(manager.sid(for: instance) == nil)
}
@Test("isLoggedIn returns correct state")
func isLoggedInReturnsCorrectState() {
let manager = InvidiousCredentialsManager()
let instance = createTestInstance()
let sid = "test-session-id-\(UUID().uuidString)"
#expect(manager.isLoggedIn(for: instance) == false)
manager.setSID(sid, for: instance)
#expect(manager.isLoggedIn(for: instance) == true)
manager.deleteSID(for: instance)
#expect(manager.isLoggedIn(for: instance) == false)
}
@Test("Thumbnail cache stores and retrieves URLs")
func thumbnailCacheWorks() {
let manager = InvidiousCredentialsManager()
let channelID = "UC\(UUID().uuidString.prefix(22))"
let thumbnailURL = URL(string: "https://example.com/thumb.jpg")!
manager.setThumbnailURL(thumbnailURL, forChannelID: channelID)
let retrieved = manager.thumbnailURL(forChannelID: channelID)
#expect(retrieved == thumbnailURL)
// Cleanup
manager.clearThumbnailCache()
}
@Test("uncachedChannelIDs filters correctly")
func uncachedChannelIDsFilters() {
let manager = InvidiousCredentialsManager()
let cachedID = "UCcached\(UUID().uuidString.prefix(16))"
let uncachedID = "UCuncached\(UUID().uuidString.prefix(14))"
manager.setThumbnailURL(URL(string: "https://example.com/thumb.jpg")!, forChannelID: cachedID)
let uncached = manager.uncachedChannelIDs(from: [cachedID, uncachedID])
#expect(uncached.count == 1)
#expect(uncached.contains(uncachedID))
#expect(!uncached.contains(cachedID))
// Cleanup
manager.clearThumbnailCache()
}
@Test("setThumbnailURLs batches correctly")
func setThumbnailURLsBatches() {
let manager = InvidiousCredentialsManager()
let id1 = "UC1\(UUID().uuidString.prefix(19))"
let id2 = "UC2\(UUID().uuidString.prefix(19))"
let url1 = URL(string: "https://example.com/thumb1.jpg")!
let url2 = URL(string: "https://example.com/thumb2.jpg")!
manager.setThumbnailURLs([id1: url1, id2: url2])
#expect(manager.thumbnailURL(forChannelID: id1) == url1)
#expect(manager.thumbnailURL(forChannelID: id2) == url2)
// Cleanup
manager.clearThumbnailCache()
}
}
// MARK: - InstanceCredentialsManager Protocol Tests
@Suite("InstanceCredentialsManager Protocol Tests")
@MainActor
struct InstanceCredentialsManagerProtocolTests {
@Test("PipedCredentialsManager conforms to protocol")
func pipedConformsToProtocol() {
let manager: InstanceCredentialsManager = PipedCredentialsManager()
let instance = Instance(type: .piped, url: URL(string: "https://piped.test.example")!)
let token = "protocol-test-\(UUID().uuidString)"
manager.setCredential(token, for: instance)
#expect(manager.credential(for: instance) == token)
#expect(manager.isLoggedIn(for: instance) == true)
manager.deleteCredential(for: instance)
#expect(manager.isLoggedIn(for: instance) == false)
}
@Test("InvidiousCredentialsManager conforms to protocol")
func invidiousConformsToProtocol() {
let manager: InstanceCredentialsManager = InvidiousCredentialsManager()
let instance = Instance(type: .invidious, url: URL(string: "https://invidious.test.example")!)
let sid = "protocol-test-\(UUID().uuidString)"
manager.setCredential(sid, for: instance)
#expect(manager.credential(for: instance) == sid)
#expect(manager.isLoggedIn(for: instance) == true)
manager.deleteCredential(for: instance)
#expect(manager.isLoggedIn(for: instance) == false)
}
}

823
YatteeTests/DataTests.swift Normal file
View File

@@ -0,0 +1,823 @@
//
// DataTests.swift
// YatteeTests
//
// Tests for the local data persistence layer.
//
import Testing
import Foundation
@testable import Yattee
@MainActor
@Suite("Data Layer Tests")
struct DataTests {
// MARK: - Watch Entry Tests
@Suite("Watch Entry")
struct WatchEntryTests {
@Test("Progress calculation")
@MainActor
func progressCalculation() {
let entry = WatchEntry(
videoID: "test123",
sourceRawValue: "youtube",
title: "Test Video",
authorName: "Test Channel",
authorID: "channel123",
duration: 600, // 10 minutes
watchedSeconds: 300 // 5 minutes
)
#expect(entry.progress == 0.5)
}
@Test("Auto-finish at 90%")
@MainActor
func autoFinishAt90Percent() {
let entry = WatchEntry(
videoID: "test123",
sourceRawValue: "youtube",
title: "Test Video",
authorName: "Test Channel",
authorID: "channel123",
duration: 100
)
#expect(!entry.isFinished)
entry.updateProgress(seconds: 90)
#expect(entry.isFinished)
#expect(entry.progress >= 0.9)
}
@Test("Reset progress")
@MainActor
func resetProgress() {
let entry = WatchEntry(
videoID: "test123",
sourceRawValue: "youtube",
title: "Test Video",
authorName: "Test Channel",
authorID: "channel123",
duration: 100,
watchedSeconds: 90,
isFinished: true
)
entry.resetProgress()
#expect(entry.watchedSeconds == 0)
#expect(!entry.isFinished)
}
@Test("Content source YouTube")
@MainActor
func contentSourceYouTube() {
let entry = WatchEntry(
videoID: "abc123",
sourceRawValue: "global",
title: "Test",
authorName: "Channel",
authorID: "ch1",
duration: 100
)
if case .global = entry.contentSource {
// Expected
} else {
Issue.record("Expected global source")
}
}
@Test("Content source Federated")
@MainActor
func contentSourceFederated() {
let entry = WatchEntry(
videoID: "uuid123",
sourceRawValue: "federated",
instanceURLString: "https://peertube.example.com",
title: "Test",
authorName: "Channel",
authorID: "ch1",
duration: 100
)
if case .federated(_, let instance) = entry.contentSource {
#expect(instance.host == "peertube.example.com")
} else {
Issue.record("Expected federated source")
}
}
@Test("Remaining time formatting")
@MainActor
func remainingTimeFormatting() {
let entry = WatchEntry(
videoID: "test",
sourceRawValue: "youtube",
title: "Test",
authorName: "Channel",
authorID: "ch1",
duration: 600, // 10 minutes
watchedSeconds: 300 // 5 minutes watched
)
// 5 minutes remaining = "5:00"
#expect(entry.remainingTime == "5:00")
}
@Test("Remaining time with seconds")
@MainActor
func remainingTimeWithSeconds() {
let entry = WatchEntry(
videoID: "test",
sourceRawValue: "youtube",
title: "Test",
authorName: "Channel",
authorID: "ch1",
duration: 125, // 2:05
watchedSeconds: 60 // 1 minute watched
)
// 65 seconds remaining = "1:05"
#expect(entry.remainingTime == "1:05")
}
@Test("Thumbnail URL conversion")
@MainActor
func thumbnailURL() {
let entry = WatchEntry(
videoID: "test",
sourceRawValue: "youtube",
title: "Test",
authorName: "Channel",
authorID: "ch1",
duration: 100,
thumbnailURLString: "https://example.com/thumb.jpg"
)
#expect(entry.thumbnailURL?.absoluteString == "https://example.com/thumb.jpg")
}
@Test("Thumbnail URL nil for invalid string")
@MainActor
func thumbnailURLNil() {
let entry = WatchEntry(
videoID: "test",
sourceRawValue: "youtube",
title: "Test",
authorName: "Channel",
authorID: "ch1",
duration: 100,
thumbnailURLString: nil
)
#expect(entry.thumbnailURL == nil)
}
@Test("Mark as finished")
@MainActor
func markAsFinished() {
let entry = WatchEntry(
videoID: "test",
sourceRawValue: "youtube",
title: "Test",
authorName: "Channel",
authorID: "ch1",
duration: 100
)
#expect(!entry.isFinished)
entry.markAsFinished()
#expect(entry.isFinished)
}
}
// MARK: - Bookmark Tests
@Suite("Bookmark")
struct BookmarkTests {
@Test("Formatted duration hours")
@MainActor
func formattedDurationHours() {
let bookmark = Bookmark(
videoID: "test",
sourceRawValue: "youtube",
title: "Long Video",
authorName: "Channel",
authorID: "ch1",
duration: 3661 // 1:01:01
)
#expect(bookmark.formattedDuration == "1:01:01")
}
@Test("Formatted duration minutes")
@MainActor
func formattedDurationMinutes() {
let bookmark = Bookmark(
videoID: "test",
sourceRawValue: "youtube",
title: "Short Video",
authorName: "Channel",
authorID: "ch1",
duration: 125 // 2:05
)
#expect(bookmark.formattedDuration == "2:05")
}
@Test("Live shows LIVE")
@MainActor
func liveShowsLive() {
let bookmark = Bookmark(
videoID: "test",
sourceRawValue: "youtube",
title: "Live Stream",
authorName: "Channel",
authorID: "ch1",
duration: 0,
isLive: true
)
#expect(bookmark.formattedDuration == "LIVE")
}
@Test("Content source Global")
@MainActor
func contentSourceGlobal() {
let bookmark = Bookmark(
videoID: "abc123",
sourceRawValue: "global",
title: "Test",
authorName: "Channel",
authorID: "ch1",
duration: 100
)
if case .global = bookmark.contentSource {
// Expected
} else {
Issue.record("Expected global source")
}
}
@Test("Content source Federated")
@MainActor
func contentSourceFederated() {
let bookmark = Bookmark(
videoID: "uuid123",
sourceRawValue: "federated",
instanceURLString: "https://peertube.example.com",
title: "Test",
authorName: "Channel",
authorID: "ch1",
duration: 100
)
if case .federated(_, let instance) = bookmark.contentSource {
#expect(instance.host == "peertube.example.com")
} else {
Issue.record("Expected federated source")
}
}
@Test("Thumbnail URL conversion")
@MainActor
func thumbnailURL() {
let bookmark = Bookmark(
videoID: "test",
sourceRawValue: "youtube",
title: "Test",
authorName: "Channel",
authorID: "ch1",
duration: 100,
thumbnailURLString: "https://example.com/thumb.jpg"
)
#expect(bookmark.thumbnailURL?.absoluteString == "https://example.com/thumb.jpg")
}
@Test("Zero duration shows empty string")
@MainActor
func zeroDuration() {
let bookmark = Bookmark(
videoID: "test",
sourceRawValue: "youtube",
title: "Test",
authorName: "Channel",
authorID: "ch1",
duration: 0,
isLive: false
)
#expect(bookmark.formattedDuration == "")
}
}
// MARK: - Local Playlist Tests
@Suite("Local Playlist")
struct LocalPlaylistTests {
@Test("Video count")
@MainActor
func videoCount() {
let playlist = LocalPlaylist(title: "My Playlist")
#expect(playlist.videoCount == 0)
}
@Test("Total duration formatting")
@MainActor
func totalDurationFormatting() {
let playlist = LocalPlaylist(title: "My Playlist")
// Empty playlist
#expect(playlist.formattedTotalDuration == "0 min")
}
@Test("Total duration with hours")
@MainActor
func totalDurationWithHours() {
let playlist = LocalPlaylist(title: "Long Playlist")
let item = LocalPlaylistItem(
sortOrder: 0,
videoID: "video1",
sourceRawValue: "youtube",
title: "Long Video",
authorName: "Channel",
authorID: "ch1",
duration: 7200 // 2 hours
)
item.playlist = playlist
playlist.items?.append(item)
#expect(playlist.formattedTotalDuration == "2h 0m")
}
@Test("Contains video check")
@MainActor
func containsVideoCheck() {
let playlist = LocalPlaylist(title: "Test")
let item = LocalPlaylistItem(
sortOrder: 0,
videoID: "abc123",
sourceRawValue: "youtube",
title: "Video",
authorName: "Channel",
authorID: "ch1",
duration: 100
)
item.playlist = playlist
playlist.items?.append(item)
#expect(playlist.contains(videoID: "abc123"))
#expect(!playlist.contains(videoID: "xyz789"))
}
@Test("Sorted items by order")
@MainActor
func sortedItems() {
let playlist = LocalPlaylist(title: "Test")
// Add items out of order
let item2 = LocalPlaylistItem(
sortOrder: 2,
videoID: "video2",
sourceRawValue: "youtube",
title: "Second",
authorName: "Channel",
authorID: "ch1",
duration: 100
)
let item1 = LocalPlaylistItem(
sortOrder: 1,
videoID: "video1",
sourceRawValue: "youtube",
title: "First",
authorName: "Channel",
authorID: "ch1",
duration: 100
)
let item3 = LocalPlaylistItem(
sortOrder: 3,
videoID: "video3",
sourceRawValue: "youtube",
title: "Third",
authorName: "Channel",
authorID: "ch1",
duration: 100
)
item1.playlist = playlist
item2.playlist = playlist
item3.playlist = playlist
playlist.items?.append(item2)
playlist.items?.append(item1)
playlist.items?.append(item3)
let sorted = playlist.sortedItems
#expect(sorted.count == 3)
#expect(sorted[0].videoID == "video1")
#expect(sorted[1].videoID == "video2")
#expect(sorted[2].videoID == "video3")
}
@Test("Thumbnail URL from first sorted item")
@MainActor
func thumbnailURL() {
let playlist = LocalPlaylist(title: "Test")
let item1 = LocalPlaylistItem(
sortOrder: 1,
videoID: "video1",
sourceRawValue: "youtube",
title: "First",
authorName: "Channel",
authorID: "ch1",
duration: 100,
thumbnailURLString: "https://example.com/thumb1.jpg"
)
let item2 = LocalPlaylistItem(
sortOrder: 0,
videoID: "video2",
sourceRawValue: "youtube",
title: "Actually First",
authorName: "Channel",
authorID: "ch1",
duration: 100,
thumbnailURLString: "https://example.com/thumb2.jpg"
)
item1.playlist = playlist
item2.playlist = playlist
playlist.items?.append(item1)
playlist.items?.append(item2)
// Should get thumbnail from item with lowest sortOrder
#expect(playlist.thumbnailURL?.absoluteString == "https://example.com/thumb2.jpg")
}
@Test("Items is optional for CloudKit compatibility")
@MainActor
func itemsOptional() {
let playlist = LocalPlaylist(title: "Empty")
// items should be initialized as empty array, not nil
#expect(playlist.items != nil)
#expect(playlist.items?.isEmpty == true)
}
}
// MARK: - Subscription Tests
@Suite("Subscription")
struct SubscriptionTests {
@Test("Formatted subscriber count")
@MainActor
func formattedSubscriberCount() {
let sub = Subscription(
channelID: "ch123",
sourceRawValue: "youtube",
name: "Popular Channel",
subscriberCount: 1_500_000
)
#expect(sub.formattedSubscriberCount == "1.5M")
}
@Test("No subscriber count")
@MainActor
func noSubscriberCount() {
let sub = Subscription(
channelID: "ch123",
sourceRawValue: "youtube",
name: "New Channel"
)
#expect(sub.formattedSubscriberCount == nil)
}
@Test("Content source Global")
@MainActor
func contentSourceGlobal() {
let sub = Subscription(
channelID: "ch123",
sourceRawValue: "global",
name: "Channel"
)
if case .global = sub.contentSource {
// Expected
} else {
Issue.record("Expected global source")
}
}
@Test("Content source Federated")
@MainActor
func contentSourceFederated() {
let sub = Subscription(
channelID: "ch123",
sourceRawValue: "federated",
instanceURLString: "https://peertube.example.com",
name: "Channel"
)
if case .federated(_, let instance) = sub.contentSource {
#expect(instance.host == "peertube.example.com")
} else {
Issue.record("Expected federated source")
}
}
@Test("Avatar URL conversion")
@MainActor
func avatarURL() {
let sub = Subscription(
channelID: "ch123",
sourceRawValue: "youtube",
name: "Channel",
avatarURLString: "https://example.com/avatar.jpg"
)
#expect(sub.avatarURL?.absoluteString == "https://example.com/avatar.jpg")
}
@Test("Banner URL conversion")
@MainActor
func bannerURL() {
let sub = Subscription(
channelID: "ch123",
sourceRawValue: "youtube",
name: "Channel",
bannerURLString: "https://example.com/banner.jpg"
)
#expect(sub.bannerURL?.absoluteString == "https://example.com/banner.jpg")
}
@Test("Update from channel")
@MainActor
func updateFromChannel() {
let sub = Subscription(
channelID: "ch123",
sourceRawValue: "youtube",
name: "Old Name",
subscriberCount: 1000
)
let channel = Channel(
id: .global("ch123"),
name: "New Name",
description: "Updated description",
subscriberCount: 2000,
thumbnailURL: URL(string: "https://example.com/new-avatar.jpg"),
bannerURL: URL(string: "https://example.com/new-banner.jpg"),
isVerified: true
)
sub.update(from: channel)
#expect(sub.name == "New Name")
#expect(sub.channelDescription == "Updated description")
#expect(sub.subscriberCount == 2000)
#expect(sub.avatarURLString == "https://example.com/new-avatar.jpg")
#expect(sub.bannerURLString == "https://example.com/new-banner.jpg")
#expect(sub.isVerified == true)
}
}
// MARK: - LocalPlaylistItem Tests
@Suite("LocalPlaylistItem")
struct LocalPlaylistItemTests {
@Test("Content source Global")
@MainActor
func contentSourceGlobal() {
let item = LocalPlaylistItem(
sortOrder: 0,
videoID: "abc123",
sourceRawValue: "global",
title: "Test",
authorName: "Channel",
authorID: "ch1",
duration: 100
)
if case .global = item.contentSource {
// Expected
} else {
Issue.record("Expected global source")
}
}
@Test("Content source Federated")
@MainActor
func contentSourceFederated() {
let item = LocalPlaylistItem(
sortOrder: 0,
videoID: "uuid123",
sourceRawValue: "federated",
instanceURLString: "https://peertube.example.com",
title: "Test",
authorName: "Channel",
authorID: "ch1",
duration: 100
)
if case .federated(_, let instance) = item.contentSource {
#expect(instance.host == "peertube.example.com")
} else {
Issue.record("Expected federated source")
}
}
@Test("Thumbnail URL conversion")
@MainActor
func thumbnailURL() {
let item = LocalPlaylistItem(
sortOrder: 0,
videoID: "abc123",
sourceRawValue: "youtube",
title: "Test",
authorName: "Channel",
authorID: "ch1",
duration: 100,
thumbnailURLString: "https://example.com/thumb.jpg"
)
#expect(item.thumbnailURL?.absoluteString == "https://example.com/thumb.jpg")
}
@Test("Default values for CloudKit compatibility")
@MainActor
func defaultValues() {
let item = LocalPlaylistItem(
sortOrder: 0,
videoID: "test",
sourceRawValue: "youtube",
title: "Test",
authorName: "Channel",
authorID: "ch1",
duration: 100
)
// Check default values are set
#expect(item.isLive == false)
#expect(item.thumbnailURLString == nil)
#expect(item.instanceURLString == nil)
#expect(item.peertubeUUID == nil)
}
}
// MARK: - DataManager Tests
@Suite("DataManager")
struct DataManagerTests {
@Test("Watch progress round trip")
@MainActor
func watchProgressRoundTrip() async throws {
let manager = try DataManager(inMemory: true)
// Create a test video
let video = Video(
id: .global("testVideo123"),
title: "Test Video",
description: nil,
author: Author(id: "ch1", name: "Channel"),
duration: 600,
publishedAt: nil,
publishedText: nil,
viewCount: nil,
likeCount: nil,
thumbnails: [],
isLive: false,
isUpcoming: false,
scheduledStartTime: nil
)
// Record progress
manager.updateWatchProgress(for: video, seconds: 300)
// Retrieve progress
let progress = manager.watchProgress(for: "testVideo123")
#expect(progress == 300)
}
@Test("Bookmark toggle")
@MainActor
func bookmarkToggle() async throws {
let manager = try DataManager(inMemory: true)
let video = Video(
id: .global("bookmarkTest"),
title: "Bookmark Me",
description: nil,
author: Author(id: "ch1", name: "Channel"),
duration: 300,
publishedAt: nil,
publishedText: nil,
viewCount: nil,
likeCount: nil,
thumbnails: [],
isLive: false,
isUpcoming: false,
scheduledStartTime: nil
)
#expect(!manager.isBookmarked(videoID: "bookmarkTest"))
manager.addBookmark(for: video)
#expect(manager.isBookmarked(videoID: "bookmarkTest"))
manager.removeBookmark(for: "bookmarkTest")
#expect(!manager.isBookmarked(videoID: "bookmarkTest"))
}
@Test("Create and delete playlist")
@MainActor
func createAndDeletePlaylist() async throws {
let manager = try DataManager(inMemory: true)
let playlist = manager.createPlaylist(title: "Test Playlist", description: "A test")
#expect(playlist.title == "Test Playlist")
#expect(playlist.playlistDescription == "A test")
var playlists = manager.playlists()
#expect(playlists.count == 1)
manager.deletePlaylist(playlist)
playlists = manager.playlists()
#expect(playlists.count == 0)
}
@Test("Subscription management")
@MainActor
func subscriptionManagement() async throws {
let manager = try DataManager(inMemory: true)
let channel = Channel(
id: .global("testChannel"),
name: "Test Channel",
description: "A test channel",
subscriberCount: 10000,
thumbnailURL: nil
)
#expect(!manager.isSubscribed(to: "testChannel"))
manager.subscribe(to: channel)
#expect(manager.isSubscribed(to: "testChannel"))
let subs = manager.subscriptions()
#expect(subs.count == 1)
#expect(subs.first?.name == "Test Channel")
manager.unsubscribe(from: "testChannel")
#expect(!manager.isSubscribed(to: "testChannel"))
}
@Test("Watch history ordering")
@MainActor
func watchHistoryOrdering() async throws {
let manager = try DataManager(inMemory: true)
// Create multiple videos
for i in 1...3 {
let video = Video(
id: .global("video\(i)"),
title: "Video \(i)",
description: nil,
author: Author(id: "ch1", name: "Channel"),
duration: 100,
publishedAt: nil,
publishedText: nil,
viewCount: nil,
likeCount: nil,
thumbnails: [],
isLive: false,
isUpcoming: false,
scheduledStartTime: nil
)
manager.updateWatchProgress(for: video, seconds: Double(i * 10))
// Small delay to ensure different timestamps
try await Task.sleep(for: .milliseconds(10))
}
let history = manager.watchHistory()
#expect(history.count == 3)
// Most recent should be first
#expect(history.first?.videoID == "video3")
}
}
}

View File

@@ -0,0 +1,493 @@
//
// DownloadTests.swift
// YatteeTests
//
// Tests for the download system.
//
import Testing
import Foundation
@testable import Yattee
// MARK: - Download Model Tests
@Suite("Download Model Tests")
struct DownloadModelTests {
@Test("Download initialization from Video")
func initialization() {
let video = makeTestVideo()
let streamURL = URL(string: "https://example.com/stream.mp4")!
let download = Download(
video: video,
quality: "1080p",
formatID: "137",
streamURL: streamURL
)
#expect(download.videoID == video.id)
#expect(download.title == video.title)
#expect(download.channelName == video.author.name)
#expect(download.quality == "1080p")
#expect(download.formatID == "137")
#expect(download.status == .queued)
#expect(download.progress == 0)
#expect(download.priority == .normal)
#expect(download.autoDelete == false)
}
@Test("Download with high priority")
func highPriority() {
let video = makeTestVideo()
let streamURL = URL(string: "https://example.com/stream.mp4")!
let download = Download(
video: video,
quality: "720p",
formatID: "136",
streamURL: streamURL,
priority: .high
)
#expect(download.priority == .high)
}
@Test("Download with auto-delete enabled")
func autoDelete() {
let video = makeTestVideo()
let streamURL = URL(string: "https://example.com/stream.mp4")!
let download = Download(
video: video,
quality: "720p",
formatID: "136",
streamURL: streamURL,
autoDelete: true
)
#expect(download.autoDelete == true)
}
private func makeTestVideo() -> Video {
Video(
id: .global("testDownload"),
title: "Test Download Video",
description: "A video to test downloads",
author: Author(id: "ch1", name: "Test Channel"),
duration: 600,
publishedAt: Date(),
publishedText: "1 day ago",
viewCount: 10000,
likeCount: 500,
thumbnails: [
Thumbnail(url: URL(string: "https://example.com/thumb.jpg")!, quality: .high)
],
isLive: false,
isUpcoming: false,
scheduledStartTime: nil
)
}
}
// MARK: - Download Status Tests
@Suite("Download Status Tests")
struct DownloadStatusTests {
@Test("Status raw values")
func rawValues() {
#expect(DownloadStatus.queued.rawValue == "queued")
#expect(DownloadStatus.downloading.rawValue == "downloading")
#expect(DownloadStatus.paused.rawValue == "paused")
#expect(DownloadStatus.completed.rawValue == "completed")
#expect(DownloadStatus.failed.rawValue == "failed")
}
@Test("Status is Codable")
func codable() throws {
for status in [DownloadStatus.queued, .downloading, .paused, .completed, .failed] {
let encoded = try JSONEncoder().encode(status)
let decoded = try JSONDecoder().decode(DownloadStatus.self, from: encoded)
#expect(status == decoded)
}
}
}
// MARK: - Download Priority Tests
@Suite("Download Priority Tests")
struct DownloadPriorityTests {
@Test("Priority ordering")
func ordering() {
#expect(DownloadPriority.low.rawValue < DownloadPriority.normal.rawValue)
#expect(DownloadPriority.normal.rawValue < DownloadPriority.high.rawValue)
}
@Test("Priority is Codable")
func codable() throws {
for priority in [DownloadPriority.low, .normal, .high] {
let encoded = try JSONEncoder().encode(priority)
let decoded = try JSONDecoder().decode(DownloadPriority.self, from: encoded)
#expect(priority == decoded)
}
}
}
// MARK: - DownloadSortOption Tests
@Suite("DownloadSortOption Tests")
struct DownloadSortOptionTests {
@Test("All cases exist")
func allCases() {
let cases = DownloadSortOption.allCases
#expect(cases.contains(.name))
#expect(cases.contains(.downloadDate))
#expect(cases.contains(.fileSize))
#expect(cases.count == 3)
}
@Test("Display names are not empty")
func displayNames() {
for option in DownloadSortOption.allCases {
#expect(!option.displayName.isEmpty)
}
}
@Test("System images are valid SF Symbols")
func systemImages() {
#expect(DownloadSortOption.name.systemImage == "textformat")
#expect(DownloadSortOption.downloadDate.systemImage == "calendar")
#expect(DownloadSortOption.fileSize.systemImage == "internaldrive")
}
@Test("Is Codable")
func codable() throws {
for option in DownloadSortOption.allCases {
let encoded = try JSONEncoder().encode(option)
let decoded = try JSONDecoder().decode(DownloadSortOption.self, from: encoded)
#expect(option == decoded)
}
}
}
// MARK: - SortDirection Tests
@Suite("SortDirection Tests")
struct SortDirectionTests {
@Test("All cases exist")
func allCases() {
let cases = SortDirection.allCases
#expect(cases.contains(.ascending))
#expect(cases.contains(.descending))
#expect(cases.count == 2)
}
@Test("System images are valid")
func systemImages() {
#expect(SortDirection.ascending.systemImage == "arrow.up")
#expect(SortDirection.descending.systemImage == "arrow.down")
}
@Test("Toggle switches direction")
func toggle() {
var direction = SortDirection.ascending
direction.toggle()
#expect(direction == .descending)
direction.toggle()
#expect(direction == .ascending)
}
@Test("Is Codable")
func codable() throws {
for direction in SortDirection.allCases {
let encoded = try JSONEncoder().encode(direction)
let decoded = try JSONDecoder().decode(SortDirection.self, from: encoded)
#expect(direction == decoded)
}
}
}
// MARK: - DownloadSettings Tests
@Suite("DownloadSettings Tests")
@MainActor
struct DownloadSettingsTests {
@Test("Default sort option is downloadDate")
func defaultSortOption() {
// Clear existing defaults
UserDefaults.standard.removeObject(forKey: "downloads.sortOption")
let settings = DownloadSettings()
#expect(settings.sortOption == .downloadDate)
}
@Test("Default sort direction is descending")
func defaultSortDirection() {
// Clear existing defaults
UserDefaults.standard.removeObject(forKey: "downloads.sortDirection")
let settings = DownloadSettings()
#expect(settings.sortDirection == .descending)
}
@Test("Default groupByChannel is false")
func defaultGroupByChannel() {
// Clear existing defaults
UserDefaults.standard.removeObject(forKey: "downloads.groupByChannel")
let settings = DownloadSettings()
#expect(settings.groupByChannel == false)
}
@Test("Sort option persists")
func sortOptionPersists() {
UserDefaults.standard.removeObject(forKey: "downloads.sortOption")
let settings = DownloadSettings()
settings.sortOption = .name
// Create new instance to check persistence
let settings2 = DownloadSettings()
#expect(settings2.sortOption == .name)
}
@Test("Sort direction persists")
func sortDirectionPersists() {
UserDefaults.standard.removeObject(forKey: "downloads.sortDirection")
let settings = DownloadSettings()
settings.sortDirection = .ascending
let settings2 = DownloadSettings()
#expect(settings2.sortDirection == .ascending)
}
@Test("GroupByChannel persists")
func groupByChannelPersists() {
UserDefaults.standard.removeObject(forKey: "downloads.groupByChannel")
let settings = DownloadSettings()
settings.groupByChannel = true
let settings2 = DownloadSettings()
#expect(settings2.groupByChannel == true)
}
}
// MARK: - Download Error Tests
@Suite("Download Error Tests")
struct DownloadErrorTests {
@Test("Error descriptions are meaningful")
func errorDescriptions() {
let notSupported = DownloadError.notSupported
#expect(notSupported.errorDescription?.contains("not supported") == true)
let alreadyDownloading = DownloadError.alreadyDownloading
#expect(alreadyDownloading.errorDescription?.contains("already downloading") == true)
let alreadyDownloaded = DownloadError.alreadyDownloaded
#expect(alreadyDownloaded.errorDescription?.contains("already been downloaded") == true)
let noStream = DownloadError.noStreamAvailable
#expect(noStream.errorDescription?.contains("stream") == true)
let failed = DownloadError.downloadFailed("Network timeout")
#expect(failed.errorDescription?.contains("Network timeout") == true)
}
}
// MARK: - DownloadManager Tests
@Suite("DownloadManager Tests")
@MainActor
struct DownloadManagerTests {
init() {
// Clear UserDefaults for clean test state
UserDefaults.standard.removeObject(forKey: "activeDownloads")
UserDefaults.standard.removeObject(forKey: "completedDownloads")
}
@Test("Initial state with clean defaults")
func initialState() {
// Clear state before test
UserDefaults.standard.removeObject(forKey: "activeDownloads")
UserDefaults.standard.removeObject(forKey: "completedDownloads")
let manager = DownloadManager()
#expect(manager.activeDownloads.isEmpty)
#expect(manager.completedDownloads.isEmpty)
#expect(manager.storageUsed == 0)
#expect(manager.maxConcurrentDownloads == 2)
}
@Test("isDownloaded returns false for unknown video")
func isDownloadedUnknown() {
let manager = DownloadManager()
let videoID = VideoID.global("unknown")
#expect(manager.isDownloaded(videoID) == false)
}
@Test("isDownloading returns false for unknown video")
func isDownloadingUnknown() {
let manager = DownloadManager()
let videoID = VideoID.global("unknown")
#expect(manager.isDownloading(videoID) == false)
}
@Test("download(for:) returns nil for unknown video")
func downloadForUnknown() {
let manager = DownloadManager()
let videoID = VideoID.global("unknown")
#expect(manager.download(for: videoID) == nil)
}
@Test("localURL returns nil for unknown video")
func localURLUnknown() {
let manager = DownloadManager()
let videoID = VideoID.global("unknown")
#expect(manager.localURL(for: videoID) == nil)
}
@Test("Available storage returns non-negative value")
func availableStorage() {
let manager = DownloadManager()
let available = manager.getAvailableStorage()
#expect(available >= 0)
}
#if !os(tvOS)
@Test("Enqueue prevents duplicate downloads")
func enqueueDuplicatePrevention() async throws {
// Clear state before test
UserDefaults.standard.removeObject(forKey: "activeDownloads")
UserDefaults.standard.removeObject(forKey: "completedDownloads")
let manager = DownloadManager()
let video = makeTestVideo(id: "duplicate")
let streamURL = URL(string: "https://example.com/video.mp4")!
// First enqueue should succeed
try await manager.enqueue(video, quality: "720p", formatID: "136", streamURL: streamURL)
#expect(manager.activeDownloads.count == 1)
// Second enqueue should throw alreadyDownloading
do {
try await manager.enqueue(video, quality: "720p", formatID: "136", streamURL: streamURL)
Issue.record("Expected alreadyDownloading error")
} catch DownloadError.alreadyDownloading {
// Expected
} catch {
Issue.record("Unexpected error: \(error)")
}
#expect(manager.activeDownloads.count == 1)
}
@Test("Cancel removes download from queue")
func cancelRemovesDownload() async throws {
// Clear state before test
UserDefaults.standard.removeObject(forKey: "activeDownloads")
UserDefaults.standard.removeObject(forKey: "completedDownloads")
let manager = DownloadManager()
let video = makeTestVideo(id: "toCancel")
let streamURL = URL(string: "https://example.com/video.mp4")!
try await manager.enqueue(video, quality: "720p", formatID: "136", streamURL: streamURL)
#expect(manager.activeDownloads.count == 1)
let download = manager.activeDownloads.first!
await manager.cancel(download)
#expect(manager.activeDownloads.isEmpty)
}
@Test("Pause changes status")
func pauseChangesStatus() async throws {
// Clear state before test
UserDefaults.standard.removeObject(forKey: "activeDownloads")
UserDefaults.standard.removeObject(forKey: "completedDownloads")
let manager = DownloadManager()
let video = makeTestVideo(id: "toPause")
let streamURL = URL(string: "https://example.com/video.mp4")!
try await manager.enqueue(video, quality: "720p", formatID: "136", streamURL: streamURL)
let download = manager.activeDownloads.first!
await manager.pause(download)
let updated = manager.activeDownloads.first
#expect(updated?.status == .paused)
}
@Test("Resume changes status from paused to queued")
func resumeChangesStatus() async throws {
// Clear state before test
UserDefaults.standard.removeObject(forKey: "activeDownloads")
UserDefaults.standard.removeObject(forKey: "completedDownloads")
let manager = DownloadManager()
let video = makeTestVideo(id: "toResume")
let streamURL = URL(string: "https://example.com/video.mp4")!
try await manager.enqueue(video, quality: "720p", formatID: "136", streamURL: streamURL)
let download = manager.activeDownloads.first!
await manager.pause(download)
#expect(manager.activeDownloads.first?.status == .paused)
let pausedDownload = manager.activeDownloads.first!
await manager.resume(pausedDownload)
#expect(manager.activeDownloads.first?.status == .queued || manager.activeDownloads.first?.status == .downloading)
}
@Test("Move in queue reorders downloads")
func moveInQueue() async throws {
// Clear state before test
UserDefaults.standard.removeObject(forKey: "activeDownloads")
UserDefaults.standard.removeObject(forKey: "completedDownloads")
let manager = DownloadManager()
// Enqueue multiple downloads
for i in 1...3 {
let video = makeTestVideo(id: "move\(i)")
let streamURL = URL(string: "https://example.com/video\(i).mp4")!
try await manager.enqueue(video, quality: "720p", formatID: "136", streamURL: streamURL)
}
#expect(manager.activeDownloads.count == 3)
// Move last to first
let lastDownload = manager.activeDownloads.last!
await manager.moveInQueue(lastDownload, to: 0)
#expect(manager.activeDownloads.first?.videoID.videoID == "move3")
}
#endif
private func makeTestVideo(id: String) -> Video {
Video(
id: .global(id),
title: "Test Video \(id)",
description: nil,
author: Author(id: "ch1", name: "Channel"),
duration: 300,
publishedAt: nil,
publishedText: nil,
viewCount: nil,
likeCount: nil,
thumbnails: [],
isLive: false,
isUpcoming: false,
scheduledStartTime: nil
)
}
}

View File

@@ -0,0 +1,207 @@
//
// GestureSettingsTests.swift
// YatteeTests
//
// Tests for player gesture settings models.
//
import Foundation
import Testing
@testable import Yattee
@Suite("Gesture Settings Tests")
struct GestureSettingsTests {
// MARK: - TapZoneLayout Tests
@Suite("TapZoneLayout")
struct TapZoneLayoutTests {
@Test("Zone count matches layout")
func zoneCountMatchesLayout() {
#expect(TapZoneLayout.single.zoneCount == 1)
#expect(TapZoneLayout.horizontalSplit.zoneCount == 2)
#expect(TapZoneLayout.verticalSplit.zoneCount == 2)
#expect(TapZoneLayout.threeColumns.zoneCount == 3)
#expect(TapZoneLayout.quadrants.zoneCount == 4)
}
@Test("Positions match zone count")
func positionsMatchZoneCount() {
for layout in TapZoneLayout.allCases {
#expect(layout.positions.count == layout.zoneCount)
}
}
@Test("Layout is codable")
func layoutIsCodable() throws {
let original = TapZoneLayout.quadrants
let encoded = try JSONEncoder().encode(original)
let decoded = try JSONDecoder().decode(TapZoneLayout.self, from: encoded)
#expect(decoded == original)
}
}
// MARK: - TapGestureAction Tests
@Suite("TapGestureAction")
struct TapGestureActionTests {
@Test("Seek action has correct seconds")
func seekActionHasCorrectSeconds() {
let forward = TapGestureAction.seekForward(seconds: 15)
let backward = TapGestureAction.seekBackward(seconds: 30)
#expect(forward.seekSeconds == 15)
#expect(backward.seekSeconds == 30)
#expect(TapGestureAction.togglePlayPause.seekSeconds == nil)
}
@Test("Action type conversion preserves seconds")
func actionTypeConversionPreservesSeconds() {
let actionType = TapGestureActionType.seekForward
let action = actionType.toAction(seconds: 25)
if case .seekForward(let seconds) = action {
#expect(seconds == 25)
} else {
Issue.record("Expected seekForward action")
}
}
@Test("All action types have display names")
func allActionTypesHaveDisplayNames() {
for actionType in TapGestureActionType.allCases {
#expect(!actionType.displayName.isEmpty)
#expect(!actionType.systemImage.isEmpty)
}
}
@Test("Action is codable with associated values")
func actionIsCodableWithAssociatedValues() throws {
let actions: [TapGestureAction] = [
.togglePlayPause,
.seekForward(seconds: 10),
.seekBackward(seconds: 30),
.toggleFullscreen
]
for original in actions {
let encoded = try JSONEncoder().encode(original)
let decoded = try JSONDecoder().decode(TapGestureAction.self, from: encoded)
#expect(decoded == original)
}
}
}
// MARK: - TapGesturesSettings Tests
@Suite("TapGesturesSettings")
struct TapGesturesSettingsTests {
@Test("Default settings are disabled")
func defaultSettingsAreDisabled() {
let settings = TapGesturesSettings.default
#expect(settings.isEnabled == false)
}
@Test("Default layout is horizontal split")
func defaultLayoutIsHorizontalSplit() {
let settings = TapGesturesSettings.default
#expect(settings.layout == .horizontalSplit)
}
@Test("Default configurations match layout positions")
func defaultConfigurationsMatchLayoutPositions() {
for layout in TapZoneLayout.allCases {
let configs = TapGesturesSettings.defaultConfigurations(for: layout)
#expect(configs.count == layout.zoneCount)
let configPositions = Set(configs.map(\.position))
let layoutPositions = Set(layout.positions)
#expect(configPositions == layoutPositions)
}
}
@Test("Double tap interval has valid range")
func doubleTapIntervalHasValidRange() {
let range = TapGesturesSettings.doubleTapIntervalRange
#expect(range.lowerBound == 150)
#expect(range.upperBound == 600)
}
@Test("With layout creates new configurations")
func withLayoutCreatesNewConfigurations() {
var settings = TapGesturesSettings(layout: .single)
#expect(settings.zoneConfigurations.count == 1)
settings = settings.withLayout(.quadrants)
#expect(settings.layout == .quadrants)
#expect(settings.zoneConfigurations.count == 4)
}
}
// MARK: - GesturesSettings Tests
@Suite("GesturesSettings")
struct GesturesSettingsTests {
@Test("Default settings have panscan gesture enabled")
func defaultSettingsHavePanscanEnabled() {
let settings = GesturesSettings.default
// Panscan is enabled by default
#expect(settings.hasActiveGestures == true)
#expect(settings.isPanscanGestureActive == true)
#expect(settings.areTapGesturesActive == false)
#expect(settings.isSeekGestureActive == false)
}
@Test("Has active gestures when tap gestures enabled")
func hasActiveGesturesWhenTapEnabled() {
// All gestures disabled
let disabled = GesturesSettings(
tapGestures: TapGesturesSettings(isEnabled: false),
seekGesture: SeekGestureSettings(isEnabled: false),
panscanGesture: PanscanGestureSettings(isEnabled: false)
)
#expect(disabled.hasActiveGestures == false)
#expect(disabled.areTapGesturesActive == false)
// Tap enabled
let enabled = GesturesSettings(
tapGestures: TapGesturesSettings(isEnabled: true),
seekGesture: SeekGestureSettings(isEnabled: false),
panscanGesture: PanscanGestureSettings(isEnabled: false)
)
#expect(enabled.hasActiveGestures == true)
#expect(enabled.areTapGesturesActive == true)
}
}
// MARK: - Serialization Tests
@Suite("Serialization")
struct SerializationTests {
@Test("GesturesSettings round-trips through JSON")
func gesturesSettingsRoundTrips() throws {
let original = GesturesSettings(
tapGestures: TapGesturesSettings(
isEnabled: true,
layout: .quadrants,
doubleTapInterval: 250
)
)
let encoder = JSONEncoder()
let decoder = JSONDecoder()
let encoded = try encoder.encode(original)
let decoded = try decoder.decode(GesturesSettings.self, from: encoded)
#expect(decoded.tapGestures.isEnabled == original.tapGestures.isEnabled)
#expect(decoded.tapGestures.layout == original.tapGestures.layout)
#expect(decoded.tapGestures.doubleTapInterval == original.tapGestures.doubleTapInterval)
}
}
}

View File

@@ -0,0 +1,149 @@
//
// InstancesManagerTests.swift
// YatteeTests
//
// Tests for the InstancesManager.
//
import Testing
import Foundation
@testable import Yattee
// MARK: - Instance Filtering Tests
@Suite("Instance Filtering Tests")
@MainActor
struct InstanceFilteringTests {
@Test("Filter YouTube instances")
func filterYouTubeInstances() {
let instances = [
Instance(type: .invidious, url: URL(string: "https://inv.example.com")!),
Instance(type: .piped, url: URL(string: "https://piped.example.com")!),
Instance(type: .peertube, url: URL(string: "https://pt.example.com")!),
]
let youtubeInstances = instances.filter(\.isYouTubeInstance)
#expect(youtubeInstances.count == 2)
#expect(youtubeInstances.allSatisfy { $0.type == .invidious || $0.type == .piped })
}
@Test("Filter PeerTube instances")
func filterPeerTubeInstances() {
let instances = [
Instance(type: .invidious, url: URL(string: "https://inv.example.com")!),
Instance(type: .peertube, url: URL(string: "https://pt1.example.com")!),
Instance(type: .peertube, url: URL(string: "https://pt2.example.com")!),
]
let peertubeInstances = instances.filter(\.isPeerTubeInstance)
#expect(peertubeInstances.count == 2)
#expect(peertubeInstances.allSatisfy { $0.type == .peertube })
}
@Test("Filter enabled instances")
func filterEnabledInstances() {
var enabled = Instance(type: .invidious, url: URL(string: "https://enabled.example.com")!)
enabled.isEnabled = true
var disabled = Instance(type: .invidious, url: URL(string: "https://disabled.example.com")!)
disabled.isEnabled = false
let instances = [enabled, disabled]
let enabledInstances = instances.filter(\.isEnabled)
#expect(enabledInstances.count == 1)
#expect(enabledInstances.first?.url.host == "enabled.example.com")
}
}
// MARK: - Instance Type Tests
@Suite("InstanceType Tests")
@MainActor
struct InstanceTypeTests {
@Test("InstanceType display names")
func displayNames() {
#expect(InstanceType.invidious.displayName == "Invidious")
#expect(InstanceType.piped.displayName == "Piped")
#expect(InstanceType.peertube.displayName == "PeerTube")
}
@Test("InstanceType is Codable")
func codable() throws {
for type in InstanceType.allCases {
let encoded = try JSONEncoder().encode(type)
let decoded = try JSONDecoder().decode(InstanceType.self, from: encoded)
#expect(type == decoded)
}
}
@Test("Instance is Codable")
func instanceCodable() throws {
let instance = Instance(
type: .invidious,
url: URL(string: "https://example.com")!,
name: "Test Instance"
)
let encoded = try JSONEncoder().encode(instance)
let decoded = try JSONDecoder().decode(Instance.self, from: encoded)
#expect(instance.type == decoded.type)
#expect(instance.url == decoded.url)
#expect(instance.name == decoded.name)
}
@Test("Instance array is Codable")
func instanceArrayCodable() throws {
let instances = [
Instance(type: .invidious, url: URL(string: "https://inv.example.com")!),
Instance(type: .piped, url: URL(string: "https://piped.example.com")!),
Instance(type: .peertube, url: URL(string: "https://pt.example.com")!, name: "My PeerTube"),
]
let encoded = try JSONEncoder().encode(instances)
let decoded = try JSONDecoder().decode([Instance].self, from: encoded)
#expect(decoded.count == 3)
#expect(decoded[0].type == .invidious)
#expect(decoded[1].type == .piped)
#expect(decoded[2].name == "My PeerTube")
}
}
// MARK: - Instance Identity Tests
@Suite("Instance Identity Tests")
@MainActor
struct InstanceIdentityTests {
@Test("Instances with same UUID are equal")
func sameUUIDEqual() {
let sharedID = UUID()
let url = URL(string: "https://example.com")!
let instance1 = Instance(id: sharedID, type: .invidious, url: url)
let instance2 = Instance(id: sharedID, type: .invidious, url: url)
#expect(instance1.id == instance2.id)
}
@Test("New instances have unique IDs")
func newInstancesHaveUniqueIDs() {
let instance1 = Instance(type: .invidious, url: URL(string: "https://example.com")!)
let instance2 = Instance(type: .invidious, url: URL(string: "https://example.com")!)
// Each new instance gets a unique UUID
#expect(instance1.id != instance2.id)
}
@Test("Instance ID is stable across name changes")
func idStableAcrossNameChanges() {
var instance = Instance(type: .invidious, url: URL(string: "https://example.com")!)
let originalID = instance.id
instance.name = "New Name"
#expect(instance.id == originalID)
}
}

View File

@@ -0,0 +1,351 @@
//
// InvidiousAPIIntegrationTests.swift
// YatteeTests
//
// Integration tests for InvidiousAPI against a real instance.
// These tests make actual network requests and validate response parsing.
//
import Testing
import Foundation
@testable import Yattee
// MARK: - Integration Test Tag
extension Tag {
/// Tag for integration tests that require network access.
@Tag static var integration: Self
}
// MARK: - Invidious API Integration Tests
@Suite("Invidious API Integration Tests", .tags(.integration), .serialized)
struct InvidiousAPIIntegrationTests {
let api: InvidiousAPI
let instance: Instance
init() {
let httpClient = HTTPClient()
self.api = InvidiousAPI(httpClient: httpClient)
self.instance = IntegrationTestConstants.testInstance
}
// MARK: - Trending Tests
@Test("Trending returns videos or handles unavailable")
func trendingReturnsVideos() async throws {
do {
let videos = try await api.trending(instance: instance)
// If trending is available, it should return videos
if !videos.isEmpty {
#expect(videos.count >= 1, "Trending should return at least one video")
}
} catch {
// Trending may not be enabled on all instances - skip gracefully
// This is acceptable for integration tests
}
}
@Test("Trending videos have required fields when available")
func trendingVideosHaveRequiredFields() async throws {
do {
let videos = try await api.trending(instance: instance)
guard let video = videos.first else {
// No videos is acceptable
return
}
#expect(!video.id.videoID.isEmpty, "Video should have an ID")
#expect(!video.title.isEmpty, "Video should have a title")
#expect(!video.author.name.isEmpty, "Video should have an author name")
#expect(video.duration >= 0, "Video should have non-negative duration")
} catch {
// Trending may not be available
}
}
@Test("Trending videos have thumbnails when available")
func trendingVideosHaveThumbnails() async throws {
do {
let videos = try await api.trending(instance: instance)
guard let video = videos.first else {
return
}
#expect(!video.thumbnails.isEmpty, "Video should have thumbnails")
#expect(video.bestThumbnail != nil, "Video should have a best thumbnail")
} catch {
// Trending may not be available
}
}
// MARK: - Search Tests
@Test("Search returns results")
func searchReturnsResults() async throws {
let result = try await api.search(
query: IntegrationTestConstants.testSearchQuery,
instance: instance,
page: 1
)
#expect(!result.videos.isEmpty, "Search should return videos")
}
@Test("Search videos have required fields")
func searchVideosHaveRequiredFields() async throws {
let result = try await api.search(
query: IntegrationTestConstants.testSearchQuery,
instance: instance,
page: 1
)
guard let video = result.videos.first else {
Issue.record("No videos returned from search")
return
}
#expect(!video.id.videoID.isEmpty, "Search video should have an ID")
#expect(!video.title.isEmpty, "Search video should have a title")
}
@Test("Search pagination works")
func searchPaginationWorks() async throws {
let page1 = try await api.search(
query: IntegrationTestConstants.testSearchQuery,
instance: instance,
page: 1
)
#expect(page1.nextPage != nil, "First page should have a next page")
let page2 = try await api.search(
query: IntegrationTestConstants.testSearchQuery,
instance: instance,
page: 2
)
// Page 2 should have different videos (if enough results exist)
if !page1.videos.isEmpty && !page2.videos.isEmpty {
let page1IDs = Set(page1.videos.map { $0.id.videoID })
let page2IDs = Set(page2.videos.map { $0.id.videoID })
let overlap = page1IDs.intersection(page2IDs)
// Allow some overlap but not complete overlap
#expect(overlap.count < page1.videos.count, "Page 2 should have different videos than page 1")
}
}
@Test("Search suggestions returns strings")
func searchSuggestionsReturnsStrings() async throws {
let suggestions = try await api.searchSuggestions(
query: "never gonna",
instance: instance
)
#expect(!suggestions.isEmpty, "Search suggestions should return results")
#expect(suggestions.contains { $0.lowercased().contains("never") }, "Suggestions should be relevant to query")
}
// MARK: - Video Details Tests
@Test("Video details returns complete info")
func videoDetailsReturnsCompleteInfo() async throws {
let video = try await api.video(
id: IntegrationTestConstants.testVideoID,
instance: instance
)
#expect(video.id.videoID == IntegrationTestConstants.testVideoID, "Should return correct video")
#expect(!video.title.isEmpty, "Video should have a title")
#expect(!video.author.name.isEmpty, "Video should have an author")
#expect(video.duration > 0, "Video should have positive duration")
#expect(video.viewCount ?? 0 > 0, "Popular video should have views")
}
@Test("Video details includes thumbnails")
func videoDetailsIncludesThumbnails() async throws {
let video = try await api.video(
id: IntegrationTestConstants.testVideoID,
instance: instance
)
#expect(!video.thumbnails.isEmpty, "Video should have thumbnails")
let thumbnail = video.thumbnails.first!
#expect(thumbnail.url.absoluteString.contains("http"), "Thumbnail should have valid URL")
}
@Test("Video details includes author info")
func videoDetailsIncludesAuthorInfo() async throws {
let video = try await api.video(
id: IntegrationTestConstants.testVideoID,
instance: instance
)
#expect(!video.author.id.isEmpty, "Author should have an ID")
#expect(!video.author.name.isEmpty, "Author should have a name")
}
// MARK: - Streams Tests
@Test("Streams includes HLS when available")
func streamsIncludesHLS() async throws {
let streams = try await api.streams(
videoID: IntegrationTestConstants.testVideoID,
instance: instance
)
// HLS may not be available on all instances
let hlsStream = streams.first { $0.format == "hls" }
if let hls = hlsStream {
#expect(hls.mimeType == "application/x-mpegURL", "HLS should have correct MIME type")
}
// Test passes whether HLS is available or not
}
@Test("Streams includes multiple formats")
func streamsIncludesMultipleFormats() async throws {
let streams = try await api.streams(
videoID: IntegrationTestConstants.testVideoID,
instance: instance
)
#expect(streams.count > 1, "Should have multiple streams")
let formats = Set(streams.map { $0.format })
#expect(formats.count > 1, "Should have multiple formats")
}
@Test("Streams includes video resolutions")
func streamsIncludesVideoResolutions() async throws {
let streams = try await api.streams(
videoID: IntegrationTestConstants.testVideoID,
instance: instance
)
let videoStreams = streams.filter { $0.resolution != nil && !$0.isAudioOnly }
#expect(!videoStreams.isEmpty, "Should have video streams with resolutions")
let hasHD = videoStreams.contains { ($0.resolution?.height ?? 0) >= 720 }
#expect(hasHD, "Popular video should have HD streams")
}
@Test("Streams includes audio-only tracks")
func streamsIncludesAudioOnlyTracks() async throws {
let streams = try await api.streams(
videoID: IntegrationTestConstants.testVideoID,
instance: instance
)
let audioStreams = streams.filter { $0.isAudioOnly }
#expect(!audioStreams.isEmpty, "Should have audio-only streams")
}
// MARK: - Channel Tests
@Test("Channel returns info")
func channelReturnsInfo() async throws {
let channel = try await api.channel(
id: IntegrationTestConstants.testChannelID,
instance: instance
)
#expect(channel.id.channelID == IntegrationTestConstants.testChannelID, "Should return correct channel")
#expect(!channel.name.isEmpty, "Channel should have a name")
#expect(channel.subscriberCount ?? 0 > 0, "Popular channel should have subscribers")
}
@Test("Channel includes thumbnail")
func channelIncludesThumbnail() async throws {
let channel = try await api.channel(
id: IntegrationTestConstants.testChannelID,
instance: instance
)
#expect(channel.thumbnailURL != nil, "Channel should have thumbnail URL")
}
@Test("Channel videos returns videos")
func channelVideosReturnsVideos() async throws {
let page = try await api.channelVideos(
id: IntegrationTestConstants.testChannelID,
instance: instance,
continuation: nil
)
#expect(!page.videos.isEmpty, "Channel should have videos")
let video = page.videos.first!
#expect(!video.title.isEmpty, "Channel video should have title")
}
// MARK: - Comments Tests
@Test("Comments returns results or handles disabled")
func commentsReturnsResultsOrHandlesDisabled() async throws {
do {
let page = try await api.comments(
videoID: IntegrationTestConstants.testVideoID,
instance: instance,
continuation: nil
)
// If we get here, comments are enabled
#expect(!page.comments.isEmpty, "Video with comments should return some")
let comment = page.comments.first!
#expect(!comment.id.isEmpty, "Comment should have ID")
#expect(!comment.content.isEmpty, "Comment should have content")
#expect(!comment.author.name.isEmpty, "Comment should have author")
} catch APIError.commentsDisabled {
// Comments disabled is acceptable - test passes
} catch {
throw error
}
}
// MARK: - Captions Tests
@Test("Captions returns available tracks")
func captionsReturnsAvailableTracks() async throws {
let captions = try await api.captions(
videoID: IntegrationTestConstants.testVideoID,
instance: instance
)
// Popular video should have captions, but it's not guaranteed
if !captions.isEmpty {
let caption = captions.first!
#expect(!caption.label.isEmpty, "Caption should have label")
#expect(!caption.languageCode.isEmpty, "Caption should have language code")
#expect(caption.url.absoluteString.contains("http"), "Caption should have valid URL")
}
}
// MARK: - Error Handling Tests
@Test("Invalid video ID returns error")
func invalidVideoIDReturnsError() async throws {
do {
_ = try await api.video(id: "invalid_video_id_that_does_not_exist", instance: instance)
Issue.record("Expected error for invalid video ID")
} catch {
// Any error is acceptable - notFound, requestFailed, etc.
// Different instances may return different error codes
}
}
@Test("Invalid channel ID returns error")
func invalidChannelIDReturnsError() async throws {
do {
_ = try await api.channel(id: "invalid_channel_id_xyz", instance: instance)
Issue.record("Expected error for invalid channel ID")
} catch {
// Any error is acceptable - notFound, requestFailed, etc.
// Different instances may return different error codes
}
}
}

View File

@@ -0,0 +1,37 @@
//
// TestConstants.swift
// YatteeTests
//
// Constants for integration tests.
//
import Foundation
@testable import Yattee
/// Constants for integration testing against a real Invidious instance.
enum IntegrationTestConstants {
/// Test Invidious instance URL (from CLAUDE.md).
static let testInstanceURL = URL(string: "https://invidious.home.arekf.net")!
/// Test instance for API calls.
static let testInstance = Instance(
type: .invidious,
url: testInstanceURL,
name: "Test Instance"
)
/// A stable, popular video ID for testing (Rick Astley - Never Gonna Give You Up).
static let testVideoID = "dQw4w9WgXcQ"
/// Rick Astley's channel ID.
static let testChannelID = "UCuAXFkgsw1L7xaCfnd5JJOw"
/// A popular music playlist ID.
static let testPlaylistID = "PLrAXtmErZgOeiKm4sgNOknGvNjby9efdf"
/// A stable search query.
static let testSearchQuery = "never gonna give you up"
/// Timeout for network requests (30 seconds).
static let networkTimeout: TimeInterval = 30
}

View File

@@ -0,0 +1,652 @@
//
// ModelTests.swift
// YatteeTests
//
// Tests for core model types.
//
import Testing
import Foundation
@testable import Yattee
// MARK: - ContentSource Tests
@Suite("ContentSource Tests")
@MainActor
struct ContentSourceTests {
@Test("Global source equality")
func globalEquality() {
let source1 = ContentSource.global(provider: ContentSource.youtubeProvider)
let source2 = ContentSource.global(provider: ContentSource.youtubeProvider)
#expect(source1 == source2)
}
@Test("Federated source with same instance are equal")
func federatedEqualitySameInstance() {
let url = URL(string: "https://peertube.example.com")!
let source1 = ContentSource.federated(provider: ContentSource.peertubeProvider, instance: url)
let source2 = ContentSource.federated(provider: ContentSource.peertubeProvider, instance: url)
#expect(source1 == source2)
}
@Test("Federated sources with different instances are not equal")
func federatedEqualityDifferentInstances() {
let url1 = URL(string: "https://peertube1.example.com")!
let url2 = URL(string: "https://peertube2.example.com")!
let source1 = ContentSource.federated(provider: ContentSource.peertubeProvider, instance: url1)
let source2 = ContentSource.federated(provider: ContentSource.peertubeProvider, instance: url2)
#expect(source1 != source2)
}
@Test("Global and Federated are not equal")
func globalNotEqualToFederated() {
let global = ContentSource.global(provider: ContentSource.youtubeProvider)
let federated = ContentSource.federated(provider: ContentSource.peertubeProvider, instance: URL(string: "https://example.com")!)
#expect(global != federated)
}
@Test("ContentSource display names")
func displayNames() {
#expect(ContentSource.global(provider: ContentSource.youtubeProvider).displayName == "YouTube")
let peertubeURL = URL(string: "https://framatube.org")!
#expect(ContentSource.federated(provider: ContentSource.peertubeProvider, instance: peertubeURL).displayName == "framatube.org")
}
@Test("ContentSource short names")
func shortNames() {
#expect(ContentSource.global(provider: ContentSource.youtubeProvider).shortName == "YT")
let peertubeURL = URL(string: "https://framatube.org")!
#expect(ContentSource.federated(provider: ContentSource.peertubeProvider, instance: peertubeURL).shortName == "framatub")
}
@Test("ContentSource is Codable")
func codable() throws {
let sources: [ContentSource] = [
.global(provider: ContentSource.youtubeProvider),
.federated(provider: ContentSource.peertubeProvider, instance: URL(string: "https://example.com")!)
]
for source in sources {
let encoded = try JSONEncoder().encode(source)
let decoded = try JSONDecoder().decode(ContentSource.self, from: encoded)
#expect(source == decoded)
}
}
@Test("ContentSource sorting - Global comes before Federated")
func sorting() {
let global = ContentSource.global(provider: ContentSource.youtubeProvider)
let federated = ContentSource.federated(provider: ContentSource.peertubeProvider, instance: URL(string: "https://example.com")!)
#expect(global < federated)
#expect(!(federated < global))
}
}
// MARK: - VideoID Tests
@Suite("VideoID Tests")
@MainActor
struct VideoIDTests {
@Test("Global VideoID creation")
func globalCreation() {
let videoID = VideoID.global("dQw4w9WgXcQ")
#expect(videoID.videoID == "dQw4w9WgXcQ")
if case .global(let provider) = videoID.source {
#expect(provider == ContentSource.youtubeProvider)
} else {
Issue.record("Expected global source")
}
#expect(videoID.uuid == nil)
}
@Test("Federated VideoID creation")
func federatedCreation() {
let instance = URL(string: "https://framatube.org")!
let videoID = VideoID.federated("123", instance: instance, uuid: "abc-def")
#expect(videoID.videoID == "123")
if case .federated(let provider, let url) = videoID.source {
#expect(provider == ContentSource.peertubeProvider)
#expect(url == instance)
} else {
Issue.record("Expected federated source")
}
#expect(videoID.uuid == "abc-def")
}
@Test("VideoID identifiable ID format")
func identifiableID() {
let ytID = VideoID.global("abc123")
#expect(ytID.id == "global:youtube:abc123")
let ptID = VideoID.federated("456", instance: URL(string: "https://example.com")!)
#expect(ptID.id == "federated:peertube:example.com:456")
}
}
// MARK: - Video Tests
@Suite("Video Tests")
@MainActor
struct VideoTests {
@Test("Video formatted duration - minutes and seconds")
func formattedDurationMinutesSeconds() {
let video = makeVideo(duration: 185) // 3:05
#expect(video.formattedDuration == "3:05")
}
@Test("Video formatted duration - hours")
func formattedDurationHours() {
let video = makeVideo(duration: 3725) // 1:02:05
#expect(video.formattedDuration == "1:02:05")
}
@Test("Video formatted duration - live shows LIVE")
func formattedDurationLive() {
let video = makeVideo(duration: 0, isLive: true)
#expect(video.formattedDuration == "LIVE")
}
@Test("Video formatted view count - thousands")
func formattedViewCountThousands() {
let video = makeVideo(viewCount: 1500)
#expect(video.formattedViewCount == "1.5K")
}
@Test("Video formatted view count - millions")
func formattedViewCountMillions() {
let video = makeVideo(viewCount: 2_500_000)
#expect(video.formattedViewCount == "2.5M")
}
@Test("Video formatted view count - exact thousands")
func formattedViewCountExactThousands() {
let video = makeVideo(viewCount: 1000)
#expect(video.formattedViewCount == "1K")
}
@Test("Video best thumbnail returns highest quality")
func bestThumbnail() {
let thumbnails = [
Thumbnail(url: URL(string: "https://example.com/default.jpg")!, quality: .default),
Thumbnail(url: URL(string: "https://example.com/maxres.jpg")!, quality: .maxres),
Thumbnail(url: URL(string: "https://example.com/high.jpg")!, quality: .high),
]
let video = makeVideo(thumbnails: thumbnails)
#expect(video.bestThumbnail?.quality == .maxres)
}
private func makeVideo(
duration: TimeInterval = 100,
isLive: Bool = false,
viewCount: Int? = nil,
thumbnails: [Thumbnail] = []
) -> Video {
Video(
id: .global("test"),
title: "Test Video",
description: nil,
author: Author(id: "channel", name: "Test Channel"),
duration: duration,
publishedAt: nil,
publishedText: nil,
viewCount: viewCount,
likeCount: nil,
thumbnails: thumbnails,
isLive: isLive,
isUpcoming: false,
scheduledStartTime: nil
)
}
}
// MARK: - Instance Tests
@Suite("Instance Tests")
@MainActor
struct InstanceTests {
@Test("Instance URL validation - valid HTTPS")
func validateURLValidHTTPS() {
let url = Instance.validateURL("https://invidious.io")
#expect(url != nil)
#expect(url?.scheme == "https")
}
@Test("Instance URL validation - preserves explicit HTTP for local servers")
func validateURLPreservesHTTP() {
// HTTP is preserved for local/private network servers (e.g., yt-dlp server)
let url = Instance.validateURL("http://invidious.io")
#expect(url?.scheme == "http")
}
@Test("Instance URL validation - adds HTTPS if missing")
func validateURLAddsScheme() {
let url = Instance.validateURL("invidious.io")
#expect(url?.scheme == "https")
}
@Test("Instance URL validation - removes trailing slash")
func validateURLRemovesTrailingSlash() {
let url = Instance.validateURL("https://invidious.io/")
#expect(url?.path == "" || !url!.absoluteString.hasSuffix("/"))
}
@Test("Instance URL validation - handles edge cases")
func validateURLEdgeCases() {
// URLComponents is lenient and encodes spaces
let urlWithSpaces = Instance.validateURL("example with spaces")
#expect(urlWithSpaces != nil) // Gets URL-encoded
// Verifies scheme is added
let simpleHost = Instance.validateURL("invidious.io")
#expect(simpleHost?.scheme == "https")
}
@Test("Instance display name uses custom name if set")
func displayNameCustom() {
let instance = Instance(
type: .invidious,
url: URL(string: "https://invidious.io")!,
name: "My Instance"
)
#expect(instance.displayName == "My Instance")
}
@Test("Instance display name falls back to host")
func displayNameFallback() {
let instance = Instance(
type: .invidious,
url: URL(string: "https://invidious.io")!
)
#expect(instance.displayName == "invidious.io")
}
@Test("Instance isYouTubeInstance for Invidious")
func isYouTubeInstanceInvidious() {
let instance = Instance(type: .invidious, url: URL(string: "https://example.com")!)
#expect(instance.isYouTubeInstance == true)
#expect(instance.isPeerTubeInstance == false)
}
@Test("Instance isYouTubeInstance for Piped")
func isYouTubeInstancePiped() {
let instance = Instance(type: .piped, url: URL(string: "https://example.com")!)
#expect(instance.isYouTubeInstance == true)
#expect(instance.isPeerTubeInstance == false)
}
@Test("Instance isPeerTubeInstance")
func isPeerTubeInstance() {
let instance = Instance(type: .peertube, url: URL(string: "https://example.com")!)
#expect(instance.isYouTubeInstance == false)
#expect(instance.isPeerTubeInstance == true)
}
}
// MARK: - Channel Tests
@Suite("Channel Tests")
@MainActor
struct ChannelTests {
@Test("Channel formatted subscriber count")
func formattedSubscriberCount() {
let channel = Channel(
id: .global("test"),
name: "Test Channel",
subscriberCount: 1_500_000
)
#expect(channel.formattedSubscriberCount == "1.5M")
}
@Test("ChannelID identifiable ID format")
func channelIDFormat() {
let ytID = ChannelID.global("UC123")
#expect(ytID.id == "global:youtube:UC123")
let ptID = ChannelID.federated("channel", instance: URL(string: "https://example.com")!)
#expect(ptID.id == "federated:peertube:example.com:channel")
}
}
// MARK: - Stream Tests
@Suite("Stream Tests")
@MainActor
struct StreamTests {
@Test("StreamResolution comparison")
func resolutionComparison() {
#expect(StreamResolution.p720 < StreamResolution.p1080)
#expect(StreamResolution.p1080 < StreamResolution.p2160)
#expect(!(StreamResolution.p1080 < StreamResolution.p720))
}
@Test("StreamResolution from height label")
func resolutionFromLabel() {
let res720 = StreamResolution(heightLabel: "720p")
#expect(res720?.height == 720)
let res1080 = StreamResolution(heightLabel: "1080")
#expect(res1080?.height == 1080)
}
@Test("Stream quality label for video")
func qualityLabelVideo() {
let stream = Stream(
url: URL(string: "https://example.com/video.mp4")!,
resolution: .p1080,
format: "mp4"
)
#expect(stream.qualityLabel == "1080p")
}
@Test("Stream quality label for audio")
func qualityLabelAudio() {
let stream = Stream(
url: URL(string: "https://example.com/audio.m4a")!,
resolution: nil,
format: "m4a",
isAudioOnly: true
)
#expect(stream.qualityLabel == "Audio")
}
@Test("Stream isNativelyPlayable for MP4 H264")
func nativelyPlayableMp4() {
let stream = Stream(
url: URL(string: "https://example.com/video.mp4")!,
resolution: .p1080,
format: "mp4",
videoCodec: "avc1.4d401f"
)
#expect(stream.isNativelyPlayable == true)
}
@Test("Stream isNativelyPlayable for WebM VP9")
func nativelyPlayableWebm() {
let stream = Stream(
url: URL(string: "https://example.com/video.webm")!,
resolution: .p1080,
format: "webm",
videoCodec: "vp9"
)
#expect(stream.isNativelyPlayable == false)
}
}
// MARK: - Playlist Tests
@Suite("Playlist Tests")
@MainActor
struct PlaylistTests {
@Test("PlaylistID local vs remote")
func playlistIDLocalVsRemote() {
let localID = PlaylistID.local("my-playlist")
#expect(localID.isLocal == true)
#expect(localID.id == "local:my-playlist")
let remoteID = PlaylistID.global("PLtest123")
#expect(remoteID.isLocal == false)
#expect(remoteID.id == "global:youtube:PLtest123")
}
}
// MARK: - Caption Tests
@Suite("Caption Tests")
struct CaptionTests {
@Test("Caption isAutoGenerated detection")
func isAutoGenerated() {
let autoCaption = Caption(
label: "English (auto-generated)",
languageCode: "en",
url: URL(string: "https://example.com/caption.vtt")!
)
#expect(autoCaption.isAutoGenerated == true)
let manualCaption = Caption(
label: "English",
languageCode: "en",
url: URL(string: "https://example.com/caption.vtt")!
)
#expect(manualCaption.isAutoGenerated == false)
}
@Test("Caption baseLanguageCode extracts base code")
func baseLanguageCode() {
let enUS = Caption(
label: "English (US)",
languageCode: "en-US",
url: URL(string: "https://example.com/caption.vtt")!
)
#expect(enUS.baseLanguageCode == "en")
let deDE = Caption(
label: "German",
languageCode: "de-DE",
url: URL(string: "https://example.com/caption.vtt")!
)
#expect(deDE.baseLanguageCode == "de")
let simple = Caption(
label: "French",
languageCode: "fr",
url: URL(string: "https://example.com/caption.vtt")!
)
#expect(simple.baseLanguageCode == "fr")
}
@Test("Caption id is unique")
func captionID() {
let caption = Caption(
label: "English",
languageCode: "en",
url: URL(string: "https://example.com/caption.vtt")!
)
#expect(caption.id == "en:English")
}
@Test("Caption displayName strips auto-generated suffix")
func displayName() {
let autoCaption = Caption(
label: "English (auto-generated)",
languageCode: "en",
url: URL(string: "https://example.com/caption.vtt")!
)
// Should return localized name or stripped label
#expect(!autoCaption.displayName.contains("auto-generated"))
}
@Test("Caption is Codable")
func codable() throws {
let caption = Caption(
label: "Spanish",
languageCode: "es",
url: URL(string: "https://example.com/caption.vtt")!
)
let encoded = try JSONEncoder().encode(caption)
let decoded = try JSONDecoder().decode(Caption.self, from: encoded)
#expect(caption == decoded)
}
@Test("Caption is Hashable")
func hashable() {
let caption1 = Caption(
label: "English",
languageCode: "en",
url: URL(string: "https://example.com/caption1.vtt")!
)
let caption2 = Caption(
label: "English",
languageCode: "en",
url: URL(string: "https://example.com/caption1.vtt")!
)
let caption3 = Caption(
label: "French",
languageCode: "fr",
url: URL(string: "https://example.com/caption2.vtt")!
)
var set = Set<Caption>()
set.insert(caption1)
set.insert(caption2)
set.insert(caption3)
#expect(set.count == 2)
}
}
// MARK: - VideoRowStyle Tests
@Suite("VideoRowStyle Tests")
struct VideoRowStyleTests {
@Test("Large style dimensions")
func largeDimensions() {
let style = VideoRowStyle.large
#expect(style.thumbnailWidth == 160)
#expect(style.thumbnailHeight == 90)
}
@Test("Regular style dimensions")
func regularDimensions() {
let style = VideoRowStyle.regular
#expect(style.thumbnailWidth == 120)
#expect(style.thumbnailHeight == 68)
}
@Test("Compact style dimensions")
func compactDimensions() {
let style = VideoRowStyle.compact
#expect(style.thumbnailWidth == 70)
#expect(style.thumbnailHeight == 39)
}
@Test("Aspect ratios are 16:9")
func aspectRatios() {
for style in [VideoRowStyle.large, .regular, .compact] {
let ratio = style.thumbnailWidth / style.thumbnailHeight
// 16:9 1.77, allow small tolerance
#expect(abs(ratio - 16.0/9.0) < 0.1)
}
}
}
// MARK: - HomeTab Tests
@Suite("HomeTab Tests")
struct HomeTabTests {
@Test("All cases exist")
func allCases() {
let cases = HomeTab.allCases
#expect(cases.contains(.playlists))
#expect(cases.contains(.history))
#expect(cases.contains(.downloads))
#expect(cases.count == 3)
}
@Test("Titles are not empty")
func titles() {
for tab in HomeTab.allCases {
#expect(!tab.title.isEmpty)
}
}
@Test("Icons are valid SF Symbol names")
func icons() {
#expect(HomeTab.playlists.icon == "list.bullet.rectangle")
#expect(HomeTab.history.icon == "clock")
#expect(HomeTab.downloads.icon == "arrow.down.circle")
}
@Test("Identifiable id uses rawValue")
func identifiableID() {
for tab in HomeTab.allCases {
#expect(tab.id == tab.rawValue)
}
}
}
// MARK: - PlayerInfoTab Tests
@Suite("PlayerInfoTab Tests")
struct PlayerInfoTabTests {
@Test("All cases exist")
func allCases() {
let cases = PlayerInfoTab.allCases
#expect(cases.contains(.description))
#expect(cases.contains(.comments))
#expect(cases.count == 2)
}
@Test("Titles are not empty")
func titles() {
for tab in PlayerInfoTab.allCases {
#expect(!tab.title.isEmpty)
}
}
}
// MARK: - SearchResultType Tests
@Suite("SearchResultType Tests")
struct SearchResultTypeTests {
@Test("All cases exist")
func allCases() {
let cases = SearchResultType.allCases
#expect(cases.contains(.all))
#expect(cases.contains(.videos))
#expect(cases.contains(.channels))
#expect(cases.contains(.playlists))
#expect(cases.count == 4)
}
@Test("Titles are not empty")
func titles() {
for type in SearchResultType.allCases {
#expect(!type.title.isEmpty)
}
}
@Test("Identifiable id uses rawValue")
func identifiableID() {
for type in SearchResultType.allCases {
#expect(type.id == type.rawValue)
}
}
}
// MARK: - CommentsLoadState Tests
@Suite("CommentsLoadState Tests")
struct CommentsLoadStateTests {
@Test("All states are equatable")
func equatable() {
#expect(CommentsLoadState.idle == CommentsLoadState.idle)
#expect(CommentsLoadState.loading == CommentsLoadState.loading)
#expect(CommentsLoadState.loaded == CommentsLoadState.loaded)
#expect(CommentsLoadState.loadingMore == CommentsLoadState.loadingMore)
#expect(CommentsLoadState.disabled == CommentsLoadState.disabled)
#expect(CommentsLoadState.error == CommentsLoadState.error)
}
@Test("Different states are not equal")
func notEqual() {
#expect(CommentsLoadState.idle != CommentsLoadState.loading)
#expect(CommentsLoadState.loaded != CommentsLoadState.error)
#expect(CommentsLoadState.loading != CommentsLoadState.loadingMore)
}
}

View File

@@ -0,0 +1,626 @@
//
// NavigationTests.swift
// YatteeTests
//
// Tests for navigation components.
//
import Testing
import Foundation
import SwiftUI
@testable import Yattee
// MARK: - URLRouter Tests
@Suite("URLRouter Tests")
struct URLRouterTests {
let router = URLRouter()
// MARK: - YouTube URL Tests
@Test("Parse standard YouTube watch URL")
func standardWatchURL() {
let url = URL(string: "https://www.youtube.com/watch?v=dQw4w9WgXcQ")!
let destination = router.route(url)
if case .video(let source, _) = destination, case .id(let videoID) = source {
#expect(videoID.videoID == "dQw4w9WgXcQ")
if case .global = videoID.source {
// Expected
} else {
Issue.record("Expected global source")
}
} else {
Issue.record("Expected video destination")
}
}
@Test("Parse YouTube short URL")
func shortURL() {
let url = URL(string: "https://youtu.be/dQw4w9WgXcQ")!
let destination = router.route(url)
if case .video(let source, _) = destination, case .id(let videoID) = source {
#expect(videoID.videoID == "dQw4w9WgXcQ")
} else {
Issue.record("Expected video destination")
}
}
@Test("Parse YouTube embed URL")
func embedURL() {
let url = URL(string: "https://www.youtube.com/embed/dQw4w9WgXcQ")!
let destination = router.route(url)
if case .video(let source, _) = destination, case .id(let videoID) = source {
#expect(videoID.videoID == "dQw4w9WgXcQ")
} else {
Issue.record("Expected video destination")
}
}
@Test("Parse YouTube shorts URL")
func shortsURL() {
let url = URL(string: "https://www.youtube.com/shorts/abc123def")!
let destination = router.route(url)
if case .video(let source, _) = destination, case .id(let videoID) = source {
#expect(videoID.videoID == "abc123def")
} else {
Issue.record("Expected video destination")
}
}
@Test("Parse YouTube watch URL with timestamp")
func watchURLWithTimestamp() {
let url = URL(string: "https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=120")!
let destination = router.route(url)
if case .video(let source, _) = destination, case .id(let videoID) = source {
#expect(videoID.videoID == "dQw4w9WgXcQ")
} else {
Issue.record("Expected video destination")
}
}
@Test("Parse YouTube live URL")
func liveURL() {
let url = URL(string: "https://www.youtube.com/live/abc123def")!
let destination = router.route(url)
if case .video(let source, _) = destination, case .id(let videoID) = source {
#expect(videoID.videoID == "abc123def")
} else {
Issue.record("Expected video destination")
}
}
@Test("Parse mobile YouTube URL")
func mobileURL() {
let url = URL(string: "https://m.youtube.com/watch?v=dQw4w9WgXcQ")!
let destination = router.route(url)
if case .video(let source, _) = destination, case .id(let videoID) = source {
#expect(videoID.videoID == "dQw4w9WgXcQ")
} else {
Issue.record("Expected video destination")
}
}
// MARK: - PeerTube URL Tests
@Test("Parse PeerTube /w/ video URL")
func peertubeWURL() {
let url = URL(string: "https://framatube.org/w/abc123-def456-ghi789")!
let destination = router.route(url)
if case .video(let source, _) = destination, case .id(let videoID) = source {
#expect(videoID.videoID == "abc123-def456-ghi789")
if case .federated(_, let instance) = videoID.source {
#expect(instance.host == "framatube.org")
} else {
Issue.record("Expected federated source")
}
} else {
Issue.record("Expected video destination")
}
}
@Test("Parse PeerTube /videos/watch/ URL")
func peertubeVideosWatchURL() {
let url = URL(string: "https://peertube.social/videos/watch/abc123")!
let destination = router.route(url)
if case .video(let source, _) = destination, case .id(let videoID) = source {
#expect(videoID.videoID == "abc123")
if case .federated(_, let instance) = videoID.source {
#expect(instance.host == "peertube.social")
} else {
Issue.record("Expected federated source")
}
} else {
Issue.record("Expected video destination")
}
}
// MARK: - Custom Scheme Tests
@Test("Parse yattee:// video URL")
func customSchemeVideoURL() {
let url = URL(string: "yattee://video/dQw4w9WgXcQ")!
let destination = router.route(url)
if case .video(let source, _) = destination, case .id(let videoID) = source {
#expect(videoID.videoID == "dQw4w9WgXcQ")
} else {
Issue.record("Expected video destination")
}
}
@Test("Parse yattee:// channel URL")
func customSchemeChannelURL() {
let url = URL(string: "yattee://channel/UCtest123")!
let destination = router.route(url)
if case .channel(let channelID, _) = destination {
#expect(channelID == "UCtest123")
} else {
Issue.record("Expected channel destination")
}
}
// MARK: - Edge Cases
@Test("Unknown URL routes to external video for yt-dlp extraction")
func unknownURL() {
let url = URL(string: "https://example.com/something")!
let destination = router.route(url)
// Unknown URLs are now routed to externalVideo for potential yt-dlp extraction
if case .externalVideo(let extractedURL) = destination {
#expect(extractedURL == url)
} else {
Issue.record("Expected externalVideo destination for unknown URLs")
}
}
@Test("YouTube URL without video ID routes to external video")
func urlWithoutVideoID() {
let url = URL(string: "https://www.youtube.com/watch")!
let destination = router.route(url)
// YouTube URLs without video ID are now treated as potential external videos
if case .externalVideo(let extractedURL) = destination {
#expect(extractedURL == url)
} else {
Issue.record("Expected externalVideo destination")
}
}
@Test("Known non-PeerTube hosts route to external video")
func nonPeerTubeHosts() {
// Vimeo should not be parsed as PeerTube but routed to external video
let vimeoURL = URL(string: "https://vimeo.com/w/123456")!
let vimeoDestination = router.route(vimeoURL)
if case .externalVideo(let url) = vimeoDestination {
#expect(url == vimeoURL)
} else {
Issue.record("Expected externalVideo destination for Vimeo")
}
// Dailymotion should not be parsed as PeerTube but routed to external video
let dailymotionURL = URL(string: "https://dailymotion.com/w/123456")!
let dailymotionDestination = router.route(dailymotionURL)
if case .externalVideo(let url) = dailymotionDestination {
#expect(url == dailymotionURL)
} else {
Issue.record("Expected externalVideo destination for Dailymotion")
}
}
// MARK: - YouTube Channel URL Tests
@Test("Parse YouTube channel URL")
func parseChannelURL() {
let url = URL(string: "https://www.youtube.com/channel/UCxyz123")!
let channelID = router.parseYouTubeChannelURL(url)
#expect(channelID == "UCxyz123")
}
@Test("Parse YouTube handle URL")
func parseHandleURL() {
let url = URL(string: "https://www.youtube.com/@channelhandle")!
let channelID = router.parseYouTubeChannelURL(url)
#expect(channelID == "@channelhandle")
}
@Test("Parse YouTube /c/ custom URL")
func parseCustomURL() {
let url = URL(string: "https://www.youtube.com/c/CustomName")!
let channelID = router.parseYouTubeChannelURL(url)
#expect(channelID == "CustomName")
}
@Test("Parse YouTube /user/ URL")
func parseUserURL() {
let url = URL(string: "https://www.youtube.com/user/Username")!
let channelID = router.parseYouTubeChannelURL(url)
#expect(channelID == "Username")
}
@Test("Non-YouTube channel URL returns nil")
func nonYouTubeChannelURL() {
let url = URL(string: "https://example.com/channel/test")!
let channelID = router.parseYouTubeChannelURL(url)
#expect(channelID == nil)
}
// MARK: - YouTube Playlist URL Tests
@Test("Parse YouTube playlist URL")
func parsePlaylistURL() {
let url = URL(string: "https://www.youtube.com/playlist?list=PLtest123")!
let destination = router.route(url)
if case .playlist(.remote(let playlistID, _, _)) = destination {
#expect(playlistID.playlistID == "PLtest123")
if case .global = playlistID.source {
// Expected
} else {
Issue.record("Expected global source")
}
} else {
Issue.record("Expected playlist destination")
}
}
@Test("YouTube watch URL with list parameter routes to video not playlist")
func watchURLWithListParameter() {
// When a URL has both v= and list=, it's a video playing within a playlist
// We should route to the video, not the playlist
let url = URL(string: "https://www.youtube.com/watch?v=dQw4w9WgXcQ&list=PLtest123")!
let destination = router.route(url)
if case .video(let source, _) = destination, case .id(let videoID) = source {
#expect(videoID.videoID == "dQw4w9WgXcQ")
} else {
Issue.record("Expected video destination for watch URL with list parameter")
}
}
// MARK: - YouTube Channel URL Routing Tests
@Test("Parse and route YouTube channel URL")
func routeChannelURL() {
let url = URL(string: "https://www.youtube.com/channel/UCxyz123")!
let destination = router.route(url)
if case .channel(let channelID, let source) = destination {
#expect(channelID == "UCxyz123")
if case .global = source {
// Expected
} else {
Issue.record("Expected global source")
}
} else {
Issue.record("Expected channel destination")
}
}
@Test("Parse and route YouTube handle URL")
func routeHandleURL() {
let url = URL(string: "https://www.youtube.com/@channelhandle")!
let destination = router.route(url)
if case .channel(let channelID, _) = destination {
#expect(channelID == "@channelhandle")
} else {
Issue.record("Expected channel destination for handle URL")
}
}
// MARK: - Custom Scheme Deep Link Tests
@Test("Parse yattee:// search URL")
func customSchemeSearchURL() {
let url = URL(string: "yattee://search?q=hello%20world")!
let destination = router.route(url)
if case .search(let query) = destination {
#expect(query == "hello world")
} else {
Issue.record("Expected search destination")
}
}
@Test("Parse yattee:// search URL without query returns nil")
func customSchemeSearchURLNoQuery() {
let url = URL(string: "yattee://search")!
let destination = router.route(url)
#expect(destination == nil)
}
@Test("Parse yattee:// playlist URL")
func customSchemePlaylistURL() {
let url = URL(string: "yattee://playlist/PLtest123")!
let destination = router.route(url)
if case .playlist(.remote(let playlistID, _, _)) = destination {
#expect(playlistID.playlistID == "PLtest123")
} else {
Issue.record("Expected playlist destination")
}
}
@Test("Parse yattee:// playlists URL")
func customSchemePlaylistsURL() {
let url = URL(string: "yattee://playlists")!
let destination = router.route(url)
#expect(destination == .playlists)
}
@Test("Parse yattee:// bookmarks URL")
func customSchemeBookmarksURL() {
let url = URL(string: "yattee://bookmarks")!
let destination = router.route(url)
#expect(destination == .bookmarks)
}
@Test("Parse yattee:// history URL")
func customSchemeHistoryURL() {
let url = URL(string: "yattee://history")!
let destination = router.route(url)
#expect(destination == .history)
}
@Test("Parse yattee:// downloads URL")
func customSchemeDownloadsURL() {
let url = URL(string: "yattee://downloads")!
let destination = router.route(url)
#expect(destination == .downloads)
}
@Test("Parse yattee:// channels URL")
func customSchemeChannelsURL() {
let url = URL(string: "yattee://channels")!
let destination = router.route(url)
#expect(destination == .manageChannels)
}
@Test("Parse yattee:// subscriptions URL")
func customSchemeSubscriptionsURL() {
let url = URL(string: "yattee://subscriptions")!
let destination = router.route(url)
#expect(destination == .subscriptionsFeed)
}
@Test("Parse yattee:// continue-watching URL")
func customSchemeContinueWatchingURL() {
let url = URL(string: "yattee://continue-watching")!
let destination = router.route(url)
#expect(destination == .continueWatching)
}
@Test("Parse yattee:// settings URL")
func customSchemeSettingsURL() {
let url = URL(string: "yattee://settings")!
let destination = router.route(url)
#expect(destination == .settings)
}
@Test("Parse yattee:// channel URL with PeerTube source")
func customSchemeChannelURLWithPeerTubeSource() {
let url = URL(string: "yattee://channel/channelid123?source=peertube&instance=https://peertube.social")!
let destination = router.route(url)
if case .channel(let channelID, let source) = destination {
#expect(channelID == "channelid123")
if case .federated(_, let instance) = source {
#expect(instance.host == "peertube.social")
} else {
Issue.record("Expected federated source")
}
} else {
Issue.record("Expected channel destination with federated source")
}
}
@Test("Parse yattee:// channel URL with PeerTube source but no instance falls back to Global")
func customSchemeChannelURLPeerTubeNoInstance() {
// If source=peertube but no instance is provided, should fall back to Global
let url = URL(string: "yattee://channel/channelid123?source=peertube")!
let destination = router.route(url)
if case .channel(let channelID, let source) = destination {
#expect(channelID == "channelid123")
if case .global = source {
// Expected - falls back to global when instance missing
} else {
Issue.record("Expected global source fallback")
}
} else {
Issue.record("Expected channel destination")
}
}
}
// MARK: - NavigationDestination Tests
@Suite("NavigationDestination Tests")
struct NavigationDestinationTests {
@Test("Video destinations are hashable")
func videoHashable() {
let video1 = NavigationDestination.video(.id(.global("abc")))
let video2 = NavigationDestination.video(.id(.global("abc")))
let video3 = NavigationDestination.video(.id(.global("def")))
#expect(video1 == video2)
#expect(video1 != video3)
}
@Test("Different destination types are not equal")
func differentTypes() {
let video = NavigationDestination.video(.id(.global("abc")))
let channel = NavigationDestination.channel("abc", .global(provider: ContentSource.youtubeProvider))
#expect(video != channel)
}
@Test("Settings destination")
func settingsDestination() {
let settings1 = NavigationDestination.settings
let settings2 = NavigationDestination.settings
#expect(settings1 == settings2)
}
@Test("Downloads destination")
func downloadsDestination() {
let downloads1 = NavigationDestination.downloads
let downloads2 = NavigationDestination.downloads
#expect(downloads1 == downloads2)
}
@Test("Search destination with query")
func searchDestination() {
let search1 = NavigationDestination.search("hello world")
let search2 = NavigationDestination.search("hello world")
let search3 = NavigationDestination.search("different query")
#expect(search1 == search2)
#expect(search1 != search3)
}
@Test("Playlist destination")
func playlistDestination() {
let playlist1 = NavigationDestination.playlist(.remote(PlaylistID(source: .global(provider: ContentSource.youtubeProvider), playlistID: "PLtest"), instance: nil))
let playlist2 = NavigationDestination.playlist(.remote(PlaylistID(source: .global(provider: ContentSource.youtubeProvider), playlistID: "PLtest"), instance: nil))
let playlist3 = NavigationDestination.playlist(.remote(PlaylistID(source: .global(provider: ContentSource.youtubeProvider), playlistID: "PLother"), instance: nil))
#expect(playlist1 == playlist2)
#expect(playlist1 != playlist3)
}
}
// MARK: - NavigationCoordinator Tests
@Suite("NavigationCoordinator Tests")
@MainActor
struct NavigationCoordinatorTests {
@Test("Initial state")
func initialState() {
let coordinator = NavigationCoordinator()
#expect(coordinator.selectedTab == .home)
#expect(coordinator.path.isEmpty)
#expect(coordinator.presentedSheet == nil)
}
@Test("Navigate to destination sets pending navigation")
func navigateToDestination() {
let coordinator = NavigationCoordinator()
let destination = NavigationDestination.video(.id(.global("test123")))
coordinator.navigate(to: destination)
#expect(coordinator.pendingNavigation == destination)
}
@Test("Multiple navigations update pending navigation")
func multipleNavigations() {
let coordinator = NavigationCoordinator()
coordinator.navigate(to: .video(.id(.global("1"))))
coordinator.navigate(to: .video(.id(.global("2"))))
coordinator.navigate(to: .video(.id(.global("3"))))
// Only the last navigation is pending
#expect(coordinator.pendingNavigation == .video(.id(.global("3"))))
}
@Test("Pop to root clears path")
func popToRoot() {
let coordinator = NavigationCoordinator()
// Manually add to path to test popToRoot
coordinator.path.append(NavigationDestination.video(.id(.global("1"))))
coordinator.path.append(NavigationDestination.video(.id(.global("2"))))
coordinator.path.append(NavigationDestination.video(.id(.global("3"))))
#expect(coordinator.path.count == 3)
coordinator.popToRoot()
#expect(coordinator.path.isEmpty)
}
@Test("Pop removes one level")
func pop() {
let coordinator = NavigationCoordinator()
// Manually add to path to test pop
coordinator.path.append(NavigationDestination.video(.id(.global("1"))))
coordinator.path.append(NavigationDestination.video(.id(.global("2"))))
#expect(coordinator.path.count == 2)
coordinator.pop()
#expect(coordinator.path.count == 1)
}
@Test("Pop on empty path is safe")
func popOnEmptyPath() {
let coordinator = NavigationCoordinator()
// Should not crash
coordinator.pop()
#expect(coordinator.path.isEmpty)
}
@Test("Switch tab")
func switchTab() {
let coordinator = NavigationCoordinator()
coordinator.selectedTab = .search
#expect(coordinator.selectedTab == .search)
}
@Test("Handle URL sets pending navigation")
func handleURL() {
let coordinator = NavigationCoordinator()
let url = URL(string: "https://youtube.com/watch?v=test123")!
coordinator.handle(url: url)
#expect(coordinator.pendingNavigation != nil)
}
@Test("Handle unknown URL does nothing")
func handleUnknownURL() {
let coordinator = NavigationCoordinator()
let url = URL(string: "https://example.com/unknown")!
coordinator.handle(url: url)
#expect(coordinator.path.isEmpty)
}
}
// MARK: - ConnectivityMonitor Tests
@Suite("ConnectivityMonitor Tests")
@MainActor
struct ConnectivityMonitorTests {
@Test("Initial state assumes online")
func initialState() {
let monitor = ConnectivityMonitor()
// By default, assume online until NWPathMonitor reports otherwise
#expect(monitor.isOnline == true)
}
}

View File

@@ -0,0 +1,304 @@
//
// NetworkingTests.swift
// YatteeTests
//
// Tests for networking layer components.
//
import Testing
import Foundation
@testable import Yattee
// MARK: - Endpoint Tests
@Suite("Endpoint Tests")
@MainActor
struct EndpointTests {
@Test("GET endpoint construction")
func getEndpoint() {
let endpoint = GenericEndpoint.get("/api/v1/videos")
#expect(endpoint.path == "/api/v1/videos")
#expect(endpoint.method == .get)
#expect(endpoint.queryItems == nil)
#expect(endpoint.body == nil)
}
@Test("GET endpoint with query parameters")
func getEndpointWithQuery() {
let endpoint = GenericEndpoint.get("/api/v1/search", query: [
"q": "test",
"page": "1"
])
#expect(endpoint.path == "/api/v1/search")
#expect(endpoint.queryItems?.count == 2)
let queryDict = Dictionary(uniqueKeysWithValues: endpoint.queryItems!.map { ($0.name, $0.value) })
#expect(queryDict["q"] == "test")
#expect(queryDict["page"] == "1")
}
@Test("POST endpoint with body")
func postEndpointWithBody() throws {
struct TestBody: Encodable {
let name: String
}
let body = TestBody(name: "test")
let endpoint = GenericEndpoint.post("/api/v1/create", body: body)
#expect(endpoint.path == "/api/v1/create")
#expect(endpoint.method == .post)
#expect(endpoint.body != nil)
}
@Test("Generic endpoint with custom timeout")
func endpointWithTimeout() {
let endpoint = GenericEndpoint(path: "/slow", timeout: 60)
#expect(endpoint.timeout == 60)
}
@Test("Default endpoint timeout is 30 seconds")
func defaultTimeout() {
let endpoint = GenericEndpoint.get("/fast")
#expect(endpoint.timeout == 30)
}
}
// MARK: - APIError Tests
@Suite("APIError Tests")
@MainActor
struct APIErrorTests {
@Test("APIError descriptions")
func errorDescriptions() {
let invalidURL = APIError.invalidURL
#expect(invalidURL.localizedDescription.contains("URL"))
let httpError = APIError.httpError(statusCode: 404, message: nil)
#expect(httpError.localizedDescription.contains("404"))
let timeout = APIError.timeout
#expect(timeout.localizedDescription.contains("timed out"))
let notFound = APIError.notFound(nil)
#expect(notFound.localizedDescription.contains("not found"))
}
@Test("All simple error descriptions")
func allSimpleErrorDescriptions() {
#expect(APIError.invalidURL.errorDescription == "Invalid URL")
#expect(APIError.timeout.errorDescription == "Request timed out")
#expect(APIError.noConnection.errorDescription == "No network connection")
#expect(APIError.cancelled.errorDescription == "Request was cancelled")
#expect(APIError.unauthorized.errorDescription == "Authentication required")
#expect(APIError.notFound(nil).errorDescription == "Resource not found")
#expect(APIError.commentsDisabled.errorDescription == "Comments are disabled")
#expect(APIError.noInstance.errorDescription == "No suitable instance available")
#expect(APIError.noStreams.errorDescription == "No playable streams available")
#expect(APIError.invalidRequest.errorDescription == "Invalid request")
}
@Test("Decoding error description")
func decodingErrorDescription() {
let error = APIError.decodingError("Missing key 'title'")
#expect(error.errorDescription?.contains("Missing key 'title'") == true)
}
@Test("Server error description")
func serverErrorDescription() {
let error = APIError.serverError("Internal server error")
#expect(error.errorDescription?.contains("Internal server error") == true)
}
@Test("Rate limited description with retry after")
func rateLimitedWithRetry() {
let error = APIError.rateLimited(retryAfter: 60)
#expect(error.errorDescription?.contains("60") == true)
}
@Test("Rate limited description without retry after")
func rateLimitedWithoutRetry() {
let error = APIError.rateLimited(retryAfter: nil)
#expect(error.errorDescription == "Rate limited")
}
@Test("Unknown error description")
func unknownErrorDescription() {
let error = APIError.unknown("Something went wrong")
#expect(error.errorDescription == "Something went wrong")
}
@Test("APIError equality")
func errorEquality() {
#expect(APIError.invalidURL == APIError.invalidURL)
#expect(APIError.httpError(statusCode: 404, message: nil) == APIError.httpError(statusCode: 404, message: nil))
#expect(APIError.httpError(statusCode: 404, message: nil) != APIError.httpError(statusCode: 500, message: nil))
#expect(APIError.timeout == APIError.timeout)
#expect(APIError.notFound(nil) == APIError.notFound(nil))
#expect(APIError.unauthorized == APIError.unauthorized)
}
@Test("APIError equality for parameterized errors")
func parameterizedErrorEquality() {
#expect(APIError.decodingError("msg") == APIError.decodingError("msg"))
#expect(APIError.decodingError("msg1") != APIError.decodingError("msg2"))
#expect(APIError.serverError("msg") == APIError.serverError("msg"))
#expect(APIError.serverError("msg1") != APIError.serverError("msg2"))
#expect(APIError.rateLimited(retryAfter: 30) == APIError.rateLimited(retryAfter: 30))
#expect(APIError.rateLimited(retryAfter: nil) == APIError.rateLimited(retryAfter: nil))
#expect(APIError.rateLimited(retryAfter: 30) != APIError.rateLimited(retryAfter: 60))
#expect(APIError.unknown("msg") == APIError.unknown("msg"))
#expect(APIError.unknown("msg1") != APIError.unknown("msg2"))
#expect(APIError.notFound(nil) == APIError.notFound(nil))
#expect(APIError.notFound("detail") == APIError.notFound("detail"))
#expect(APIError.notFound("detail1") != APIError.notFound("detail2"))
#expect(APIError.notFound(nil) != APIError.notFound("detail"))
}
@Test("notFound error with detail message")
func notFoundWithDetail() {
let noDetail = APIError.notFound(nil)
#expect(noDetail.errorDescription == "Resource not found")
let withDetail = APIError.notFound("Video not found: This live event will begin in 11 days.")
#expect(withDetail.errorDescription == "Video not found: This live event will begin in 11 days.")
}
@Test("Different error types are not equal")
func differentTypesNotEqual() {
#expect(APIError.invalidURL != APIError.timeout)
#expect(APIError.notFound(nil) != APIError.unauthorized)
#expect(APIError.commentsDisabled != APIError.noStreams)
}
@Test("APIError isRetryable")
func retryableErrors() {
#expect(APIError.timeout.isRetryable == true)
#expect(APIError.noConnection.isRetryable == true)
#expect(APIError.rateLimited(retryAfter: 60).isRetryable == true)
#expect(APIError.httpError(statusCode: 500, message: nil).isRetryable == true)
#expect(APIError.httpError(statusCode: 429, message: nil).isRetryable == true)
#expect(APIError.invalidURL.isRetryable == false)
#expect(APIError.notFound(nil).isRetryable == false)
#expect(APIError.unauthorized.isRetryable == false)
#expect(APIError.httpError(statusCode: 400, message: nil).isRetryable == false)
}
@Test("All non-retryable errors")
func allNonRetryableErrors() {
#expect(APIError.invalidURL.isRetryable == false)
#expect(APIError.decodingError("").isRetryable == false)
#expect(APIError.cancelled.isRetryable == false)
#expect(APIError.serverError("").isRetryable == false)
#expect(APIError.unauthorized.isRetryable == false)
#expect(APIError.notFound(nil).isRetryable == false)
#expect(APIError.commentsDisabled.isRetryable == false)
#expect(APIError.noInstance.isRetryable == false)
#expect(APIError.noStreams.isRetryable == false)
#expect(APIError.invalidRequest.isRetryable == false)
#expect(APIError.unknown("").isRetryable == false)
}
@Test("Server errors (5xx) are retryable")
func serverErrorsRetryable() {
#expect(APIError.httpError(statusCode: 500, message: nil).isRetryable == true)
#expect(APIError.httpError(statusCode: 502, message: nil).isRetryable == true)
#expect(APIError.httpError(statusCode: 503, message: nil).isRetryable == true)
#expect(APIError.httpError(statusCode: 504, message: nil).isRetryable == true)
}
@Test("Decoding error from Swift DecodingError types")
func decodingErrorFactory() {
// Test typeMismatch
let typeMismatchContext = DecodingError.Context(codingPath: [], debugDescription: "Expected String")
let typeMismatch = DecodingError.typeMismatch(String.self, typeMismatchContext)
let apiError1 = APIError.decodingError(typeMismatch)
#expect(apiError1.errorDescription?.contains("Type mismatch") == true)
// Test valueNotFound
let valueNotFoundContext = DecodingError.Context(codingPath: [], debugDescription: "No value")
let valueNotFound = DecodingError.valueNotFound(Int.self, valueNotFoundContext)
let apiError2 = APIError.decodingError(valueNotFound)
#expect(apiError2.errorDescription?.contains("Value not found") == true)
// Test dataCorrupted
let dataCorruptedContext = DecodingError.Context(codingPath: [], debugDescription: "Corrupted")
let dataCorrupted = DecodingError.dataCorrupted(dataCorruptedContext)
let apiError3 = APIError.decodingError(dataCorrupted)
#expect(apiError3.errorDescription?.contains("Data corrupted") == true)
}
}
// MARK: - URL Building Tests
@Suite("URL Building Tests")
@MainActor
struct URLBuildingTests {
@Test("Build URL from base and endpoint")
func buildURL() throws {
let baseURL = URL(string: "https://api.example.com")!
let endpoint = GenericEndpoint.get("/v1/videos")
let request = try endpoint.urlRequest(baseURL: baseURL)
#expect(request.url?.absoluteString == "https://api.example.com/v1/videos")
}
@Test("Build URL with query parameters")
func buildURLWithQuery() throws {
let baseURL = URL(string: "https://api.example.com")!
let endpoint = GenericEndpoint.get("/search", query: [
"q": "hello world",
"limit": "10"
])
let request = try endpoint.urlRequest(baseURL: baseURL)
let urlString = request.url?.absoluteString ?? ""
#expect(urlString.contains("q=hello%20world"))
#expect(urlString.contains("limit=10"))
}
@Test("URLRequest has correct method")
func requestMethod() throws {
let baseURL = URL(string: "https://api.example.com")!
let getEndpoint = GenericEndpoint.get("/resource")
let getRequest = try getEndpoint.urlRequest(baseURL: baseURL)
#expect(getRequest.httpMethod == "GET")
let postEndpoint = GenericEndpoint.post("/resource", body: ["key": "value"])
let postRequest = try postEndpoint.urlRequest(baseURL: baseURL)
#expect(postRequest.httpMethod == "POST")
}
@Test("URLRequest has JSON Accept header")
func acceptHeader() throws {
let baseURL = URL(string: "https://api.example.com")!
let endpoint = GenericEndpoint.get("/resource")
let request = try endpoint.urlRequest(baseURL: baseURL)
#expect(request.value(forHTTPHeaderField: "Accept") == "application/json")
}
}
// MARK: - HTTPMethod Tests
@Suite("HTTPMethod Tests")
@MainActor
struct HTTPMethodTests {
@Test("HTTPMethod raw values")
func rawValues() {
#expect(HTTPMethod.get.rawValue == "GET")
#expect(HTTPMethod.post.rawValue == "POST")
#expect(HTTPMethod.put.rawValue == "PUT")
#expect(HTTPMethod.patch.rawValue == "PATCH")
#expect(HTTPMethod.delete.rawValue == "DELETE")
}
}

View File

@@ -0,0 +1,371 @@
//
// PlayerTests.swift
// YatteeTests
//
// Tests for player service and SponsorBlock integration.
//
import Testing
import Foundation
@testable import Yattee
// MARK: - SponsorBlock Category Extended Tests
@Suite("SponsorBlock Category Extended Tests")
struct SponsorBlockCategoryExtendedTests {
@Test("All categories have descriptions")
func descriptions() {
for category in SponsorBlockCategory.allCases {
#expect(!category.localizedDescription.isEmpty)
}
}
@Test("Default auto-skip categories")
func defaultAutoSkip() {
#expect(SponsorBlockCategory.sponsor.defaultAutoSkip)
#expect(SponsorBlockCategory.selfpromo.defaultAutoSkip)
#expect(SponsorBlockCategory.interaction.defaultAutoSkip)
#expect(!SponsorBlockCategory.filler.defaultAutoSkip)
#expect(!SponsorBlockCategory.highlight.defaultAutoSkip)
}
@Test("Highlight category exists")
func highlightCategory() {
#expect(SponsorBlockCategory.highlight.rawValue == "poi_highlight")
#expect(SponsorBlockCategory.highlight.displayName == "Highlight")
}
}
// MARK: - SponsorBlock Segment Tests
@Suite("SponsorBlock Segment Tests")
struct SponsorBlockSegmentTests {
@Test("Segment timing calculations")
func segmentTiming() throws {
let json = """
{
"UUID": "test-uuid",
"category": "sponsor",
"actionType": "skip",
"segment": [10.5, 30.0],
"videoDuration": 600.0,
"votes": 10,
"description": "Sponsor segment"
}
"""
let segment = try JSONDecoder().decode(SponsorBlockSegment.self, from: json.data(using: .utf8)!)
#expect(segment.uuid == "test-uuid")
#expect(segment.startTime == 10.5)
#expect(segment.endTime == 30.0)
#expect(segment.duration == 19.5)
#expect(segment.category == .sponsor)
#expect(segment.actionType == .skip)
#expect(!segment.isPointOfInterest)
#expect(segment.segmentDescription == "Sponsor segment")
}
@Test("Point of interest detection")
func pointOfInterest() throws {
let json = """
{
"UUID": "poi-uuid",
"category": "poi_highlight",
"actionType": "poi",
"segment": [120.0, 120.0]
}
"""
let segment = try JSONDecoder().decode(SponsorBlockSegment.self, from: json.data(using: .utf8)!)
#expect(segment.isPointOfInterest)
#expect(segment.startTime == segment.endTime)
}
}
// MARK: - Segment Array Extension Tests
@Suite("Segment Array Extensions")
struct SegmentArrayTests {
let segments: [SponsorBlockSegment]
init() throws {
let json = """
[
{"UUID": "1", "category": "sponsor", "actionType": "skip", "segment": [10.0, 20.0]},
{"UUID": "2", "category": "intro", "actionType": "skip", "segment": [0.0, 5.0]},
{"UUID": "3", "category": "selfpromo", "actionType": "mute", "segment": [30.0, 40.0]},
{"UUID": "4", "category": "outro", "actionType": "skip", "segment": [580.0, 600.0]}
]
"""
self.segments = try JSONDecoder().decode([SponsorBlockSegment].self, from: json.data(using: .utf8)!)
}
@Test("Filter skippable segments")
func skippable() {
let skippable = segments.skippable()
#expect(skippable.count == 3)
#expect(!skippable.contains { $0.uuid == "3" }) // mute action excluded
}
@Test("Filter by categories")
func inCategories() {
let sponsorOnly = segments.inCategories([.sponsor])
#expect(sponsorOnly.count == 1)
#expect(sponsorOnly.first?.uuid == "1")
let multiple = segments.inCategories([.sponsor, .intro])
#expect(multiple.count == 2)
}
@Test("Find segment at time")
func segmentAtTime() {
let atStart = segments.segment(at: 2.0)
#expect(atStart?.uuid == "2") // intro 0-5
let atSponsor = segments.segment(at: 15.0)
#expect(atSponsor?.uuid == "1") // sponsor 10-20
let atNothing = segments.segment(at: 25.0)
#expect(atNothing == nil)
}
@Test("Find next segment after time")
func nextSegmentAfterTime() {
let afterStart = segments.nextSegment(after: 6.0)
#expect(afterStart?.uuid == "1") // sponsor at 10.0
let afterSponsor = segments.nextSegment(after: 25.0)
#expect(afterSponsor?.uuid == "3") // selfpromo at 30.0
let afterAll = segments.nextSegment(after: 590.0)
#expect(afterAll == nil)
}
}
// MARK: - Player State Tests
@Suite("Player State Tests")
@MainActor
struct PlayerStateTests {
@Test("Initial state")
func initialState() {
let state = PlayerState()
#expect(state.playbackState == .idle)
#expect(state.currentVideo == nil)
#expect(state.currentTime == 0)
#expect(state.duration == 0)
#expect(state.rate == .x1)
#expect(!state.isMuted)
}
@Test("Progress calculation")
func progressCalculation() {
let state = PlayerState()
state.duration = 100
state.currentTime = 50
#expect(state.progress == 0.5)
}
@Test("Progress calculation with zero duration")
func progressZeroDuration() {
let state = PlayerState()
state.duration = 0
state.currentTime = 50
#expect(state.progress == 0)
}
@Test("Time formatting")
func timeFormatting() {
let state = PlayerState()
state.currentTime = 65 // 1:05
#expect(state.formattedCurrentTime == "1:05")
state.duration = 3661 // 1:01:01
#expect(state.formattedDuration == "1:01:01")
state.currentTime = 3600 // remaining = 61 seconds = 1:01
#expect(state.formattedRemainingTime == "-1:01")
}
@Test("Queue operations")
func queueOperations() {
let state = PlayerState()
let video1 = Video(
id: .global("video1"),
title: "Video 1",
description: nil,
author: Author(id: "ch1", name: "Channel"),
duration: 100,
publishedAt: nil,
publishedText: nil,
viewCount: nil,
likeCount: nil,
thumbnails: [],
isLive: false,
isUpcoming: false,
scheduledStartTime: nil
)
let video2 = Video(
id: .global("video2"),
title: "Video 2",
description: nil,
author: Author(id: "ch1", name: "Channel"),
duration: 200,
publishedAt: nil,
publishedText: nil,
viewCount: nil,
likeCount: nil,
thumbnails: [],
isLive: false,
isUpcoming: false,
scheduledStartTime: nil
)
// Add to queue
state.addToQueue(video1)
state.addToQueue(video2)
#expect(state.queue.count == 2)
#expect(!state.hasPrevious)
#expect(state.hasNext)
// Advance - returns first item (video1) and removes it from queue
let next = state.advanceQueue()
#expect(next?.video.id == video1.id)
#expect(state.queue.count == 1)
#expect(!state.hasPrevious) // No history yet
#expect(state.hasNext) // video2 still in queue
// Add video1 to history manually (simulating playback)
state.pushToHistory(next!)
// Now advance to video2
let next2 = state.advanceQueue()
#expect(next2?.video.id == video2.id)
#expect(state.hasPrevious) // video1 is in history
#expect(!state.hasNext) // queue is empty
// Retreat - returns last item from history (video1)
let prev = state.retreatQueue()
#expect(prev?.video.id == video1.id)
#expect(!state.hasPrevious) // history is now empty
// Clear
state.clearQueue()
#expect(state.queue.isEmpty)
}
@Test("SponsorBlock auto-skip categories")
func autoSkipCategories() {
let state = PlayerState()
// Default should include common skip categories
#expect(state.autoSkipCategories.contains(.sponsor))
#expect(state.autoSkipCategories.contains(.selfpromo))
#expect(!state.autoSkipCategories.contains(.filler))
}
}
// MARK: - Playback Rate Tests
@Suite("Playback Rate Tests")
struct PlaybackRateTests {
@Test("All rates have display text")
func displayText() {
for rate in PlaybackRate.allCases {
#expect(!rate.displayText.isEmpty)
}
}
@Test("Normal rate displays correctly")
func normalRate() {
#expect(PlaybackRate.x1.displayText == "Normal")
}
@Test("Other rates format correctly")
func otherRates() {
#expect(PlaybackRate.x15.displayText == "1.5x")
#expect(PlaybackRate.x2.displayText == "2x")
#expect(PlaybackRate.x025.displayText == "0.25x")
}
@Test("Compact display text always shows numeric format")
func compactDisplayText() {
#expect(PlaybackRate.x1.compactDisplayText == "1x")
#expect(PlaybackRate.x15.compactDisplayText == "1.5x")
#expect(PlaybackRate.x2.compactDisplayText == "2x")
}
}
// MARK: - Video Chapter Tests
@Suite("Video Chapter Tests")
struct VideoChapterTests {
@Test("Chapter initialization")
func initialization() {
let chapter = VideoChapter(
title: "Introduction",
startTime: 0,
endTime: 60
)
#expect(chapter.title == "Introduction")
#expect(chapter.startTime == 0)
#expect(chapter.endTime == 60)
#expect(chapter.duration == 60)
}
@Test("Formatted start time")
func formattedStartTime() {
let chapter1 = VideoChapter(title: "A", startTime: 65)
#expect(chapter1.formattedStartTime == "1:05")
let chapter2 = VideoChapter(title: "B", startTime: 3661)
#expect(chapter2.formattedStartTime == "1:01:01")
}
@Test("Current chapter detection")
@MainActor
func currentChapter() {
let state = PlayerState()
state.chapters = [
VideoChapter(title: "Intro", startTime: 0, endTime: 30),
VideoChapter(title: "Main", startTime: 30, endTime: 120),
VideoChapter(title: "Outro", startTime: 120, endTime: 150)
]
state.currentTime = 15
#expect(state.currentChapter?.title == "Intro")
state.currentTime = 60
#expect(state.currentChapter?.title == "Main")
state.currentTime = 130
#expect(state.currentChapter?.title == "Outro")
}
}
// MARK: - Playback State Tests
@Suite("Playback State Tests")
struct PlaybackStateTests {
@Test("State equality")
func stateEquality() {
#expect(PlaybackState.idle == PlaybackState.idle)
#expect(PlaybackState.playing == PlaybackState.playing)
#expect(PlaybackState.idle != PlaybackState.playing)
// Failed states are equal regardless of error content
let error1 = NSError(domain: "test", code: 1)
let error2 = NSError(domain: "test", code: 2)
#expect(PlaybackState.failed(error1) == PlaybackState.failed(error2))
}
}

View File

@@ -0,0 +1,144 @@
//
// SearchHistoryTests.swift
// YatteeTests
//
// Tests for search history functionality.
//
import Testing
import Foundation
@testable import Yattee
@MainActor
@Suite("Search History Tests")
struct SearchHistoryTests {
@Test("Add search query creates new entry")
@MainActor
func addSearchQuery() async throws {
let dataManager = try DataManager(inMemory: true)
let settingsManager = SettingsManager()
dataManager.settingsManager = settingsManager
settingsManager.searchHistoryLimit = 25
dataManager.addSearchQuery("swift programming")
let history = dataManager.fetchSearchHistory(limit: 10)
#expect(history.count == 1)
#expect(history.first?.query == "swift programming")
}
@Test("Duplicate query moves to top with case-insensitive matching")
@MainActor
func duplicateQueryDeduplication() async throws {
let dataManager = try DataManager(inMemory: true)
let settingsManager = SettingsManager()
dataManager.settingsManager = settingsManager
settingsManager.searchHistoryLimit = 25
// Add three queries
dataManager.addSearchQuery("swift")
try await Task.sleep(for: .milliseconds(10)) // Small delay to ensure different timestamps
dataManager.addSearchQuery("python")
try await Task.sleep(for: .milliseconds(10))
dataManager.addSearchQuery("Swift") // Same as first but different case
let history = dataManager.fetchSearchHistory(limit: 10)
// Should only have 2 entries (swift deduplicated)
#expect(history.count == 2)
// "Swift" should be at top (most recent)
#expect(history[0].query == "swift")
#expect(history[1].query == "python")
}
@Test("Enforces user-configured limit")
@MainActor
func searchHistoryLimit() async throws {
let dataManager = try DataManager(inMemory: true)
let settingsManager = SettingsManager()
dataManager.settingsManager = settingsManager
settingsManager.searchHistoryLimit = 5
// Add 10 queries
for i in 1...10 {
dataManager.addSearchQuery("query \(i)")
}
let history = dataManager.fetchSearchHistory(limit: 100)
// Should only keep last 5
#expect(history.count == 5)
#expect(history[0].query == "query 10")
#expect(history[4].query == "query 6")
}
@Test("Delete removes specific entry")
@MainActor
func deleteSearchQuery() async throws {
let dataManager = try DataManager(inMemory: true)
let settingsManager = SettingsManager()
dataManager.settingsManager = settingsManager
dataManager.addSearchQuery("query 1")
dataManager.addSearchQuery("query 2")
dataManager.addSearchQuery("query 3")
var history = dataManager.fetchSearchHistory(limit: 10)
#expect(history.count == 3)
// Delete middle entry
let toDelete = history[1]
dataManager.deleteSearchQuery(toDelete)
history = dataManager.fetchSearchHistory(limit: 10)
#expect(history.count == 2)
#expect(history[0].query == "query 3")
#expect(history[1].query == "query 1")
}
@Test("Clear all removes all entries")
@MainActor
func clearAllSearchHistory() async throws {
let dataManager = try DataManager(inMemory: true)
let settingsManager = SettingsManager()
dataManager.settingsManager = settingsManager
dataManager.addSearchQuery("query 1")
dataManager.addSearchQuery("query 2")
dataManager.addSearchQuery("query 3")
var history = dataManager.fetchSearchHistory(limit: 10)
#expect(history.count == 3)
dataManager.clearSearchHistory()
history = dataManager.fetchSearchHistory(limit: 10)
#expect(history.isEmpty)
}
@Test("Whitespace trimming and empty query rejection")
@MainActor
func queryTrimming() async throws {
let dataManager = try DataManager(inMemory: true)
let settingsManager = SettingsManager()
dataManager.settingsManager = settingsManager
// Try to add empty query
dataManager.addSearchQuery("")
var history = dataManager.fetchSearchHistory(limit: 10)
#expect(history.isEmpty)
// Try to add whitespace-only query
dataManager.addSearchQuery(" ")
history = dataManager.fetchSearchHistory(limit: 10)
#expect(history.isEmpty)
// Add query with leading/trailing whitespace
dataManager.addSearchQuery(" swift programming ")
history = dataManager.fetchSearchHistory(limit: 10)
#expect(history.count == 1)
#expect(history.first?.query == "swift programming")
}
}

View File

@@ -0,0 +1,467 @@
//
// SeekGestureTests.swift
// YatteeTests
//
// Tests for horizontal seek gesture models and algorithm.
//
import Foundation
import Testing
@testable import Yattee
@Suite("Seek Gesture Tests")
struct SeekGestureTests {
// MARK: - SeekGestureSensitivity Tests
@Suite("SeekGestureSensitivity")
struct SeekGestureSensitivityTests {
@Test("Base seconds per screen width values")
func baseSecondsPerScreenWidth() {
#expect(SeekGestureSensitivity.low.baseSecondsPerScreenWidth == 30)
#expect(SeekGestureSensitivity.medium.baseSecondsPerScreenWidth == 60)
#expect(SeekGestureSensitivity.high.baseSecondsPerScreenWidth == 120)
}
@Test("All sensitivities have display names")
func displayNames() {
for sensitivity in SeekGestureSensitivity.allCases {
#expect(!sensitivity.displayName.isEmpty)
#expect(!sensitivity.description.isEmpty)
}
}
@Test("Sensitivity is codable")
func isCodable() throws {
for original in SeekGestureSensitivity.allCases {
let encoded = try JSONEncoder().encode(original)
let decoded = try JSONDecoder().decode(SeekGestureSensitivity.self, from: encoded)
#expect(decoded == original)
}
}
}
// MARK: - SeekGestureSettings Tests
@Suite("SeekGestureSettings")
struct SeekGestureSettingsTests {
@Test("Default settings are disabled")
func defaultSettingsAreDisabled() {
let settings = SeekGestureSettings.default
#expect(settings.isEnabled == false)
}
@Test("Default sensitivity is medium")
func defaultSensitivityIsMedium() {
let settings = SeekGestureSettings.default
#expect(settings.sensitivity == .medium)
}
@Test("Settings is codable")
func isCodable() throws {
let original = SeekGestureSettings(isEnabled: true, sensitivity: .high)
let encoded = try JSONEncoder().encode(original)
let decoded = try JSONDecoder().decode(SeekGestureSettings.self, from: encoded)
#expect(decoded.isEnabled == original.isEnabled)
#expect(decoded.sensitivity == original.sensitivity)
}
@Test("Settings is hashable")
func isHashable() {
let settings1 = SeekGestureSettings(isEnabled: true, sensitivity: .low)
let settings2 = SeekGestureSettings(isEnabled: true, sensitivity: .low)
let settings3 = SeekGestureSettings(isEnabled: false, sensitivity: .high)
#expect(settings1 == settings2)
#expect(settings1 != settings3)
#expect(settings1.hashValue == settings2.hashValue)
}
}
// MARK: - SeekGestureCalculator Tests
@Suite("SeekGestureCalculator")
struct SeekGestureCalculatorTests {
// MARK: - Horizontal Movement Detection
@Suite("isHorizontalMovement")
struct IsHorizontalMovementTests {
@Test("Horizontal movement exceeding threshold is recognized")
func horizontalExceedingThreshold() {
// 25pt horizontal, 0pt vertical - should be recognized
let translation = CGSize(width: 25, height: 0)
#expect(SeekGestureCalculator.isHorizontalMovement(translation: translation) == true)
}
@Test("Horizontal movement below threshold is not recognized")
func horizontalBelowThreshold() {
// 15pt horizontal - below 20pt threshold
let translation = CGSize(width: 15, height: 0)
#expect(SeekGestureCalculator.isHorizontalMovement(translation: translation) == false)
}
@Test("Negative horizontal movement is recognized")
func negativeHorizontal() {
// -30pt horizontal (backward direction)
let translation = CGSize(width: -30, height: 0)
#expect(SeekGestureCalculator.isHorizontalMovement(translation: translation) == true)
}
@Test("Diagonal within 30 degrees is recognized")
func diagonalWithinAngle() {
// tan(30°) 0.577, so for 50pt horizontal, max vertical 28.9pt
let translation = CGSize(width: 50, height: 25)
#expect(SeekGestureCalculator.isHorizontalMovement(translation: translation) == true)
}
@Test("Diagonal beyond 30 degrees is not recognized")
func diagonalBeyondAngle() {
// 45 degree angle - beyond 30 degree limit
let translation = CGSize(width: 30, height: 30)
#expect(SeekGestureCalculator.isHorizontalMovement(translation: translation) == false)
}
@Test("Vertical movement is not recognized")
func verticalMovement() {
// Purely vertical
let translation = CGSize(width: 5, height: 50)
#expect(SeekGestureCalculator.isHorizontalMovement(translation: translation) == false)
}
@Test("Zero movement is not recognized")
func zeroMovement() {
let translation = CGSize(width: 0, height: 0)
#expect(SeekGestureCalculator.isHorizontalMovement(translation: translation) == false)
}
}
// MARK: - Duration Multiplier
@Suite("calculateDurationMultiplier")
struct DurationMultiplierTests {
@Test("5 minute video gives minimum multiplier")
func fiveMinuteVideo() {
let multiplier = SeekGestureCalculator.calculateDurationMultiplier(videoDuration: 300)
#expect(multiplier == 0.5)
}
@Test("10 minute video gives 1.0 multiplier")
func tenMinuteVideo() {
let multiplier = SeekGestureCalculator.calculateDurationMultiplier(videoDuration: 600)
#expect(multiplier == 1.0)
}
@Test("20 minute video gives 2.0 multiplier")
func twentyMinuteVideo() {
let multiplier = SeekGestureCalculator.calculateDurationMultiplier(videoDuration: 1200)
#expect(multiplier == 2.0)
}
@Test("30 minute video gives maximum multiplier")
func thirtyMinuteVideo() {
let multiplier = SeekGestureCalculator.calculateDurationMultiplier(videoDuration: 1800)
#expect(multiplier == 3.0)
}
@Test("60 minute video is capped at maximum")
func sixtyMinuteVideo() {
let multiplier = SeekGestureCalculator.calculateDurationMultiplier(videoDuration: 3600)
#expect(multiplier == 3.0)
}
@Test("Very short video is capped at minimum")
func veryShortVideo() {
let multiplier = SeekGestureCalculator.calculateDurationMultiplier(videoDuration: 60)
#expect(multiplier == 0.5)
}
@Test("Zero duration returns default")
func zeroDuration() {
let multiplier = SeekGestureCalculator.calculateDurationMultiplier(videoDuration: 0)
#expect(multiplier == 1.0)
}
}
// MARK: - Seek Delta Calculation
@Suite("calculateSeekDelta")
struct SeekDeltaTests {
@Test("Full screen swipe with medium sensitivity on 10 min video")
func fullSwipeMedium() {
// 10 min video, medium sensitivity, full screen swipe
// Expected: 60s * 1.0 = 60s
let delta = SeekGestureCalculator.calculateSeekDelta(
dragDistance: 400,
screenWidth: 400,
videoDuration: 600,
sensitivity: .medium
)
#expect(delta == 60)
}
@Test("Half screen swipe")
func halfScreenSwipe() {
// Half screen swipe should give half the seek delta
let delta = SeekGestureCalculator.calculateSeekDelta(
dragDistance: 200,
screenWidth: 400,
videoDuration: 600,
sensitivity: .medium
)
#expect(delta == 30)
}
@Test("Negative swipe for backward seek")
func negativeSwipe() {
let delta = SeekGestureCalculator.calculateSeekDelta(
dragDistance: -200,
screenWidth: 400,
videoDuration: 600,
sensitivity: .medium
)
#expect(delta == -30)
}
@Test("Low sensitivity gives smaller seek")
func lowSensitivity() {
// Low = 30s base, vs medium = 60s
let delta = SeekGestureCalculator.calculateSeekDelta(
dragDistance: 400,
screenWidth: 400,
videoDuration: 600,
sensitivity: .low
)
#expect(delta == 30)
}
@Test("High sensitivity gives larger seek")
func highSensitivity() {
// High = 120s base, vs medium = 60s
let delta = SeekGestureCalculator.calculateSeekDelta(
dragDistance: 400,
screenWidth: 400,
videoDuration: 600,
sensitivity: .high
)
#expect(delta == 120)
}
@Test("Short video reduces seek amount")
func shortVideoMultiplier() {
// 5 min video has 0.5x multiplier
// Expected: 60s * 0.5 = 30s for full swipe
let delta = SeekGestureCalculator.calculateSeekDelta(
dragDistance: 400,
screenWidth: 400,
videoDuration: 300,
sensitivity: .medium
)
#expect(delta == 30)
}
@Test("Long video increases seek amount")
func longVideoMultiplier() {
// 30 min video has 3.0x multiplier
// Expected: 60s * 3.0 = 180s for full swipe
let delta = SeekGestureCalculator.calculateSeekDelta(
dragDistance: 400,
screenWidth: 400,
videoDuration: 1800,
sensitivity: .medium
)
#expect(delta == 180)
}
@Test("Small drag below minimum threshold returns nil")
func belowMinimumThreshold() {
// Very small drag that would result in < 5s seek
let delta = SeekGestureCalculator.calculateSeekDelta(
dragDistance: 10,
screenWidth: 400,
videoDuration: 600,
sensitivity: .medium
)
#expect(delta == nil)
}
@Test("Drag at exactly minimum threshold returns value")
func atMinimumThreshold() {
// Calculate drag distance needed for exactly 5s
// 5s = (drag/400) * 60 * 1.0 drag = 400 * 5 / 60 33.3pt
let delta = SeekGestureCalculator.calculateSeekDelta(
dragDistance: 34,
screenWidth: 400,
videoDuration: 600,
sensitivity: .medium
)
#expect(delta != nil)
if let delta {
#expect(delta >= 5.0)
}
}
@Test("Zero screen width returns nil")
func zeroScreenWidth() {
let delta = SeekGestureCalculator.calculateSeekDelta(
dragDistance: 100,
screenWidth: 0,
videoDuration: 600,
sensitivity: .medium
)
#expect(delta == nil)
}
@Test("Zero duration returns nil")
func zeroDuration() {
let delta = SeekGestureCalculator.calculateSeekDelta(
dragDistance: 100,
screenWidth: 400,
videoDuration: 0,
sensitivity: .medium
)
#expect(delta == nil)
}
}
// MARK: - Boundary Clamping
@Suite("clampSeekTime")
struct ClampSeekTimeTests {
@Test("Normal forward seek within bounds")
func normalForwardSeek() {
let result = SeekGestureCalculator.clampSeekTime(
currentTime: 100,
seekDelta: 50,
duration: 600
)
#expect(result.seekTime == 150)
#expect(result.hitBoundary == false)
}
@Test("Normal backward seek within bounds")
func normalBackwardSeek() {
let result = SeekGestureCalculator.clampSeekTime(
currentTime: 100,
seekDelta: -50,
duration: 600
)
#expect(result.seekTime == 50)
#expect(result.hitBoundary == false)
}
@Test("Forward seek past end is clamped")
func forwardPastEnd() {
let result = SeekGestureCalculator.clampSeekTime(
currentTime: 550,
seekDelta: 100,
duration: 600
)
#expect(result.seekTime == 600)
#expect(result.hitBoundary == true)
}
@Test("Backward seek past start is clamped")
func backwardPastStart() {
let result = SeekGestureCalculator.clampSeekTime(
currentTime: 30,
seekDelta: -50,
duration: 600
)
#expect(result.seekTime == 0)
#expect(result.hitBoundary == true)
}
@Test("Exactly at boundary does not report hit")
func exactlyAtBoundary() {
// Seek to exactly the end
let result = SeekGestureCalculator.clampSeekTime(
currentTime: 500,
seekDelta: 100,
duration: 600
)
#expect(result.seekTime == 600)
#expect(result.hitBoundary == false)
}
@Test("Zero duration clamps to zero")
func zeroDuration() {
let result = SeekGestureCalculator.clampSeekTime(
currentTime: 0,
seekDelta: 100,
duration: 0
)
#expect(result.seekTime == 0)
#expect(result.hitBoundary == true)
}
}
}
// MARK: - GesturesSettings Integration
@Suite("GesturesSettings Integration")
struct GesturesSettingsIntegrationTests {
@Test("hasActiveGestures includes seek gesture")
func hasActiveGesturesIncludesSeek() {
// Only seek enabled
let seekOnly = GesturesSettings(
tapGestures: TapGesturesSettings(isEnabled: false),
seekGesture: SeekGestureSettings(isEnabled: true),
panscanGesture: PanscanGestureSettings(isEnabled: false)
)
#expect(seekOnly.hasActiveGestures == true)
#expect(seekOnly.isSeekGestureActive == true)
#expect(seekOnly.areTapGesturesActive == false)
// Only tap enabled
let tapOnly = GesturesSettings(
tapGestures: TapGesturesSettings(isEnabled: true),
seekGesture: SeekGestureSettings(isEnabled: false),
panscanGesture: PanscanGestureSettings(isEnabled: false)
)
#expect(tapOnly.hasActiveGestures == true)
#expect(tapOnly.isSeekGestureActive == false)
#expect(tapOnly.areTapGesturesActive == true)
// Both enabled
let both = GesturesSettings(
tapGestures: TapGesturesSettings(isEnabled: true),
seekGesture: SeekGestureSettings(isEnabled: true),
panscanGesture: PanscanGestureSettings(isEnabled: false)
)
#expect(both.hasActiveGestures == true)
// Neither enabled (all disabled including panscan)
let neither = GesturesSettings(
tapGestures: TapGesturesSettings(isEnabled: false),
seekGesture: SeekGestureSettings(isEnabled: false),
panscanGesture: PanscanGestureSettings(isEnabled: false)
)
#expect(neither.hasActiveGestures == false)
}
@Test("GesturesSettings serialization with seek gesture")
func serializationWithSeekGesture() throws {
let original = GesturesSettings(
tapGestures: TapGesturesSettings(isEnabled: true, layout: .quadrants),
seekGesture: SeekGestureSettings(isEnabled: true, sensitivity: .high)
)
let encoded = try JSONEncoder().encode(original)
let decoded = try JSONDecoder().decode(GesturesSettings.self, from: encoded)
#expect(decoded.tapGestures.isEnabled == original.tapGestures.isEnabled)
#expect(decoded.tapGestures.layout == original.tapGestures.layout)
#expect(decoded.seekGesture.isEnabled == original.seekGesture.isEnabled)
#expect(decoded.seekGesture.sensitivity == original.seekGesture.sensitivity)
}
}
}

View File

@@ -0,0 +1,743 @@
//
// ServiceTests.swift
// YatteeTests
//
// Tests for service layer components including WebDAV client and storage utilities.
//
import Testing
import Foundation
@testable import Yattee
// MARK: - Storage Diagnostics Tests
@Suite("StorageDiagnostics Tests")
@MainActor
struct StorageDiagnosticsTests {
@Test("StorageUsageItem initialization")
func storageUsageItemInit() {
let item = StorageUsageItem(
name: "Downloads",
path: "/path/to/downloads",
size: 1024 * 1024 * 500, // 500 MB
fileCount: 42
)
#expect(item.name == "Downloads")
#expect(item.path == "/path/to/downloads")
#expect(item.size == 524288000)
#expect(item.fileCount == 42)
#expect(!item.id.uuidString.isEmpty)
}
@Test("StorageUsageItem is Identifiable")
func storageUsageItemIdentifiable() {
let item1 = StorageUsageItem(name: "A", path: "/a", size: 100, fileCount: 1)
let item2 = StorageUsageItem(name: "A", path: "/a", size: 100, fileCount: 1)
// Each item should have unique ID even with same content
#expect(item1.id != item2.id)
}
@Test("StorageDiagnostics formatted values")
func diagnosticsFormattedValues() {
let diagnostics = StorageDiagnostics(
items: [],
totalSize: 1024 * 1024 * 1024, // 1 GB
documentsSize: 500 * 1024 * 1024,
cachesSize: 200 * 1024 * 1024,
appSupportSize: 100 * 1024 * 1024,
tempSize: 50 * 1024 * 1024,
otherSize: 150 * 1024 * 1024
)
#expect(!diagnostics.formattedTotal.isEmpty)
#expect(!diagnostics.formattedDocuments.isEmpty)
#expect(!diagnostics.formattedCaches.isEmpty)
#expect(!diagnostics.formattedAppSupport.isEmpty)
#expect(!diagnostics.formattedTemp.isEmpty)
}
@Test("StorageDiagnostics with items")
func diagnosticsWithItems() {
let items = [
StorageUsageItem(name: "Downloads", path: "/downloads", size: 300_000_000, fileCount: 10),
StorageUsageItem(name: "Cache", path: "/cache", size: 100_000_000, fileCount: 50),
StorageUsageItem(name: "Temp", path: "/temp", size: 50_000_000, fileCount: 5)
]
let diagnostics = StorageDiagnostics(
items: items,
totalSize: 450_000_000,
documentsSize: 300_000_000,
cachesSize: 100_000_000,
appSupportSize: 0,
tempSize: 50_000_000,
otherSize: 0
)
#expect(diagnostics.items.count == 3)
#expect(diagnostics.totalSize == 450_000_000)
}
@Test("scanAppStorage returns valid diagnostics")
func scanAppStorageReturnsValid() {
let diagnostics = scanAppStorage()
#expect(diagnostics.totalSize >= 0)
#expect(diagnostics.documentsSize >= 0)
#expect(diagnostics.cachesSize >= 0)
#expect(!diagnostics.formattedTotal.isEmpty)
}
}
// MARK: - LockedStorage Tests
@Suite("LockedStorage Tests")
struct LockedStorageTests {
@Test("LockedStorage read returns value")
func readReturnsValue() {
let storage = LockedStorage(42)
let value = storage.read { $0 }
#expect(value == 42)
}
@Test("LockedStorage write modifies value")
func writeModifiesValue() {
let storage = LockedStorage(0)
storage.write { $0 += 10 }
let value = storage.read { $0 }
#expect(value == 10)
}
@Test("LockedStorage with string")
func withString() {
let storage = LockedStorage("hello")
storage.write { $0 += " world" }
let value = storage.read { $0 }
#expect(value == "hello world")
}
@Test("LockedStorage with array")
func withArray() {
let storage = LockedStorage<[Int]>([])
storage.write { $0.append(1) }
storage.write { $0.append(2) }
storage.write { $0.append(3) }
let value = storage.read { $0 }
#expect(value == [1, 2, 3])
}
@Test("LockedStorage read with transformation")
func readWithTransformation() {
let storage = LockedStorage([1, 2, 3, 4, 5])
let sum = storage.read { $0.reduce(0, +) }
#expect(sum == 15)
}
@Test("LockedStorage concurrent access")
func concurrentAccess() async {
let storage = LockedStorage(0)
await withTaskGroup(of: Void.self) { group in
for _ in 0..<100 {
group.addTask {
storage.write { $0 += 1 }
}
}
}
let finalValue = storage.read { $0 }
#expect(finalValue == 100)
}
}
// MARK: - BandwidthTestResult Tests
@Suite("BandwidthTestResult Tests")
struct BandwidthTestResultTests {
@Test("BandwidthTestResult with write access")
func bandwidthTestResultWithWrite() {
let result = BandwidthTestResult(
hasWriteAccess: true,
uploadSpeed: 50_000_000, // 50 MB/s
downloadSpeed: 100_000_000, // 100 MB/s
testFileSize: 5 * 1024 * 1024, // 5 MB
warning: nil
)
#expect(result.hasWriteAccess == true)
#expect(result.uploadSpeed == 50_000_000)
#expect(result.downloadSpeed == 100_000_000)
#expect(result.testFileSize == 5 * 1024 * 1024)
#expect(result.warning == nil)
}
@Test("BandwidthTestResult read-only mode")
func bandwidthTestResultReadOnly() {
let result = BandwidthTestResult(
hasWriteAccess: false,
uploadSpeed: nil,
downloadSpeed: 75_000_000,
testFileSize: 5 * 1024 * 1024,
warning: "Server is read-only"
)
#expect(result.hasWriteAccess == false)
#expect(result.uploadSpeed == nil)
#expect(result.downloadSpeed == 75_000_000)
#expect(result.warning == "Server is read-only")
}
@Test("BandwidthTestResult formatted speeds")
func bandwidthTestResultFormattedSpeeds() {
let result = BandwidthTestResult(
hasWriteAccess: true,
uploadSpeed: 50_000_000,
downloadSpeed: 100_000_000,
testFileSize: 5 * 1024 * 1024,
warning: nil
)
// Formatted strings should contain speed values (optional returns)
#expect(result.formattedDownloadSpeed != nil)
#expect(result.formattedUploadSpeed != nil)
#expect(result.formattedDownloadSpeed?.contains("/s") == true)
#expect(result.formattedUploadSpeed?.contains("/s") == true)
}
@Test("formattedUploadSpeed nil when no upload")
func formattedUploadSpeedNil() {
let result = BandwidthTestResult(
hasWriteAccess: false,
uploadSpeed: nil,
downloadSpeed: 50_000_000,
testFileSize: 5_000_000,
warning: nil
)
#expect(result.formattedUploadSpeed == nil)
}
@Test("formattedDownloadSpeed nil when no download")
func formattedDownloadSpeedNil() {
let result = BandwidthTestResult(
hasWriteAccess: false,
uploadSpeed: nil,
downloadSpeed: nil,
testFileSize: 0,
warning: "No files available"
)
#expect(result.formattedDownloadSpeed == nil)
}
@Test("Warning message preserved")
func warningPreserved() {
let result = BandwidthTestResult(
hasWriteAccess: false,
uploadSpeed: nil,
downloadSpeed: nil,
testFileSize: 0,
warning: "Server appears empty, could not test download speed"
)
#expect(result.warning == "Server appears empty, could not test download speed")
}
}
// MARK: - MediaSourceError Tests
@Suite("MediaSourceError Tests")
struct MediaSourceErrorTests {
@Test("MediaSourceError cases exist")
func errorCasesExist() {
let authError = MediaSourceError.authenticationFailed
let pathError = MediaSourceError.pathNotFound("/test/path")
let connectionError = MediaSourceError.connectionFailed("timeout")
let unknownError = MediaSourceError.unknown("something went wrong")
#expect(authError.errorDescription != nil)
#expect(pathError.errorDescription != nil)
#expect(connectionError.errorDescription != nil)
#expect(unknownError.errorDescription != nil)
}
@Test("MediaSourceError path not found includes path")
func pathNotFoundIncludesPath() {
let error = MediaSourceError.pathNotFound("/videos/movie.mp4")
let description = error.errorDescription ?? ""
#expect(description.contains("video") || description.contains("movie") ||
description.contains("path") || description.contains("not found") ||
description.contains("Path"))
}
@Test("MediaSourceError connection failed includes message")
func connectionFailedIncludesMessage() {
let error = MediaSourceError.connectionFailed("HTTP 500")
let description = error.errorDescription ?? ""
#expect(description.contains("500") || description.contains("connection") ||
description.contains("failed") || description.contains("HTTP") ||
description.contains("Connection"))
}
@Test("isRetryable for timeout")
func timeoutIsRetryable() {
let error = MediaSourceError.timeout
#expect(error.isRetryable == true)
}
@Test("isRetryable for noConnection")
func noConnectionIsRetryable() {
let error = MediaSourceError.noConnection
#expect(error.isRetryable == true)
}
@Test("isRetryable for connectionFailed")
func connectionFailedIsRetryable() {
let error = MediaSourceError.connectionFailed("network error")
#expect(error.isRetryable == true)
}
@Test("isRetryable for authenticationFailed")
func authenticationFailedNotRetryable() {
let error = MediaSourceError.authenticationFailed
#expect(error.isRetryable == false)
}
@Test("isRetryable for pathNotFound")
func pathNotFoundNotRetryable() {
let error = MediaSourceError.pathNotFound("/invalid")
#expect(error.isRetryable == false)
}
@Test("isRetryable for accessDenied")
func accessDeniedNotRetryable() {
let error = MediaSourceError.accessDenied
#expect(error.isRetryable == false)
}
@Test("Error equality same cases")
func equalitySameCases() {
#expect(MediaSourceError.authenticationFailed == MediaSourceError.authenticationFailed)
#expect(MediaSourceError.timeout == MediaSourceError.timeout)
#expect(MediaSourceError.noConnection == MediaSourceError.noConnection)
#expect(MediaSourceError.accessDenied == MediaSourceError.accessDenied)
}
@Test("Error equality with associated values")
func equalityAssociatedValues() {
#expect(MediaSourceError.pathNotFound("/a") == MediaSourceError.pathNotFound("/a"))
#expect(MediaSourceError.pathNotFound("/a") != MediaSourceError.pathNotFound("/b"))
#expect(MediaSourceError.connectionFailed("x") == MediaSourceError.connectionFailed("x"))
#expect(MediaSourceError.connectionFailed("x") != MediaSourceError.connectionFailed("y"))
}
@Test("All error cases have descriptions")
func allCasesHaveDescriptions() {
let errors: [MediaSourceError] = [
.connectionFailed("test"),
.authenticationFailed,
.pathNotFound("/test"),
.parsingFailed("xml error"),
.notADirectory,
.invalidResponse,
.bookmarkResolutionFailed,
.accessDenied,
.timeout,
.noConnection,
.unknown("mystery")
]
for error in errors {
#expect(error.errorDescription != nil)
#expect(!error.errorDescription!.isEmpty)
}
}
}
// MARK: - MediaSource Configuration Tests
@Suite("MediaSource Configuration Tests")
struct MediaSourceConfigurationTests {
@Test("WebDAV factory method")
func webdavFactory() {
let source = MediaSource.webdav(
name: "My NAS",
url: URL(string: "https://nas.local:5006/webdav")!,
username: "admin"
)
#expect(source.type == .webdav)
#expect(source.name == "My NAS")
#expect(source.username == "admin")
#expect(source.url.absoluteString == "https://nas.local:5006/webdav")
#expect(source.isEnabled == true)
#expect(source.requiresAuthentication == true)
}
@Test("WebDAV without username")
func webdavWithoutUsername() {
let source = MediaSource.webdav(
name: "Public NAS",
url: URL(string: "https://public.nas/webdav")!
)
#expect(source.username == nil)
#expect(source.requiresAuthentication == false)
}
@Test("LocalFolder factory method")
func localFolderFactory() {
let url = URL(fileURLWithPath: "/Users/test/Videos")
let source = MediaSource.localFolder(
name: "Videos",
url: url,
bookmarkData: Data([0x01, 0x02, 0x03])
)
#expect(source.type == .localFolder)
#expect(source.name == "Videos")
#expect(source.bookmarkData != nil)
#expect(source.requiresAuthentication == false)
}
@Test("MediaSourceType displayName")
func mediaSourceTypeDisplayName() {
#expect(!MediaSourceType.webdav.displayName.isEmpty)
#expect(!MediaSourceType.localFolder.displayName.isEmpty)
}
@Test("MediaSourceType systemImage")
func mediaSourceTypeSystemImage() {
#expect(!MediaSourceType.webdav.systemImage.isEmpty)
#expect(!MediaSourceType.localFolder.systemImage.isEmpty)
}
@Test("MediaSourceType CaseIterable")
func mediaSourceTypeCaseIterable() {
let allCases = MediaSourceType.allCases
#expect(allCases.contains(.webdav))
#expect(allCases.contains(.localFolder))
#expect(allCases.contains(.smb))
#expect(allCases.count == 3)
}
@Test("MediaSource urlDisplayString WebDAV")
func urlDisplayStringWebDAV() {
let source = MediaSource.webdav(
name: "NAS",
url: URL(string: "https://nas.synology.me/webdav")!
)
#expect(source.urlDisplayString == "nas.synology.me")
}
@Test("MediaSource urlDisplayString LocalFolder")
func urlDisplayStringLocalFolder() {
let source = MediaSource.localFolder(
name: "Movies",
url: URL(fileURLWithPath: "/Users/test/Movies")
)
#expect(source.urlDisplayString == "Movies")
}
@Test("MediaSource is Identifiable")
func mediaSourceIdentifiable() {
let source1 = MediaSource.webdav(name: "A", url: URL(string: "https://a.com")!)
let source2 = MediaSource.webdav(name: "A", url: URL(string: "https://a.com")!)
// Each source gets unique UUID
#expect(source1.id != source2.id)
}
@Test("MediaSource is Codable")
func mediaSourceCodable() throws {
let source = MediaSource.webdav(
name: "Test NAS",
url: URL(string: "https://nas.local")!,
username: "user"
)
let encoded = try JSONEncoder().encode(source)
let decoded = try JSONDecoder().decode(MediaSource.self, from: encoded)
#expect(decoded.name == source.name)
#expect(decoded.type == source.type)
#expect(decoded.url == source.url)
#expect(decoded.username == source.username)
}
}
// MARK: - MediaFile Tests
@Suite("MediaFile Tests")
struct MediaFileTests {
private func createTestSource() -> MediaSource {
MediaSource.webdav(name: "Test", url: URL(string: "https://nas.local")!)
}
@Test("MediaFile initialization")
func mediaFileInit() {
let source = createTestSource()
let file = MediaFile(
source: source,
path: "/videos/movie.mp4",
name: "movie.mp4",
isDirectory: false,
size: 104857600, // 100 MB
modifiedDate: Date()
)
#expect(file.name == "movie.mp4")
#expect(file.path == "/videos/movie.mp4")
#expect(file.isDirectory == false)
#expect(file.size == 104857600)
}
@Test("MediaFile directory type")
func mediaFileDirectory() {
let source = MediaSource.localFolder(
name: "Videos",
url: URL(fileURLWithPath: "/Users/test/Videos")
)
let folder = MediaFile(
source: source,
path: "/Movies",
name: "Movies",
isDirectory: true,
size: nil,
modifiedDate: nil
)
#expect(folder.isDirectory == true)
#expect(folder.size == nil)
}
@Test("MediaFile is Identifiable")
func mediaFileIdentifiable() {
let source = createTestSource()
let file = MediaFile(
source: source,
path: "/video.mp4",
name: "video.mp4",
isDirectory: false,
size: 1000,
modifiedDate: nil
)
#expect(!file.id.isEmpty)
#expect(file.id.contains(source.id.uuidString))
}
@Test("MediaFile Hashable")
func mediaFileHashable() {
let source = createTestSource()
let file1 = MediaFile(
source: source,
path: "/video.mp4",
name: "video.mp4",
isDirectory: false,
size: 1000,
modifiedDate: nil
)
let file2 = MediaFile(
source: source,
path: "/video.mp4",
name: "video.mp4",
isDirectory: false,
size: 1000,
modifiedDate: nil
)
// Same path and source should be equal
#expect(file1 == file2)
var set = Set<MediaFile>()
set.insert(file1)
#expect(set.contains(file2))
}
@Test("MediaFile isVideo for video files")
func isVideoForVideoFiles() {
let source = createTestSource()
let extensions = ["mp4", "mkv", "avi", "mov", "webm", "flv", "m4v"]
for ext in extensions {
let file = MediaFile(
source: source,
path: "/movie.\(ext)",
name: "movie.\(ext)",
isDirectory: false
)
#expect(file.isVideo == true, "Expected .\(ext) to be video")
}
}
@Test("MediaFile isVideo false for directories")
func isVideoFalseForDirectories() {
let source = createTestSource()
let folder = MediaFile(
source: source,
path: "/Videos",
name: "Videos",
isDirectory: true
)
#expect(folder.isVideo == false)
}
@Test("MediaFile isAudio for audio files")
func isAudioForAudioFiles() {
let source = createTestSource()
let extensions = ["mp3", "m4a", "flac", "wav", "ogg", "opus", "aac"]
for ext in extensions {
let file = MediaFile(
source: source,
path: "/song.\(ext)",
name: "song.\(ext)",
isDirectory: false
)
#expect(file.isAudio == true, "Expected .\(ext) to be audio")
}
}
@Test("MediaFile isPlayable")
func isPlayable() {
let source = createTestSource()
let videoFile = MediaFile(source: source, path: "/movie.mp4", name: "movie.mp4", isDirectory: false)
let audioFile = MediaFile(source: source, path: "/song.mp3", name: "song.mp3", isDirectory: false)
let textFile = MediaFile(source: source, path: "/readme.txt", name: "readme.txt", isDirectory: false)
let folder = MediaFile(source: source, path: "/Movies", name: "Movies", isDirectory: true)
#expect(videoFile.isPlayable == true)
#expect(audioFile.isPlayable == true)
#expect(textFile.isPlayable == false)
#expect(folder.isPlayable == false)
}
@Test("MediaFile fileExtension")
func fileExtension() {
let source = createTestSource()
let file1 = MediaFile(source: source, path: "/video.MP4", name: "video.MP4", isDirectory: false)
let file2 = MediaFile(source: source, path: "/movie.MKV", name: "movie.MKV", isDirectory: false)
// Extensions should be lowercase
#expect(file1.fileExtension == "mp4")
#expect(file2.fileExtension == "mkv")
}
@Test("MediaFile formattedSize")
func formattedSize() {
let source = createTestSource()
let smallFile = MediaFile(source: source, path: "/small.txt", name: "small.txt", isDirectory: false, size: 1024)
let largeFile = MediaFile(source: source, path: "/large.mp4", name: "large.mp4", isDirectory: false, size: 1_500_000_000)
let noSize = MediaFile(source: source, path: "/unknown.dat", name: "unknown.dat", isDirectory: false, size: nil)
#expect(smallFile.formattedSize != nil)
#expect(largeFile.formattedSize != nil)
#expect(noSize.formattedSize == nil)
}
@Test("MediaFile systemImage")
func systemImage() {
let source = createTestSource()
let folder = MediaFile(source: source, path: "/Dir", name: "Dir", isDirectory: true)
let video = MediaFile(source: source, path: "/movie.mp4", name: "movie.mp4", isDirectory: false)
let audio = MediaFile(source: source, path: "/song.mp3", name: "song.mp3", isDirectory: false)
let other = MediaFile(source: source, path: "/doc.pdf", name: "doc.pdf", isDirectory: false)
#expect(folder.systemImage == "folder.fill")
#expect(video.systemImage == "film")
#expect(audio.systemImage == "music.note")
#expect(other.systemImage == "doc")
}
@Test("MediaFile url construction")
func urlConstruction() {
let source = MediaSource.webdav(name: "NAS", url: URL(string: "https://nas.local/webdav")!)
let file = MediaFile(source: source, path: "/videos/movie.mp4", name: "movie.mp4", isDirectory: false)
#expect(file.url.absoluteString.contains("nas.local"))
#expect(file.url.absoluteString.contains("movie.mp4"))
}
@Test("MediaFile toVideo conversion")
func toVideoConversion() {
let source = MediaSource.webdav(name: "NAS", url: URL(string: "https://nas.local")!)
let modDate = Date()
let file = MediaFile(
source: source,
path: "/movies/My Movie.mp4",
name: "My Movie.mp4",
isDirectory: false,
size: 1_000_000,
modifiedDate: modDate
)
let video = file.toVideo()
#expect(video.title == "My Movie")
#expect(video.author.name == "NAS")
#expect(video.publishedAt == modDate)
#expect(video.isLive == false)
}
@Test("MediaFile toStream conversion")
func toStreamConversion() {
let source = MediaSource.webdav(name: "NAS", url: URL(string: "https://nas.local")!)
let file = MediaFile(
source: source,
path: "/movie.mkv",
name: "movie.mkv",
isDirectory: false
)
let stream = file.toStream(authHeaders: ["Authorization": "Basic abc123"])
#expect(stream.format == "mkv")
#expect(stream.httpHeaders?["Authorization"] == "Basic abc123")
}
@Test("MediaFile preview samples")
func previewSamples() {
let file = MediaFile.preview
let folder = MediaFile.folderPreview
#expect(file.isDirectory == false)
#expect(file.isVideo == true)
#expect(folder.isDirectory == true)
}
@Test("MediaFile video extensions coverage")
func videoExtensionsCoverage() {
// Verify all expected video extensions are included
let expected = ["mp4", "m4v", "mov", "mkv", "avi", "webm", "wmv", "flv", "mpg", "mpeg", "3gp", "ts", "vob"]
for ext in expected {
#expect(MediaFile.videoExtensions.contains(ext), "Missing video extension: \(ext)")
}
}
@Test("MediaFile audio extensions coverage")
func audioExtensionsCoverage() {
// Verify all expected audio extensions are included
let expected = ["mp3", "m4a", "aac", "flac", "wav", "ogg", "opus", "wma", "aiff"]
for ext in expected {
#expect(MediaFile.audioExtensions.contains(ext), "Missing audio extension: \(ext)")
}
}
}

View File

@@ -0,0 +1,275 @@
//
// SettingsTests.swift
// YatteeTests
//
// Tests for settings and preferences types.
//
import Testing
import Foundation
import SwiftUI
@testable import Yattee
// MARK: - AppTheme Tests
@Suite("AppTheme Tests")
@MainActor
struct AppThemeTests {
@Test("AppTheme cases")
func allCases() {
let cases = AppTheme.allCases
#expect(cases.contains(.system))
#expect(cases.contains(.light))
#expect(cases.contains(.dark))
}
@Test("AppTheme colorScheme mapping")
func colorSchemeMapping() {
#expect(AppTheme.system.colorScheme == nil)
#expect(AppTheme.light.colorScheme == .light)
#expect(AppTheme.dark.colorScheme == .dark)
}
@Test("AppTheme is Codable")
func codable() throws {
for theme in AppTheme.allCases {
let encoded = try JSONEncoder().encode(theme)
let decoded = try JSONDecoder().decode(AppTheme.self, from: encoded)
#expect(theme == decoded)
}
}
}
// MARK: - AccentColor Tests
@Suite("AccentColor Tests")
@MainActor
struct AccentColorTests {
@Test("AccentColor cases")
func allCases() {
let cases = AccentColor.allCases
#expect(cases.contains(.default))
#expect(cases.contains(.red))
#expect(cases.contains(.blue))
#expect(cases.contains(.green))
#expect(cases.contains(.purple))
}
@Test("AccentColor is Codable")
func codable() throws {
for color in AccentColor.allCases {
let encoded = try JSONEncoder().encode(color)
let decoded = try JSONDecoder().decode(AccentColor.self, from: encoded)
#expect(color == decoded)
}
}
}
// MARK: - VideoQuality Tests
@Suite("VideoQuality Tests")
@MainActor
struct VideoQualityTests {
@Test("VideoQuality cases")
func allCases() {
let cases = VideoQuality.allCases
#expect(cases.contains(.auto))
#expect(cases.contains(.hd4k))
#expect(cases.contains(.hd1440p))
#expect(cases.contains(.hd1080p))
#expect(cases.contains(.hd720p))
#expect(cases.contains(.sd480p))
#expect(cases.contains(.sd360p))
}
@Test("VideoQuality raw values")
func rawValues() {
#expect(VideoQuality.auto.rawValue == "auto")
#expect(VideoQuality.hd4k.rawValue == "4k")
#expect(VideoQuality.hd1440p.rawValue == "1440p")
#expect(VideoQuality.hd1080p.rawValue == "1080p")
}
@Test("VideoQuality recommendedForPlatform returns valid quality")
func recommendedForPlatform() {
let recommended = VideoQuality.recommendedForPlatform
#expect(VideoQuality.allCases.contains(recommended))
}
@Test("VideoQuality is Codable")
func codable() throws {
for quality in VideoQuality.allCases {
let encoded = try JSONEncoder().encode(quality)
let decoded = try JSONDecoder().decode(VideoQuality.self, from: encoded)
#expect(quality == decoded)
}
}
}
// MARK: - SponsorBlockCategory Tests
@Suite("SponsorBlockCategory Tests")
@MainActor
struct SponsorBlockCategoryTests {
@Test("SponsorBlockCategory cases")
func allCases() {
let cases = SponsorBlockCategory.allCases
#expect(cases.contains(.sponsor))
#expect(cases.contains(.selfpromo))
#expect(cases.contains(.interaction))
#expect(cases.contains(.intro))
#expect(cases.contains(.outro))
#expect(cases.contains(.preview))
#expect(cases.contains(.musicOfftopic))
#expect(cases.contains(.filler))
#expect(cases.contains(.highlight))
}
@Test("SponsorBlockCategory display names are not empty")
func displayNames() {
// Display names use localized strings, so we just verify they're not empty
for category in SponsorBlockCategory.allCases {
#expect(!category.displayName.isEmpty)
}
}
@Test("SponsorBlockCategory raw values")
func rawValues() {
#expect(SponsorBlockCategory.sponsor.rawValue == "sponsor")
#expect(SponsorBlockCategory.musicOfftopic.rawValue == "music_offtopic")
}
@Test("SponsorBlockCategory defaultEnabled set")
func defaultEnabled() {
let defaults = SponsorBlockCategory.defaultEnabled
#expect(defaults.contains(.sponsor))
#expect(defaults.contains(.selfpromo))
#expect(defaults.contains(.interaction))
#expect(defaults.contains(.intro))
#expect(defaults.contains(.outro))
#expect(!defaults.contains(.preview))
#expect(!defaults.contains(.musicOfftopic))
#expect(!defaults.contains(.filler))
}
@Test("SponsorBlockCategory is Codable")
func codable() throws {
for category in SponsorBlockCategory.allCases {
let encoded = try JSONEncoder().encode(category)
let decoded = try JSONDecoder().decode(SponsorBlockCategory.self, from: encoded)
#expect(category == decoded)
}
}
@Test("SponsorBlockCategory Set is Codable")
func setCodable() throws {
let categories: Set<SponsorBlockCategory> = [.sponsor, .intro, .outro]
let encoded = try JSONEncoder().encode(categories)
let decoded = try JSONDecoder().decode(Set<SponsorBlockCategory>.self, from: encoded)
#expect(categories == decoded)
}
}
// MARK: - MacPlayerMode Tests (macOS only)
#if os(macOS)
@Suite("MacPlayerMode Tests")
@MainActor
struct MacPlayerModeTests {
@Test("MacPlayerMode cases")
func allCases() {
let cases = MacPlayerMode.allCases
#expect(cases.contains(.window))
#expect(cases.contains(.inline))
}
@Test("MacPlayerMode display names")
func displayNames() {
#expect(MacPlayerMode.window.displayName == "Separate Window")
#expect(MacPlayerMode.floatingWindow.displayName == "Floating Window")
#expect(MacPlayerMode.inline.displayName == "Inline (Sheet)")
}
@Test("MacPlayerMode is Codable")
func codable() throws {
for mode in MacPlayerMode.allCases {
let encoded = try JSONEncoder().encode(mode)
let decoded = try JSONDecoder().decode(MacPlayerMode.self, from: encoded)
#expect(mode == decoded)
}
}
}
#endif
// MARK: - UserAgentGenerator Tests
@Suite("UserAgentGenerator Tests")
struct UserAgentGeneratorTests {
@Test("Random user agent is not empty")
func randomNotEmpty() {
let ua = UserAgentGenerator.generateRandom()
#expect(!ua.isEmpty)
}
@Test("Random user agent starts with Mozilla")
func randomStartsWithMozilla() {
for _ in 0..<10 {
let ua = UserAgentGenerator.generateRandom()
#expect(ua.hasPrefix("Mozilla/5.0"))
}
}
@Test("Random user agent contains browser identifier")
func randomContainsBrowser() {
// Run multiple times to test randomness
var containsChrome = false
var containsFirefox = false
var containsSafari = false
var containsEdge = false
for _ in 0..<100 {
let ua = UserAgentGenerator.generateRandom()
if ua.contains("Chrome/") { containsChrome = true }
if ua.contains("Firefox/") { containsFirefox = true }
if ua.contains("Safari/") { containsSafari = true }
if ua.contains("Edg/") { containsEdge = true }
}
// At least some browser types should appear (probabilistic)
#expect(containsChrome || containsFirefox || containsSafari || containsEdge)
}
@Test("Default user agent is valid")
func defaultUserAgent() {
let ua = UserAgentGenerator.defaultUserAgent
#expect(!ua.isEmpty)
#expect(ua.hasPrefix("Mozilla/5.0"))
}
@Test("Random user agent has reasonable length")
func reasonableLength() {
for _ in 0..<10 {
let ua = UserAgentGenerator.generateRandom()
// User agents are typically 80-200 characters
#expect(ua.count > 50)
#expect(ua.count < 300)
}
}
@Test("Random user agent contains platform info")
func containsPlatformInfo() {
for _ in 0..<20 {
let ua = UserAgentGenerator.generateRandom()
// Should contain Windows, Macintosh, or similar
let containsPlatform = ua.contains("Windows") || ua.contains("Macintosh") || ua.contains("Intel Mac")
#expect(containsPlatform)
}
}
}