mirror of
https://github.com/yattee/yattee.git
synced 2026-10-23 13:12:53 +00:00
yattee://play|play-next|queue|download|bookmark|subscribe|unsubscribe?url={link}
and yattee://player/play|pause|toggle|next|previous|close|seek|rate run through
one LinkActionPerformer that reports a LinkActionResult, ready for
x-callback-url. An unencoded url= keeps its own query; only the action's own
parameters end it.
Actions that change saved state ask first through a confirmation sheet
presented by the top-most sheet host, unless the new 'Run Link Actions
Without Asking' setting is on. Download skips the question when the quality
picker will show anyway. Player commands leave sheets and the player alone.
976 lines
36 KiB
Swift
976 lines
36 KiB
Swift
//
|
|
// 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: - Timestamp Parsing Tests
|
|
|
|
@Test("Parse timestamp from youtu.be short URL")
|
|
func timestampFromShortURL() {
|
|
let url = URL(string: "https://youtu.be/GBimVR2VBQU?t=17097")!
|
|
#expect(router.parseTimestamp(url) == 17097)
|
|
}
|
|
|
|
@Test("Parse compound timestamp value")
|
|
func compoundTimestampValue() {
|
|
#expect(URLRouter.parseTimestampValue("1h2m3s") == 3723)
|
|
#expect(URLRouter.parseTimestampValue("2m30s") == 150)
|
|
#expect(URLRouter.parseTimestampValue("90s") == 90)
|
|
#expect(URLRouter.parseTimestampValue("90.5") == 90.5)
|
|
}
|
|
|
|
@Test("Reject non-finite and negative plain timestamp values")
|
|
func rejectInvalidPlainTimestampValues() {
|
|
// Plain numeric values that are accepted by TimeInterval(_:) but are not
|
|
// valid seek targets must return nil rather than poisoning the player.
|
|
#expect(URLRouter.parseTimestampValue("inf") == nil)
|
|
#expect(URLRouter.parseTimestampValue("infinity") == nil)
|
|
#expect(URLRouter.parseTimestampValue("nan") == nil)
|
|
#expect(URLRouter.parseTimestampValue("-5") == nil)
|
|
// Valid plain seconds still parse.
|
|
#expect(URLRouter.parseTimestampValue("0") == 0)
|
|
#expect(URLRouter.parseTimestampValue("90") == 90)
|
|
#expect(URLRouter.parseTimestampValue("90.5") == 90.5)
|
|
}
|
|
|
|
// MARK: - Share Extension Wrapper Tests
|
|
|
|
@Test("Unwrap yattee://open wrapper to inner URL with timestamp")
|
|
func unwrapOpenWrapper() {
|
|
// Share extension percent-encoding leaves ?, /, : intact - this is the literal form delivered
|
|
let wrapper = URL(string: "yattee://open?url=https://youtu.be/GBimVR2VBQU?t=17097")!
|
|
let inner = router.unwrapped(wrapper)
|
|
|
|
#expect(inner.absoluteString == "https://youtu.be/GBimVR2VBQU?t=17097")
|
|
#expect(router.parseTimestamp(inner) == 17097)
|
|
|
|
// Unwrapped URL still routes to the right video
|
|
if case .video(let source, _) = router.route(inner), case .id(let videoID) = source {
|
|
#expect(videoID.videoID == "GBimVR2VBQU")
|
|
} else {
|
|
Issue.record("Expected video destination")
|
|
}
|
|
}
|
|
|
|
@Test("Unwrap wrapper with ampersand timestamp form")
|
|
func unwrapOpenWrapperAmpersandForm() {
|
|
let wrapper = URL(string: "yattee://open?url=https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=120")!
|
|
let inner = router.unwrapped(wrapper)
|
|
|
|
#expect(router.parseTimestamp(inner) == 120)
|
|
if case .video(let source, _) = router.route(inner), case .id(let videoID) = source {
|
|
#expect(videoID.videoID == "dQw4w9WgXcQ")
|
|
} else {
|
|
Issue.record("Expected video destination")
|
|
}
|
|
}
|
|
|
|
@Test("Unwrapped returns non-wrapper URLs unchanged")
|
|
func unwrapPassthrough() {
|
|
let plain = URL(string: "https://youtu.be/dQw4w9WgXcQ?t=42")!
|
|
#expect(router.unwrapped(plain) == plain)
|
|
|
|
let scheme = URL(string: "yattee://subscriptions")!
|
|
#expect(router.unwrapped(scheme) == scheme)
|
|
}
|
|
|
|
@Test("route() unwraps yattee://open itself, keeping inner query items")
|
|
func routeUnwrapsOpenWrapper() {
|
|
let wrapper = URL(string: "yattee://open?url=https://www.youtube.com/watch?list=PLtest123&index=2")!
|
|
if case .playlist(.remote(let id, _, _, _)) = router.route(wrapper) {
|
|
#expect(id.playlistID == "PLtest123")
|
|
} else {
|
|
Issue.record("Expected playlist destination")
|
|
}
|
|
}
|
|
|
|
@Test("yattee://open with no inner URL routes nowhere")
|
|
func emptyOpenWrapper() {
|
|
#expect(router.route(URL(string: "yattee://open")!) == nil)
|
|
#expect(router.route(URL(string: "yattee://open?url=")!) == nil)
|
|
}
|
|
|
|
// MARK: - Local File Rejection
|
|
|
|
@Test("File URLs never route, directly or wrapped")
|
|
func fileURLsRejected() {
|
|
#expect(router.route(URL(string: "file:///private/var/mobile/movie.mp4")!) == nil)
|
|
#expect(router.route(URL(string: "yattee://open?url=file:///private/var/mobile/movie.mp4")!) == nil)
|
|
}
|
|
|
|
@Test("Remote direct media still routes")
|
|
func remoteDirectMedia() {
|
|
let url = URL(string: "https://example.com/video.mp4")!
|
|
if case .directMedia(let media) = router.route(url) {
|
|
#expect(media == url)
|
|
} else {
|
|
Issue.record("Expected directMedia destination")
|
|
}
|
|
}
|
|
|
|
// MARK: - Legacy (1.x) Scheme Tests
|
|
|
|
@Test("Legacy yattee:// links route like their web equivalents", arguments: [
|
|
"yattee://watch?v=dQw4w9WgXcQ",
|
|
"yattee://www.youtube.com/watch?v=dQw4w9WgXcQ",
|
|
"yattee://youtube.com/watch?v=dQw4w9WgXcQ&t=30",
|
|
"yattee://youtu.be/dQw4w9WgXcQ",
|
|
"yattee://https://www.youtube.com/watch?v=dQw4w9WgXcQ",
|
|
"yattee://shorts/dQw4w9WgXcQ",
|
|
])
|
|
func legacyVideoLinks(_ string: String) {
|
|
if case .video(let source, _) = router.route(URL(string: string)!), case .id(let videoID) = source {
|
|
#expect(videoID.videoID == "dQw4w9WgXcQ")
|
|
} else {
|
|
Issue.record("Expected video destination for \(string)")
|
|
}
|
|
}
|
|
|
|
@Test("Legacy playlist and channel links")
|
|
func legacyPlaylistAndChannel() {
|
|
if case .playlist(.remote(let id, _, _, _)) = router.route(URL(string: "yattee://playlist?list=PLtest123")!) {
|
|
#expect(id.playlistID == "PLtest123")
|
|
} else {
|
|
Issue.record("Expected playlist destination")
|
|
}
|
|
if case .channel(let id, _) = router.route(URL(string: "yattee://www.youtube.com/@somehandle")!) {
|
|
#expect(id == "@somehandle")
|
|
} else {
|
|
Issue.record("Expected channel destination")
|
|
}
|
|
}
|
|
|
|
@Test("Unknown yattee:// hosts still route nowhere")
|
|
func unknownCustomHost() {
|
|
#expect(router.route(URL(string: "yattee://nonsense")!) == nil)
|
|
#expect(router.route(URL(string: "yattee://example.com/page")!) == nil)
|
|
}
|
|
|
|
// 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: - External Host Exclusion Tests
|
|
|
|
@Test("www.duckduckgo.com is excluded from external video routing")
|
|
func wwwDuckDuckGoExcluded() {
|
|
let url = URL(string: "https://www.duckduckgo.com/?q=cat+videos")!
|
|
let destination = router.route(url)
|
|
if case .externalVideo = destination {
|
|
Issue.record("www.duckduckgo.com must not route to externalVideo; yt-dlp extraction should not run for a search engine")
|
|
}
|
|
}
|
|
|
|
@Test("duckduckgo.com bare host remains excluded from external video")
|
|
func duckDuckGoBareExcluded() {
|
|
let url = URL(string: "https://duckduckgo.com/?q=test")!
|
|
let destination = router.route(url)
|
|
if case .externalVideo = destination {
|
|
Issue.record("duckduckgo.com must not route to externalVideo; yt-dlp extraction should not run for a search engine")
|
|
}
|
|
}
|
|
|
|
// 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:// playlist URL with PeerTube source")
|
|
func customSchemePeerTubePlaylist() {
|
|
let url = URL(string: "yattee://playlist/abc123?source=peertube&instance=https://peertube.social")!
|
|
if case .playlist(.remote(let id, _, _, _)) = router.route(url),
|
|
case .federated(_, let instance) = id.source {
|
|
#expect(id.playlistID == "abc123")
|
|
#expect(instance.host == "peertube.social")
|
|
} else {
|
|
Issue.record("Expected federated playlist destination")
|
|
}
|
|
}
|
|
|
|
@Test("PeerTube playlist web URLs route to playlists, not videos", arguments: [
|
|
"https://framatube.org/w/p/abc123",
|
|
"https://framatube.org/video-playlists/abc123",
|
|
])
|
|
func peertubePlaylistURLs(_ string: String) {
|
|
if case .playlist(.remote(let id, _, _, _)) = router.route(URL(string: string)!),
|
|
case .federated(_, let instance) = id.source {
|
|
#expect(id.playlistID == "abc123")
|
|
#expect(instance.host == "framatube.org")
|
|
} else {
|
|
Issue.record("Expected federated playlist destination for \(string)")
|
|
}
|
|
}
|
|
|
|
@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.router.selection == .home)
|
|
#expect(coordinator.router.stack(for: .home).isEmpty)
|
|
}
|
|
|
|
@Test("Navigate pushes onto the selected section's stack")
|
|
func navigateToDestination() {
|
|
let coordinator = NavigationCoordinator()
|
|
let destination = NavigationDestination.video(.id(.global("test123")))
|
|
|
|
coordinator.navigate(to: destination)
|
|
|
|
#expect(coordinator.router.stack(for: .home) == [destination])
|
|
}
|
|
|
|
@Test("Multiple navigations stack in order")
|
|
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"))))
|
|
|
|
#expect(coordinator.router.stack(for: .home) == [
|
|
.video(.id(.global("1"))),
|
|
.video(.id(.global("2"))),
|
|
.video(.id(.global("3"))),
|
|
])
|
|
}
|
|
|
|
@Test("Each section keeps its own stack")
|
|
func perSectionStacks() {
|
|
let coordinator = NavigationCoordinator()
|
|
|
|
coordinator.navigate(to: .video(.id(.global("1"))))
|
|
coordinator.router.select(.search)
|
|
coordinator.navigate(to: .video(.id(.global("2"))))
|
|
|
|
#expect(coordinator.router.stack(for: .home) == [.video(.id(.global("1")))])
|
|
#expect(coordinator.router.stack(for: .search) == [.video(.id(.global("2")))])
|
|
}
|
|
|
|
@Test("Pop to root clears the selected stack only")
|
|
func popToRoot() {
|
|
let coordinator = NavigationCoordinator()
|
|
coordinator.navigate(to: .video(.id(.global("1"))))
|
|
coordinator.router.select(.search)
|
|
coordinator.navigate(to: .video(.id(.global("2"))))
|
|
|
|
coordinator.router.popToRoot()
|
|
|
|
#expect(coordinator.router.stack(for: .search).isEmpty)
|
|
#expect(coordinator.router.stack(for: .home).count == 1)
|
|
}
|
|
|
|
@Test("Navigate to subscriptions selects the feed")
|
|
func navigateToSubscriptions() {
|
|
let coordinator = NavigationCoordinator()
|
|
|
|
coordinator.navigateToSubscriptions()
|
|
|
|
#expect(coordinator.router.selection == .subscriptionsFeed)
|
|
}
|
|
|
|
@Test("Startup tab applies only once")
|
|
func startupTabOnce() {
|
|
let router = NavigationRouter()
|
|
|
|
router.applyStartupTabIfNeeded(.search)
|
|
router.select(.bookmarks)
|
|
router.applyStartupTabIfNeeded(.search)
|
|
|
|
#expect(router.selection == .bookmarks)
|
|
}
|
|
|
|
@Test("A stack that is no longer selected can't clear itself")
|
|
func offscreenStackWriteIgnored() {
|
|
let router = NavigationRouter()
|
|
router.push(.history)
|
|
let homePath = router.path(for: .home)
|
|
|
|
router.select(.bookmarks)
|
|
homePath.wrappedValue = []
|
|
|
|
#expect(router.stack(for: .home) == [.history])
|
|
}
|
|
|
|
@Test("The selected stack still pops")
|
|
func selectedStackPops() {
|
|
let router = NavigationRouter()
|
|
router.push(.history)
|
|
|
|
router.path(for: .home).wrappedValue = []
|
|
|
|
#expect(router.stack(for: .home).isEmpty)
|
|
}
|
|
|
|
@Test("A destination pushed before startup keeps its section")
|
|
func deepLinkBeforeStartup() {
|
|
let coordinator = NavigationCoordinator()
|
|
|
|
coordinator.navigate(to: .video(.id(.global("deep"))))
|
|
coordinator.router.applyStartupTabIfNeeded(.search)
|
|
|
|
#expect(coordinator.router.selection == .home)
|
|
#expect(coordinator.router.stack(for: .home) == [.video(.id(.global("deep")))])
|
|
}
|
|
}
|
|
|
|
// MARK: - NavigationRouter Layout Mapping Tests
|
|
|
|
@Suite("NavigationRouter Layout Mapping Tests")
|
|
@MainActor
|
|
struct NavigationRouterLayoutMappingTests {
|
|
|
|
@Test("Available selection is left alone")
|
|
func availableSelectionUnchanged() {
|
|
let router = NavigationRouter()
|
|
router.select(.bookmarks)
|
|
router.push(.video(.id(.global("1"))))
|
|
|
|
router.ensureSelectionIsAvailable(isAvailable: { _ in true })
|
|
|
|
#expect(router.selection == .bookmarks)
|
|
#expect(router.stack(for: .bookmarks).count == 1)
|
|
}
|
|
|
|
@Test("Sidebar channel maps to the subscriptions tab with the channel pushed first")
|
|
func channelToSubscriptions() {
|
|
let router = NavigationRouter()
|
|
let source = ContentSource.global(provider: ContentSource.youtubeProvider)
|
|
let channel = SidebarItem.channel(channelID: "UC1", name: "Channel", source: source)
|
|
router.select(channel)
|
|
router.push(.video(.id(.global("v1"))))
|
|
|
|
router.ensureSelectionIsAvailable(isAvailable: { [.home, .search, .subscriptionsFeed].contains($0) })
|
|
|
|
#expect(router.selection == .subscriptionsFeed)
|
|
#expect(router.stack(for: .subscriptionsFeed) == [.channel("UC1", source), .video(.id(.global("v1")))])
|
|
#expect(router.stack(for: channel).isEmpty)
|
|
}
|
|
|
|
@Test("Hidden tab falls back to home with its screen pushed")
|
|
func hiddenTabToHome() {
|
|
let router = NavigationRouter()
|
|
router.select(.bookmarks)
|
|
router.push(.video(.id(.global("v1"))))
|
|
|
|
router.ensureSelectionIsAvailable(isAvailable: { [.home, .search].contains($0) })
|
|
|
|
#expect(router.selection == .home)
|
|
#expect(router.stack(for: .home) == [.bookmarks, .video(.id(.global("v1")))])
|
|
}
|
|
|
|
@Test("Sidebar playlist maps to the playlists tab")
|
|
func playlistToPlaylists() {
|
|
let router = NavigationRouter()
|
|
let id = UUID()
|
|
router.select(.playlist(id: id, title: "Mix"))
|
|
|
|
router.ensureSelectionIsAvailable(isAvailable: { [.home, .search, .playlistsList].contains($0) })
|
|
|
|
#expect(router.selection == .playlistsList)
|
|
#expect(router.stack(for: .playlistsList) == [.playlist(.local(id, title: "Mix"))])
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|
|
|
|
// MARK: - LinkActionParser Tests
|
|
|
|
@Suite("LinkActionParser Tests")
|
|
struct LinkActionParserTests {
|
|
let parser = LinkActionParser()
|
|
|
|
private func parse(_ string: String) -> LinkAction? {
|
|
parser.parse(URL(string: string)!)
|
|
}
|
|
|
|
@Test("Encoded and raw url= give the same inner link with its own query")
|
|
func encodedAndRawTargets() {
|
|
let inner = URL(string: "https://www.youtube.com/watch?v=dQw4w9WgXcQ&list=PL123")!
|
|
#expect(parse("yattee://queue?url=https%3A%2F%2Fwww.youtube.com%2Fwatch%3Fv%3DdQw4w9WgXcQ%26list%3DPL123") == .addToQueue(inner))
|
|
#expect(parse("yattee://queue?url=https://www.youtube.com/watch?v=dQw4w9WgXcQ&list=PL123") == .addToQueue(inner))
|
|
}
|
|
|
|
@Test("Play takes the action's t, falling back to the link's own")
|
|
func playStartTime() {
|
|
#expect(parse("yattee://play?url=https://youtu.be/dQw4w9WgXcQ&t=1m30s")
|
|
== .play(URL(string: "https://youtu.be/dQw4w9WgXcQ")!, startTime: 90))
|
|
#expect(parse("yattee://play?url=https%3A%2F%2Fyoutu.be%2FdQw4w9WgXcQ%3Ft%3D42")
|
|
== .play(URL(string: "https://youtu.be/dQw4w9WgXcQ?t=42")!, startTime: 42))
|
|
#expect(parse("yattee://play?url=https://youtu.be/dQw4w9WgXcQ")
|
|
== .play(URL(string: "https://youtu.be/dQw4w9WgXcQ")!, startTime: nil))
|
|
}
|
|
|
|
@Test("x-callback parameters end the raw url")
|
|
func callbackParametersEndURL() {
|
|
let action = parse("yattee://bookmark?url=https://www.youtube.com/watch?v=dQw4w9WgXcQ&x-success=shortcuts://done")
|
|
#expect(action == .bookmark(URL(string: "https://www.youtube.com/watch?v=dQw4w9WgXcQ")!))
|
|
}
|
|
|
|
@Test("Every target verb parses", arguments: [
|
|
("play-next", LinkAction.playNext(URL(string: "https://youtu.be/abc")!)),
|
|
("download", .download(URL(string: "https://youtu.be/abc")!)),
|
|
("subscribe", .subscribe(URL(string: "https://youtu.be/abc")!)),
|
|
("unsubscribe", .unsubscribe(URL(string: "https://youtu.be/abc")!)),
|
|
])
|
|
func targetVerbs(_ verb: String, _ expected: LinkAction) {
|
|
#expect(parse("yattee://\(verb)?url=https://youtu.be/abc") == expected)
|
|
}
|
|
|
|
@Test("Missing, local or non-web targets are rejected")
|
|
func badTargets() {
|
|
#expect(parse("yattee://play") == nil)
|
|
#expect(parse("yattee://play?url=") == nil)
|
|
#expect(parse("yattee://play?url=file:///etc/hosts") == nil)
|
|
#expect(parse("yattee://queue?url=javascript:alert(1)") == nil)
|
|
}
|
|
|
|
@Test("Player commands")
|
|
func playerCommands() {
|
|
#expect(parse("yattee://player/pause") == .player(.pause))
|
|
#expect(parse("yattee://player/play") == .player(.play))
|
|
#expect(parse("yattee://player/toggle") == .player(.toggle))
|
|
#expect(parse("yattee://player/next") == .player(.next))
|
|
#expect(parse("yattee://player/previous") == .player(.previous))
|
|
#expect(parse("yattee://player/close") == .player(.close))
|
|
#expect(parse("yattee://player/seek?t=90") == .player(.seek(to: 90)))
|
|
#expect(parse("yattee://player/seek?t=1m") == .player(.seek(to: 60)))
|
|
#expect(parse("yattee://player/seek?by=-10") == .player(.seekBy(-10)))
|
|
#expect(parse("yattee://player/rate?value=1.5") == .player(.rate(1.5)))
|
|
}
|
|
|
|
@Test("Invalid player commands are rejected")
|
|
func badPlayerCommands() {
|
|
#expect(parse("yattee://player") == nil)
|
|
#expect(parse("yattee://player/explode") == nil)
|
|
#expect(parse("yattee://player/seek") == nil)
|
|
#expect(parse("yattee://player/seek?by=nan") == nil)
|
|
#expect(parse("yattee://player/rate?value=0") == nil)
|
|
#expect(parse("yattee://player/rate?value=inf") == nil)
|
|
}
|
|
|
|
@Test("Screen links are not actions, and the router leaves action hosts alone")
|
|
func separationFromRouter() {
|
|
#expect(parse("yattee://playlist/PL123") == nil)
|
|
#expect(parse("yattee://video/abc") == nil)
|
|
#expect(URLRouter().route(URL(string: "yattee://play?url=https://youtu.be/abc")!) == nil)
|
|
#expect(URLRouter().route(URL(string: "yattee://player/pause")!) == nil)
|
|
}
|
|
|
|
@Test("Only play dismisses sheets")
|
|
func actionTraits() {
|
|
let url = URL(string: "https://youtu.be/abc")!
|
|
#expect(LinkAction.play(url, startTime: nil).dismissesPresentations)
|
|
#expect(!LinkAction.player(.pause).dismissesPresentations)
|
|
#expect(!LinkAction.bookmark(url).dismissesPresentations)
|
|
}
|
|
}
|