react-native-audio-browser 0.2.0-next.32 → 0.2.0-next.33
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ios/Browser/BrowserConfig.swift +0 -5
- package/ios/Browser/BrowserError.swift +16 -0
- package/ios/Browser/BrowserManager.swift +36 -141
- package/ios/Browser/BrowserPathHelper.swift +25 -12
- package/ios/Browser/FavoriteSet.swift +46 -0
- package/ios/Browser/JsonModels.swift +79 -199
- package/ios/Browser/PageResolver.swift +16 -96
- package/ios/Browser/RequestLayer.swift +97 -2
- package/ios/Browser/RequestPipeline.swift +78 -227
- package/ios/Browser/SectionScope.swift +25 -0
- package/ios/Browser/SimpleRouter.swift +73 -144
- package/ios/Extension/SearchParams+Query.swift +28 -0
- package/ios/Extension/Track+Copying.swift +3 -1
- package/ios/Extension/Track+Identity.swift +7 -0
- package/ios/Http/HttpClient.swift +12 -30
- package/ios/HybridAudioBrowser+Browser.swift +5 -21
- package/ios/Model/NitroTypeStubs.swift +65 -0
- package/ios/Tests/BrowserPathHelperTests.swift +33 -0
- package/ios/Tests/FavoriteSetTests.swift +60 -0
- package/ios/Tests/RequestLayerTests.swift +116 -0
- package/ios/Tests/SearchParamsQueryTests.swift +48 -0
- package/ios/Tests/SectionScopeTests.swift +53 -0
- package/ios/Tests/SimpleRouterTests.swift +38 -3
- package/package.json +1 -1
|
@@ -8,11 +8,6 @@ struct RouteMatch {
|
|
|
8
8
|
|
|
9
9
|
/// Specificity score (higher = more specific match)
|
|
10
10
|
let specificity: Int
|
|
11
|
-
|
|
12
|
-
init(params: [String: String] = [:], specificity: Int = 0) {
|
|
13
|
-
self.params = params
|
|
14
|
-
self.specificity = specificity
|
|
15
|
-
}
|
|
16
11
|
}
|
|
17
12
|
|
|
18
13
|
/// Protocol for route entries that can be matched.
|
|
@@ -20,165 +15,99 @@ protocol RouteEntry {
|
|
|
20
15
|
var path: String { get }
|
|
21
16
|
}
|
|
22
17
|
|
|
23
|
-
///
|
|
18
|
+
/// Route pattern matching with parameter extraction.
|
|
24
19
|
///
|
|
25
20
|
/// Supports patterns like:
|
|
26
21
|
/// - /artists - exact match
|
|
27
22
|
/// - /artists/{id} - parameter extraction
|
|
28
|
-
/// - /artists/{id}/albums - multiple segments with parameters
|
|
29
23
|
/// - /artists/* - wildcard match (matches any single segment)
|
|
30
|
-
/// - /files/** - tail wildcard (matches remaining path segments)
|
|
24
|
+
/// - /files/** - tail wildcard (matches remaining path segments, captured as `tail`)
|
|
31
25
|
///
|
|
32
|
-
///
|
|
33
|
-
///
|
|
34
|
-
|
|
35
|
-
/// -
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
///
|
|
41
|
-
///
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
26
|
+
/// The most specific route wins, the first on a tie: constant segments over parameters over
|
|
27
|
+
/// wildcards over the tail wildcard, then more segments.
|
|
28
|
+
enum SimpleRouter {
|
|
29
|
+
/// The route the top-level `browse:` source is flattened into.
|
|
30
|
+
static let defaultRoutePath = "__default__"
|
|
31
|
+
static let tabsRoutePath = "__tabs__"
|
|
32
|
+
static let searchRoutePath = "__search__"
|
|
33
|
+
|
|
34
|
+
/// The route that browses `path`: the best match among the routes that are not tabs, search or
|
|
35
|
+
/// the default, else the default route with `path` as its `path` param.
|
|
36
|
+
static func browseRoute<T: RouteEntry>(for path: String, in routes: [T]) -> (route: T, match: RouteMatch)? {
|
|
37
|
+
let browseRoutes = routes.filter {
|
|
38
|
+
$0.path != tabsRoutePath && $0.path != searchRoutePath && $0.path != defaultRoutePath
|
|
39
|
+
}
|
|
40
|
+
if let match = findBestMatch(path: path, routes: browseRoutes) {
|
|
41
|
+
return match
|
|
42
|
+
}
|
|
43
|
+
guard let defaultRoute = routes.first(where: { $0.path == defaultRoutePath }) else { return nil }
|
|
44
|
+
return (defaultRoute, RouteMatch(params: ["path": path], specificity: 0))
|
|
45
|
+
}
|
|
45
46
|
|
|
47
|
+
/// The most specific of `routes` matching `path`, or nil when none does.
|
|
48
|
+
static func findBestMatch<T: RouteEntry>(path: String, routes: [T]) -> (route: T, match: RouteMatch)? {
|
|
49
|
+
var best: (route: T, match: RouteMatch)?
|
|
46
50
|
for route in routes {
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
}
|
|
51
|
+
guard let match = match(pattern: route.path, path: path) else { continue }
|
|
52
|
+
if best.map({ match.specificity > $0.match.specificity }) ?? true {
|
|
53
|
+
best = (route, match)
|
|
51
54
|
}
|
|
52
55
|
}
|
|
53
|
-
|
|
54
|
-
return bestMatch
|
|
56
|
+
return best
|
|
55
57
|
}
|
|
56
58
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
var parameterSegments = 0
|
|
70
|
-
var wildcardSegments = 0
|
|
71
|
-
var hasTailWildcard = false
|
|
72
|
-
|
|
73
|
-
// Check for tail wildcard at end
|
|
74
|
-
if let lastSegment = patternSegments.last, lastSegment == "**" {
|
|
75
|
-
hasTailWildcard = true
|
|
76
|
-
|
|
77
|
-
// For tail wildcard, pattern must have <= path segments
|
|
78
|
-
if patternSegments.count - 1 > pathSegments.count {
|
|
79
|
-
return nil
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
// Match all segments except the tail wildcard
|
|
83
|
-
for i in 0 ..< (patternSegments.count - 1) {
|
|
84
|
-
guard
|
|
85
|
-
matchSingleSegment(
|
|
86
|
-
patternSegment: patternSegments[i],
|
|
87
|
-
pathSegment: pathSegments[i],
|
|
88
|
-
params: ¶ms,
|
|
89
|
-
)
|
|
90
|
-
else {
|
|
91
|
-
return nil
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
updateSegmentCounts(
|
|
95
|
-
patternSegment: patternSegments[i],
|
|
96
|
-
constantSegments: &constantSegments,
|
|
97
|
-
parameterSegments: ¶meterSegments,
|
|
98
|
-
wildcardSegments: &wildcardSegments,
|
|
99
|
-
)
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
// Capture remaining path segments as "tail" parameter (matches Kotlin behavior)
|
|
103
|
-
if pathSegments.count > patternSegments.count - 1 {
|
|
104
|
-
let remainingSegments = pathSegments.dropFirst(patternSegments.count - 1)
|
|
105
|
-
params["tail"] = remainingSegments.joined(separator: "/")
|
|
106
|
-
}
|
|
107
|
-
} else {
|
|
108
|
-
// Normal matching - must have same number of segments
|
|
109
|
-
if patternSegments.count != pathSegments.count {
|
|
110
|
-
return nil
|
|
59
|
+
private enum Segment {
|
|
60
|
+
case constant(Substring)
|
|
61
|
+
case param(String)
|
|
62
|
+
case wildcard
|
|
63
|
+
|
|
64
|
+
init(_ segment: Substring) {
|
|
65
|
+
if segment.hasPrefix("{"), segment.hasSuffix("}") {
|
|
66
|
+
self = .param(String(segment.dropFirst().dropLast()))
|
|
67
|
+
} else if segment == "*" {
|
|
68
|
+
self = .wildcard
|
|
69
|
+
} else {
|
|
70
|
+
self = .constant(segment)
|
|
111
71
|
}
|
|
72
|
+
}
|
|
112
73
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
params: ¶ms,
|
|
119
|
-
)
|
|
120
|
-
else {
|
|
121
|
-
return nil
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
updateSegmentCounts(
|
|
125
|
-
patternSegment: patternSegments[i],
|
|
126
|
-
constantSegments: &constantSegments,
|
|
127
|
-
parameterSegments: ¶meterSegments,
|
|
128
|
-
wildcardSegments: &wildcardSegments,
|
|
129
|
-
)
|
|
74
|
+
var specificity: Int {
|
|
75
|
+
switch self {
|
|
76
|
+
case .constant: 1000
|
|
77
|
+
case .param: 100
|
|
78
|
+
case .wildcard: 10
|
|
130
79
|
}
|
|
131
80
|
}
|
|
132
|
-
|
|
133
|
-
// Specificity calculation (higher = more specific):
|
|
134
|
-
// - Constants: 1000 points each
|
|
135
|
-
// - Parameters: 100 points each
|
|
136
|
-
// - Single wildcards: 10 points each
|
|
137
|
-
// - Tail wildcard: 1 point
|
|
138
|
-
// - Base path length: segments count
|
|
139
|
-
let specificity =
|
|
140
|
-
(constantSegments * 1000)
|
|
141
|
-
+ (parameterSegments * 100)
|
|
142
|
-
+ (wildcardSegments * 10)
|
|
143
|
-
+ (hasTailWildcard ? 1 : 0)
|
|
144
|
-
+ patternSegments.count
|
|
145
|
-
|
|
146
|
-
return RouteMatch(params: params, specificity: specificity)
|
|
147
81
|
}
|
|
148
82
|
|
|
149
|
-
private func
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
return true
|
|
159
|
-
}
|
|
83
|
+
private static func match(pattern: String, path: String) -> RouteMatch? {
|
|
84
|
+
var patternSegments = pattern.split(separator: "/")
|
|
85
|
+
let hasTailWildcard = patternSegments.last == "**"
|
|
86
|
+
if hasTailWildcard { patternSegments.removeLast() }
|
|
87
|
+
let pathSegments = path.split(separator: "/")
|
|
88
|
+
guard hasTailWildcard
|
|
89
|
+
? pathSegments.count >= patternSegments.count
|
|
90
|
+
: pathSegments.count == patternSegments.count
|
|
91
|
+
else { return nil }
|
|
160
92
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
93
|
+
var params: [String: String] = [:]
|
|
94
|
+
// Each segment counts once more, the tail wildcard twice (its segment and its own point).
|
|
95
|
+
var specificity = patternSegments.count + (hasTailWildcard ? 2 : 0)
|
|
96
|
+
for (patternSegment, pathSegment) in zip(patternSegments, pathSegments) {
|
|
97
|
+
let segment = Segment(patternSegment)
|
|
98
|
+
switch segment {
|
|
99
|
+
case let .constant(constant):
|
|
100
|
+
guard constant == pathSegment else { return nil }
|
|
101
|
+
case let .param(name):
|
|
102
|
+
params[name] = String(pathSegment)
|
|
103
|
+
case .wildcard:
|
|
104
|
+
break
|
|
105
|
+
}
|
|
106
|
+
specificity += segment.specificity
|
|
164
107
|
}
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
return patternSegment == pathSegment
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
private func updateSegmentCounts(
|
|
171
|
-
patternSegment: String,
|
|
172
|
-
constantSegments: inout Int,
|
|
173
|
-
parameterSegments: inout Int,
|
|
174
|
-
wildcardSegments: inout Int,
|
|
175
|
-
) {
|
|
176
|
-
if patternSegment.hasPrefix("{"), patternSegment.hasSuffix("}") {
|
|
177
|
-
parameterSegments += 1
|
|
178
|
-
} else if patternSegment == "*" {
|
|
179
|
-
wildcardSegments += 1
|
|
180
|
-
} else {
|
|
181
|
-
constantSegments += 1
|
|
108
|
+
if hasTailWildcard, pathSegments.count > patternSegments.count {
|
|
109
|
+
params["tail"] = pathSegments.dropFirst(patternSegments.count).joined(separator: "/")
|
|
182
110
|
}
|
|
111
|
+
return RouteMatch(params: params, specificity: specificity)
|
|
183
112
|
}
|
|
184
113
|
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
#if canImport(NitroModules)
|
|
2
|
+
import NitroModules
|
|
3
|
+
#endif
|
|
4
|
+
|
|
5
|
+
extension SearchParams {
|
|
6
|
+
/// The query params this search sends: `q` plus whichever filters are set, as on Android and
|
|
7
|
+
/// web. Also the search's cache identity, so a field added here distinguishes both.
|
|
8
|
+
var queryParams: [String: String] {
|
|
9
|
+
var query: [String: String] = ["q": query]
|
|
10
|
+
if let mode { query["mode"] = mode.stringValue }
|
|
11
|
+
if reference == .my { query["reference"] = "my" }
|
|
12
|
+
if let genre { query["genre"] = genre }
|
|
13
|
+
if let artist { query["artist"] = artist }
|
|
14
|
+
if let album { query["album"] = album }
|
|
15
|
+
if let title { query["title"] = title }
|
|
16
|
+
if let playlist { query["playlist"] = playlist }
|
|
17
|
+
return query
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/// The cache identity: `queryParams`, sorted so key order is stable and `\u{1}`-delimited so
|
|
21
|
+
/// two param sets cannot serialize alike.
|
|
22
|
+
var cacheKey: String {
|
|
23
|
+
queryParams
|
|
24
|
+
.sorted { $0.key < $1.key }
|
|
25
|
+
.map { "\($0.key)=\($0.value)" }
|
|
26
|
+
.joined(separator: "\u{1}")
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -13,27 +13,11 @@ final class HttpClient: @unchecked Sendable {
|
|
|
13
13
|
/// HTTP request configuration (matches Kotlin HttpClient.HttpRequest).
|
|
14
14
|
struct HttpRequest {
|
|
15
15
|
let url: String
|
|
16
|
-
let method:
|
|
16
|
+
let method: HttpMethod
|
|
17
17
|
let headers: [String: String]?
|
|
18
18
|
let body: String?
|
|
19
19
|
let contentType: String
|
|
20
20
|
let userAgent: String
|
|
21
|
-
|
|
22
|
-
init(
|
|
23
|
-
url: String,
|
|
24
|
-
method: String = "GET",
|
|
25
|
-
headers: [String: String]? = nil,
|
|
26
|
-
body: String? = nil,
|
|
27
|
-
contentType: String = HttpClient.defaultContentType,
|
|
28
|
-
userAgent: String = HttpClient.defaultUserAgent,
|
|
29
|
-
) {
|
|
30
|
-
self.url = url
|
|
31
|
-
self.method = method
|
|
32
|
-
self.headers = headers
|
|
33
|
-
self.body = body
|
|
34
|
-
self.contentType = contentType
|
|
35
|
-
self.userAgent = userAgent
|
|
36
|
-
}
|
|
37
21
|
}
|
|
38
22
|
|
|
39
23
|
/// A non-2xx response (matches Kotlin HttpClient.HttpException).
|
|
@@ -55,14 +39,14 @@ final class HttpClient: @unchecked Sendable {
|
|
|
55
39
|
|
|
56
40
|
enum HttpClientError: LocalizedError {
|
|
57
41
|
case invalidUrl(String)
|
|
58
|
-
case unsupportedMethod(
|
|
42
|
+
case unsupportedMethod(HttpMethod)
|
|
59
43
|
case notHttp
|
|
60
44
|
case undecodable(String)
|
|
61
45
|
|
|
62
46
|
var errorDescription: String? {
|
|
63
47
|
switch self {
|
|
64
48
|
case let .invalidUrl(url): "Invalid URL: \(url)"
|
|
65
|
-
case let .unsupportedMethod(method): "Unsupported HTTP method: \(method)"
|
|
49
|
+
case let .unsupportedMethod(method): "Unsupported HTTP method: \(method.stringValue)"
|
|
66
50
|
case .notHttp: "Invalid response type"
|
|
67
51
|
case let .undecodable(reason): reason
|
|
68
52
|
}
|
|
@@ -74,7 +58,7 @@ final class HttpClient: @unchecked Sendable {
|
|
|
74
58
|
}
|
|
75
59
|
|
|
76
60
|
/// Sends `httpRequest` and returns the status code and body, whatever the status.
|
|
77
|
-
func request(_ httpRequest: HttpRequest) async throws -> (code: Int, data: Data) {
|
|
61
|
+
private func request(_ httpRequest: HttpRequest) async throws -> (code: Int, data: Data) {
|
|
78
62
|
guard let url = URL(string: httpRequest.url) else { throw HttpClientError.invalidUrl(httpRequest.url) }
|
|
79
63
|
|
|
80
64
|
var urlRequest = URLRequest(url: url)
|
|
@@ -87,21 +71,19 @@ final class HttpClient: @unchecked Sendable {
|
|
|
87
71
|
urlRequest.setValue(httpRequest.userAgent, forHTTPHeaderField: "User-Agent")
|
|
88
72
|
}
|
|
89
73
|
|
|
90
|
-
|
|
91
|
-
switch method {
|
|
92
|
-
case
|
|
93
|
-
|
|
94
|
-
case
|
|
95
|
-
urlRequest.httpMethod = method
|
|
74
|
+
urlRequest.httpMethod = httpRequest.method.stringValue
|
|
75
|
+
switch httpRequest.method {
|
|
76
|
+
case .get:
|
|
77
|
+
break
|
|
78
|
+
case .post, .put, .patch:
|
|
96
79
|
urlRequest.setValue(httpRequest.contentType, forHTTPHeaderField: "Content-Type")
|
|
97
80
|
urlRequest.httpBody = httpRequest.body?.data(using: .utf8)
|
|
98
|
-
case
|
|
99
|
-
urlRequest.httpMethod = method
|
|
81
|
+
case .delete:
|
|
100
82
|
if let body = httpRequest.body {
|
|
101
83
|
urlRequest.setValue(httpRequest.contentType, forHTTPHeaderField: "Content-Type")
|
|
102
84
|
urlRequest.httpBody = body.data(using: .utf8)
|
|
103
85
|
}
|
|
104
|
-
|
|
86
|
+
case .head, .options:
|
|
105
87
|
throw HttpClientError.unsupportedMethod(httpRequest.method)
|
|
106
88
|
}
|
|
107
89
|
|
|
@@ -143,7 +125,7 @@ final class HttpClient: @unchecked Sendable {
|
|
|
143
125
|
|
|
144
126
|
extension HttpClient {
|
|
145
127
|
private func logRequest(_ request: HttpRequest) {
|
|
146
|
-
logger.debug("→ \(request.method) \(request.url)")
|
|
128
|
+
logger.debug("→ \(request.method.stringValue) \(request.url)")
|
|
147
129
|
for (key, value) in request.headers ?? [:] {
|
|
148
130
|
logger.debug(" \(key): \(value)")
|
|
149
131
|
}
|
|
@@ -93,29 +93,13 @@ public extension HybridAudioBrowser {
|
|
|
93
93
|
}
|
|
94
94
|
|
|
95
95
|
func invalidateAllContent() throws {
|
|
96
|
-
onMainActor {
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
externalContentChangedEmitter.emit(Self.invalidateAllSentinel)
|
|
100
|
-
|
|
101
|
-
// Re-query the tabs source too: a callback source can resolve to new
|
|
102
|
-
// titles after an invalidation (e.g. a locale switch). BrowserManager
|
|
103
|
-
// emits tabsChanged only when the result actually differs, and a failed
|
|
104
|
-
// re-query keeps the current tabs. (Android needs no equivalent — its
|
|
105
|
-
// root re-query already runs queryTabs(); the web stub re-queries tabs on
|
|
106
|
-
// the re-navigation.)
|
|
107
|
-
Task {
|
|
108
|
-
_ = try? await browserManager.queryTabs()
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
// Re-resolve the JS-facing current path with the cache cleared.
|
|
112
|
-
Task { @MainActor in
|
|
113
|
-
do {
|
|
114
|
-
try await browserManager.load(browserManager.path, bypassCache: true)
|
|
115
|
-
} catch {
|
|
116
|
-
await reportNavigationError(error, path: browserManager.path)
|
|
96
|
+
onMainActor {
|
|
97
|
+
browserManager.invalidateAll { error, path in
|
|
98
|
+
await self.reportNavigationError(error, path: path)
|
|
117
99
|
}
|
|
118
100
|
}
|
|
101
|
+
// Tell external controllers (CarPlay) to refresh every displayed template.
|
|
102
|
+
externalContentChangedEmitter.emit(Self.invalidateAllSentinel)
|
|
119
103
|
}
|
|
120
104
|
|
|
121
105
|
func setFavorites(favorites: [String]) throws {
|
|
@@ -110,6 +110,71 @@
|
|
|
110
110
|
case options
|
|
111
111
|
}
|
|
112
112
|
|
|
113
|
+
enum SearchMode {
|
|
114
|
+
case any
|
|
115
|
+
case song
|
|
116
|
+
case playlist
|
|
117
|
+
case station
|
|
118
|
+
case podcast
|
|
119
|
+
case audiobook
|
|
120
|
+
case news
|
|
121
|
+
case music
|
|
122
|
+
case musicVideo
|
|
123
|
+
case movie
|
|
124
|
+
case tvShow
|
|
125
|
+
case tvShowEpisode
|
|
126
|
+
|
|
127
|
+
var stringValue: String {
|
|
128
|
+
switch self {
|
|
129
|
+
case .any: "any"
|
|
130
|
+
case .song: "song"
|
|
131
|
+
case .playlist: "playlist"
|
|
132
|
+
case .station: "station"
|
|
133
|
+
case .podcast: "podcast"
|
|
134
|
+
case .audiobook: "audiobook"
|
|
135
|
+
case .news: "news"
|
|
136
|
+
case .music: "music"
|
|
137
|
+
case .musicVideo: "music-video"
|
|
138
|
+
case .movie: "movie"
|
|
139
|
+
case .tvShow: "tv-show"
|
|
140
|
+
case .tvShowEpisode: "tv-show-episode"
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
enum MediaReference {
|
|
146
|
+
case my
|
|
147
|
+
case unknown
|
|
148
|
+
|
|
149
|
+
var stringValue: String {
|
|
150
|
+
switch self {
|
|
151
|
+
case .my: "my"
|
|
152
|
+
case .unknown: "unknown"
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
struct SearchParams {
|
|
158
|
+
var mode: SearchMode?
|
|
159
|
+
var query: String
|
|
160
|
+
var genre: String?
|
|
161
|
+
var artist: String?
|
|
162
|
+
var album: String?
|
|
163
|
+
var title: String?
|
|
164
|
+
var playlist: String?
|
|
165
|
+
var reference: MediaReference
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
struct ImageContext {
|
|
169
|
+
var width: Double?
|
|
170
|
+
var height: Double?
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
struct ImageQueryParams {
|
|
174
|
+
var width: String?
|
|
175
|
+
var height: String?
|
|
176
|
+
}
|
|
177
|
+
|
|
113
178
|
struct ImageSource: Equatable {
|
|
114
179
|
var uri: String
|
|
115
180
|
var method: HttpMethod?
|
|
@@ -177,3 +177,36 @@ import Testing
|
|
|
177
177
|
#expect(BrowserPathHelper.isSearchPath("/search?q=jazz") == false)
|
|
178
178
|
#expect(BrowserPathHelper.isSearchPath("/__searchable") == false)
|
|
179
179
|
}
|
|
180
|
+
|
|
181
|
+
// MARK: - stampingContextualPaths
|
|
182
|
+
|
|
183
|
+
@Test func stampsPlayableOnlyTracksWithTheirFlatPagePosition() throws {
|
|
184
|
+
let sections = [
|
|
185
|
+
Section(children: [Track(path: "/a", title: "A"), Track(src: "one.mp3", title: "One")]),
|
|
186
|
+
Section(children: [Track(id: "two", src: "two.mp3", title: "Two")]),
|
|
187
|
+
]
|
|
188
|
+
let stamped = try BrowserPathHelper.stampingContextualPaths(sections, parentPath: "/page")
|
|
189
|
+
#expect(stamped.map { $0.children.map(\.path) } == [
|
|
190
|
+
["/a", "/page?__trackId=one.mp3&__index=1"],
|
|
191
|
+
["/page?__trackId=two&__index=2"],
|
|
192
|
+
])
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
@Test func keepsTheSectionsAndATrackThatHasAPath() throws {
|
|
196
|
+
let track = Track(path: "/a", src: "a.mp3", title: "A")
|
|
197
|
+
let sections = [Section(title: "Top", path: "/top", children: [track])]
|
|
198
|
+
#expect(try BrowserPathHelper.stampingContextualPaths(sections, parentPath: "/page") == sections)
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
@Test func stampsBesideAnExistingQuery() throws {
|
|
202
|
+
let sections = [Section(children: [Track(src: "s.mp3", title: "S")])]
|
|
203
|
+
let stamped = try BrowserPathHelper.stampingContextualPaths(sections, parentPath: "/search?q=jazz")
|
|
204
|
+
#expect(stamped[0].children[0].path == "/search?q=jazz&__trackId=s.mp3&__index=0")
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
@Test func rejectsATrackWithNeitherPathNorSrc() {
|
|
208
|
+
let sections = [Section(children: [Track(id: "only-id", title: "Nowhere")])]
|
|
209
|
+
#expect(throws: BrowserError.self) {
|
|
210
|
+
try BrowserPathHelper.stampingContextualPaths(sections, parentPath: "/page")
|
|
211
|
+
}
|
|
212
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import Testing
|
|
2
|
+
|
|
3
|
+
@testable import AudioBrowserTestable
|
|
4
|
+
|
|
5
|
+
struct FavoriteSetTests {
|
|
6
|
+
private func favorites(_ identities: [String], enabled: Bool = true) -> FavoriteSet {
|
|
7
|
+
var set = FavoriteSet()
|
|
8
|
+
set.enabled = enabled
|
|
9
|
+
_ = set.replace(with: identities)
|
|
10
|
+
return set
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
@Test func replaceReportsWhetherTheIdentitiesChanged() {
|
|
14
|
+
var set = FavoriteSet()
|
|
15
|
+
let changes = [["a", "b"], ["b", "a"], ["a", "b", "a"], []].map { set.replace(with: $0) }
|
|
16
|
+
#expect(changes == [true, false, false, true])
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
@Test func hydratesTrueOrFalseByIdentity() {
|
|
20
|
+
let set = favorites(["a", "stable"])
|
|
21
|
+
#expect(set.hydrate(Track(src: "a")).favorited == true)
|
|
22
|
+
#expect(set.hydrate(Track(src: "b")).favorited == false)
|
|
23
|
+
#expect(set.hydrate(Track(id: "stable", src: "x")).favorited == true)
|
|
24
|
+
#expect(set.hydrate(Track(id: "other", src: "a")).favorited == false)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
@Test func leavesATrackAloneWhenDisabled() {
|
|
28
|
+
#expect(favorites(["a"], enabled: false).hydrate(Track(src: "a")).favorited == nil)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
@Test func leavesATrackWithoutIdentityAlone() {
|
|
32
|
+
#expect(favorites(["a"]).hydrate(Track(path: "/browse")).favorited == nil)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
@Test func aFavoritedTheTrackCarriesWins() {
|
|
36
|
+
let set = favorites(["a"])
|
|
37
|
+
#expect(set.hydrate(Track(src: "a", favorited: false)).favorited == false)
|
|
38
|
+
#expect(set.hydrate(Track(src: "b", favorited: true)).favorited == true)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
@Test func aLocalToggleHoldsUntilTheNextReplace() {
|
|
42
|
+
var set = favorites(["a"])
|
|
43
|
+
set.set("b", favorited: true)
|
|
44
|
+
set.set("a", favorited: false)
|
|
45
|
+
#expect(set.hydrate(Track(src: "a")).favorited == false)
|
|
46
|
+
#expect(set.hydrate(Track(src: "b")).favorited == true)
|
|
47
|
+
_ = set.replace(with: ["a"])
|
|
48
|
+
#expect(set.hydrate(Track(src: "b")).favorited == false)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
@Test func hydratesEverySectionOfAPage() {
|
|
52
|
+
let page = ResolvedTrack(
|
|
53
|
+
path: "/p",
|
|
54
|
+
sections: [Section(children: [Track(src: "a")]), Section(children: [Track(src: "b")])],
|
|
55
|
+
title: "P",
|
|
56
|
+
)
|
|
57
|
+
let hydrated = favorites(["b"]).hydrate(page)
|
|
58
|
+
#expect(hydrated.sections?.map { $0.children.map(\.favorited) } == [[false], [true]])
|
|
59
|
+
}
|
|
60
|
+
}
|