Files
yattee/YatteeTests/NavigationTests.swift
Arkadiusz Fal f0ebe9b612 Reach a playlist through the account that owns it
The app could show a server-side playlist and nothing else. Editing was
never the missing part on its own: nothing on screen knew whose playlist
it was looking at, and without an owner there is no credential to fetch it
with, no row identity to remove by, and nobody to send a change to.

So the destination carries the account. Only Invidious ids hint at
ownership — the IVPL prefix — and that heuristic stays for links that
arrive from elsewhere; a Piped playlist looks exactly like any other, so
the places that already know whose list a row came out of now say so. An
owned playlist is then fetched authenticated, which is both the only way
to see a private one and the only way to learn what the server calls its
rows. A playlist read from the public endpoint has no row identities at
all, and that is precisely why nothing about it may be changed: the edit
affordances appear over the authenticated snapshot or not at all.

Rows are paired with entries by walking the two lists together rather
than by index. Entries are a subsequence — a row the server declined to
name is still displayed, it just can never be a target — and matching on
the video alone would be wrong too, since a playlist may hold the same
video twice and each copy is its own row.

Every change goes through the cache, so the refetch that reconciles it
cannot be skipped, and what the view shows afterwards is only ever what
came back. That includes the failures: a removal aimed at a row that has
since moved says so specifically, over a list that has already been
refreshed.

Adding a video now offers every account that can be written to, on every
instance rather than the selected one alone — this is not a browsing
decision. Those rows never claim the video is already there: the list
fetch does not carry a playlist's videos, so membership is unknowable
from here, and a duplicate is the server's business. Capabilities decide
throughout, never the instance type, which is what finally lets a Piped
login list and edit its playlists in the browse tab beside an Invidious
one — and what keeps the visibility picker away from a backend that has
no such setting.

Handoff leaves the account out of its payload deliberately. It assembles
its userInfo key by key, so the added value changes nothing about the
coding either way, but an account is only usable on a device holding its
credential — the receiving device resolves ownership for itself.
2026-09-02 23:44:07 +02:00

718 lines
25 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)
}
// 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:// 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)
}
}