Files
yattee/YatteeTests/ModelTests.swift
2026-02-08 18:33:56 +01:00

653 lines
20 KiB
Swift

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