mirror of
https://github.com/yattee/yattee.git
synced 2026-08-30 19:01:43 +00:00
Yattee v2 rewrite
This commit is contained in:
1219
YatteeTests/APIParsingTests.swift
Normal file
1219
YatteeTests/APIParsingTests.swift
Normal file
File diff suppressed because it is too large
Load Diff
272
YatteeTests/APITests.swift
Normal file
272
YatteeTests/APITests.swift
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
||||
129
YatteeTests/ChapterIntegrationTests.swift
Normal file
129
YatteeTests/ChapterIntegrationTests.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
513
YatteeTests/ChapterParserTests.swift
Normal file
513
YatteeTests/ChapterParserTests.swift
Normal 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")
|
||||
}
|
||||
}
|
||||
338
YatteeTests/CredentialsTests.swift
Normal file
338
YatteeTests/CredentialsTests.swift
Normal 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
823
YatteeTests/DataTests.swift
Normal 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")
|
||||
}
|
||||
}
|
||||
}
|
||||
493
YatteeTests/DownloadTests.swift
Normal file
493
YatteeTests/DownloadTests.swift
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
||||
207
YatteeTests/GestureSettingsTests.swift
Normal file
207
YatteeTests/GestureSettingsTests.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
149
YatteeTests/InstancesManagerTests.swift
Normal file
149
YatteeTests/InstancesManagerTests.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
351
YatteeTests/Integration/InvidiousAPIIntegrationTests.swift
Normal file
351
YatteeTests/Integration/InvidiousAPIIntegrationTests.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
37
YatteeTests/Integration/TestConstants.swift
Normal file
37
YatteeTests/Integration/TestConstants.swift
Normal 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
|
||||
}
|
||||
652
YatteeTests/ModelTests.swift
Normal file
652
YatteeTests/ModelTests.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
626
YatteeTests/NavigationTests.swift
Normal file
626
YatteeTests/NavigationTests.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
304
YatteeTests/NetworkingTests.swift
Normal file
304
YatteeTests/NetworkingTests.swift
Normal 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")
|
||||
}
|
||||
}
|
||||
371
YatteeTests/PlayerTests.swift
Normal file
371
YatteeTests/PlayerTests.swift
Normal 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))
|
||||
}
|
||||
}
|
||||
144
YatteeTests/SearchHistoryTests.swift
Normal file
144
YatteeTests/SearchHistoryTests.swift
Normal 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")
|
||||
}
|
||||
}
|
||||
467
YatteeTests/SeekGestureTests.swift
Normal file
467
YatteeTests/SeekGestureTests.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
743
YatteeTests/ServiceTests.swift
Normal file
743
YatteeTests/ServiceTests.swift
Normal 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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
275
YatteeTests/SettingsTests.swift
Normal file
275
YatteeTests/SettingsTests.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user