From d45f014daf5c54ef73a7d8db87ac94b9bd3d31bd Mon Sep 17 00:00:00 2001 From: Guille Gonzalez Date: Sun, 29 Aug 2021 08:25:24 +0200 Subject: [PATCH] Restore platform requirements and bump dependencies --- Package.resolved | 12 +- Package.swift | 14 +- .../Core/NetworkImageLoader.swift | 192 ++++---- .../NetworkImage/Core/NetworkImageStore.swift | 104 +++-- Sources/NetworkImage/Core/URLLoader.swift | 76 ++-- .../NetworkImage/SwiftUI/Image+OSImage.swift | 22 +- .../NetworkImage/SwiftUI/NetworkImage.swift | 409 +++++++++--------- .../SwiftUI/NetworkImageStyle.swift | 110 +++-- .../SwiftUI/ResizableNetworkImageStyle.swift | 35 +- Sources/NetworkImage/Unavailable.swift | 44 +- .../NetworkImageLoaderTests.swift | 260 ++++++----- .../NetworkImageStoreTests.swift | 209 +++++---- .../NetworkImageTests/NetworkImageTests.swift | 2 +- 13 files changed, 732 insertions(+), 757 deletions(-) diff --git a/Package.resolved b/Package.resolved index 94517b2..3d06134 100644 --- a/Package.resolved +++ b/Package.resolved @@ -6,8 +6,8 @@ "repositoryURL": "https://github.com/pointfreeco/combine-schedulers", "state": { "branch": null, - "revision": "c37e5ae8012fb654af776cc556ff8ae64398c841", - "version": "0.5.0" + "revision": "6bde3b0063ba8e7537b43744948535ca7e9e0dad", + "version": "0.5.2" } }, { @@ -15,8 +15,8 @@ "repositoryURL": "https://github.com/pointfreeco/swift-snapshot-testing", "state": { "branch": null, - "revision": "c466812aa2e22898f27557e2e780d3aad7a27203", - "version": "1.8.2" + "revision": "f8a9c997c3c1dab4e216a8ec9014e23144cbab37", + "version": "1.9.0" } }, { @@ -24,8 +24,8 @@ "repositoryURL": "https://github.com/pointfreeco/xctest-dynamic-overlay", "state": { "branch": null, - "revision": "603974e3909ad4b48ba04aad7e0ceee4f077a518", - "version": "0.1.0" + "revision": "50a70a9d3583fe228ce672e8923010c8df2deddd", + "version": "0.2.1" } } ] diff --git a/Package.swift b/Package.swift index 05474d6..dcc06f8 100644 --- a/Package.swift +++ b/Package.swift @@ -6,21 +6,21 @@ import PackageDescription let package = Package( name: "NetworkImage", platforms: [ - .macOS(.v10_12), - .iOS(.v11), - .tvOS(.v11), - .watchOS(.v3), + .macOS(.v10_15), + .iOS(.v13), + .tvOS(.v13), + .watchOS(.v6), ], products: [ .library(name: "NetworkImage", targets: ["NetworkImage"]), ], dependencies: [ - .package(url: "https://github.com/pointfreeco/combine-schedulers", from: "0.5.0"), - .package(url: "https://github.com/pointfreeco/xctest-dynamic-overlay", from: "0.1.0"), + .package(url: "https://github.com/pointfreeco/combine-schedulers", from: "0.5.2"), + .package(url: "https://github.com/pointfreeco/xctest-dynamic-overlay", from: "0.2.1"), .package( name: "SnapshotTesting", url: "https://github.com/pointfreeco/swift-snapshot-testing", - from: "1.8.2" + from: "1.9.0" ), ], targets: [ diff --git a/Sources/NetworkImage/Core/NetworkImageLoader.swift b/Sources/NetworkImage/Core/NetworkImageLoader.swift index 9e5bd5b..cde97ad 100644 --- a/Sources/NetworkImage/Core/NetworkImageLoader.swift +++ b/Sources/NetworkImage/Core/NetworkImageLoader.swift @@ -1,118 +1,116 @@ -#if canImport(Combine) - import Combine - import Foundation - import XCTestDynamicOverlay +import Combine +import Foundation +import XCTestDynamicOverlay - /// Loads and caches images. - @available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *) - public struct NetworkImageLoader { - private let _image: (URL) -> AnyPublisher - private let _cachedImage: (URL) -> OSImage? +/// Loads and caches images. +@available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *) +public struct NetworkImageLoader { + private let _image: (URL) -> AnyPublisher + private let _cachedImage: (URL) -> OSImage? - /// Creates an image loader. - /// - Parameters: - /// - urlSession: The `URLSession` that will load the images. - /// - imageCache: An immediate cache to store the images in memory. - public init(urlSession: URLSession, imageCache: NetworkImageCache) { - self.init(urlLoader: URLLoader(urlSession: urlSession), imageCache: imageCache) - } + /// Creates an image loader. + /// - Parameters: + /// - urlSession: The `URLSession` that will load the images. + /// - imageCache: An immediate cache to store the images in memory. + public init(urlSession: URLSession, imageCache: NetworkImageCache) { + self.init(urlLoader: URLLoader(urlSession: urlSession), imageCache: imageCache) + } - init(urlLoader: URLLoader, imageCache: NetworkImageCache) { - self.init( - image: { url in - if let image = imageCache.image(for: url) { - return Just(image) - .setFailureType(to: Error.self) - .eraseToAnyPublisher() - } else { - return urlLoader.dataTaskPublisher(for: url) - .tryMap { data, response in - if let httpResponse = response as? HTTPURLResponse { - guard 200 ..< 300 ~= httpResponse.statusCode else { - throw NetworkImageError.badStatus(httpResponse.statusCode) - } + init(urlLoader: URLLoader, imageCache: NetworkImageCache) { + self.init( + image: { url in + if let image = imageCache.image(for: url) { + return Just(image) + .setFailureType(to: Error.self) + .eraseToAnyPublisher() + } else { + return urlLoader.dataTaskPublisher(for: url) + .tryMap { data, response in + if let httpResponse = response as? HTTPURLResponse { + guard 200 ..< 300 ~= httpResponse.statusCode else { + throw NetworkImageError.badStatus(httpResponse.statusCode) } - - return try decodeImage(from: data) } - .handleEvents(receiveOutput: { image in - imageCache.setImage(image, for: url) - }) - .eraseToAnyPublisher() - } - }, - cachedImage: { url in - imageCache.image(for: url) - } - ) - } - init( - image: @escaping (URL) -> AnyPublisher, - cachedImage: @escaping (URL) -> OSImage? - ) { - _image = image - _cachedImage = cachedImage - } + return try decodeImage(from: data) + } + .handleEvents(receiveOutput: { image in + imageCache.setImage(image, for: url) + }) + .eraseToAnyPublisher() + } + }, + cachedImage: { url in + imageCache.image(for: url) + } + ) + } - /// Returns a publisher that loads an image for a given URL. - public func image(for url: URL) -> AnyPublisher { - _image(url) - } + init( + image: @escaping (URL) -> AnyPublisher, + cachedImage: @escaping (URL) -> OSImage? + ) { + _image = image + _cachedImage = cachedImage + } - /// Returns the cached image for a given URL if there is any. - public func cachedImage(for url: URL) -> OSImage? { - _cachedImage(url) - } + /// Returns a publisher that loads an image for a given URL. + public func image(for url: URL) -> AnyPublisher { + _image(url) } - @available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *) - public extension NetworkImageLoader { - /// The shared singleton image loader. - /// - /// The shared image loader uses the shared `URLCache` and provides - /// reasonable defaults for disk and memory caches. - static let shared = Self(urlSession: .imageLoading, imageCache: NetworkImageCache()) + /// Returns the cached image for a given URL if there is any. + public func cachedImage(for url: URL) -> OSImage? { + _cachedImage(url) } +} - #if DEBUG - @available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *) - public extension NetworkImageLoader { - static func mock

( - url matchingURL: URL, - withResponse response: P - ) -> Self where P: Publisher, P.Output == OSImage, P.Failure == Error { - Self { url in - if url != matchingURL { - XCTFail("\(Self.self).image recevied an unexpected URL: \(url)") - } +@available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *) +public extension NetworkImageLoader { + /// The shared singleton image loader. + /// + /// The shared image loader uses the shared `URLCache` and provides + /// reasonable defaults for disk and memory caches. + static let shared = Self(urlSession: .imageLoading, imageCache: NetworkImageCache()) +} - return response.eraseToAnyPublisher() - } cachedImage: { _ in - nil +#if DEBUG + @available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *) + public extension NetworkImageLoader { + static func mock

( + url matchingURL: URL, + withResponse response: P + ) -> Self where P: Publisher, P.Output == OSImage, P.Failure == Error { + Self { url in + if url != matchingURL { + XCTFail("\(Self.self).image recevied an unexpected URL: \(url)") } + + return response.eraseToAnyPublisher() + } cachedImage: { _ in + nil } + } - static func mock

( - response: P - ) -> Self where P: Publisher, P.Output == OSImage, P.Failure == Error { - Self { _ in - response.eraseToAnyPublisher() - } cachedImage: { _ in - nil - } + static func mock

( + response: P + ) -> Self where P: Publisher, P.Output == OSImage, P.Failure == Error { + Self { _ in + response.eraseToAnyPublisher() + } cachedImage: { _ in + nil } + } - static var failing: Self { - Self { _ in - XCTFail("\(Self.self).image is unimplemented") - return Just(OSImage()) - .setFailureType(to: Error.self) - .eraseToAnyPublisher() - } cachedImage: { _ in - nil - } + static var failing: Self { + Self { _ in + XCTFail("\(Self.self).image is unimplemented") + return Just(OSImage()) + .setFailureType(to: Error.self) + .eraseToAnyPublisher() + } cachedImage: { _ in + nil } } - #endif + } #endif diff --git a/Sources/NetworkImage/Core/NetworkImageStore.swift b/Sources/NetworkImage/Core/NetworkImageStore.swift index 53a8c0d..f1e5dcd 100644 --- a/Sources/NetworkImage/Core/NetworkImageStore.swift +++ b/Sources/NetworkImage/Core/NetworkImageStore.swift @@ -1,64 +1,62 @@ -#if canImport(Combine) - import Combine - import CombineSchedulers - import Foundation +import Combine +import CombineSchedulers +import Foundation - @available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *) - internal struct NetworkImageEnvironment { - var imageLoader: NetworkImageLoader - var mainQueue: AnySchedulerOf - } +@available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *) +internal struct NetworkImageEnvironment { + var imageLoader: NetworkImageLoader + var mainQueue: AnySchedulerOf +} - @available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *) - internal final class NetworkImageStore: ObservableObject { - enum Action { - case onAppear(environment: NetworkImageEnvironment) - case didLoadImage(OSImage) - case didFail - } +@available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *) +internal final class NetworkImageStore: ObservableObject { + enum Action { + case onAppear(environment: NetworkImageEnvironment) + case didLoadImage(OSImage) + case didFail + } - enum State: Equatable { - case notRequested(URL) - case placeholder - case image(OSImage) - case fallback - } + enum State: Equatable { + case notRequested(URL) + case placeholder + case image(OSImage) + case fallback + } - @Published private(set) var state: State - private var cancellables: Set = [] + @Published private(set) var state: State + private var cancellables: Set = [] - init(url: URL?) { - if let url = url { - state = .notRequested(url) - } else { - state = .fallback - } + init(url: URL?) { + if let url = url { + state = .notRequested(url) + } else { + state = .fallback } + } - func send(_ action: Action) { - switch action { - case let .onAppear(environment): - guard case let .notRequested(url) = state else { - return - } - if let image = environment.imageLoader.cachedImage(for: url) { - state = .image(image) - } else { - state = .placeholder - environment.imageLoader.image(for: url) - .map { .didLoadImage($0) } - .replaceError(with: .didFail) - .receive(on: environment.mainQueue) - .sink { [weak self] action in - self?.send(action) - } - .store(in: &cancellables) - } - case let .didLoadImage(image): + func send(_ action: Action) { + switch action { + case let .onAppear(environment): + guard case let .notRequested(url) = state else { + return + } + if let image = environment.imageLoader.cachedImage(for: url) { state = .image(image) - case .didFail: - state = .fallback + } else { + state = .placeholder + environment.imageLoader.image(for: url) + .map { .didLoadImage($0) } + .replaceError(with: .didFail) + .receive(on: environment.mainQueue) + .sink { [weak self] action in + self?.send(action) + } + .store(in: &cancellables) } + case let .didLoadImage(image): + state = .image(image) + case .didFail: + state = .fallback } } -#endif +} diff --git a/Sources/NetworkImage/Core/URLLoader.swift b/Sources/NetworkImage/Core/URLLoader.swift index 211bd4a..b065af5 100644 --- a/Sources/NetworkImage/Core/URLLoader.swift +++ b/Sources/NetworkImage/Core/URLLoader.swift @@ -1,52 +1,50 @@ -#if canImport(Combine) - import Combine - import Foundation - import XCTestDynamicOverlay +import Combine +import Foundation +import XCTestDynamicOverlay - @available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *) - internal struct URLLoader { - private let _dataTaskPublisher: (URL) -> AnyPublisher<(data: Data, response: URLResponse), URLError> +@available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *) +internal struct URLLoader { + private let _dataTaskPublisher: (URL) -> AnyPublisher<(data: Data, response: URLResponse), URLError> - init(dataTaskPublisher: @escaping (URL) -> AnyPublisher<(data: Data, response: URLResponse), URLError>) { - _dataTaskPublisher = dataTaskPublisher - } + init(dataTaskPublisher: @escaping (URL) -> AnyPublisher<(data: Data, response: URLResponse), URLError>) { + _dataTaskPublisher = dataTaskPublisher + } - init(urlSession: URLSession) { - self.init { url in - urlSession.dataTaskPublisher(for: url) - .eraseToAnyPublisher() - } + init(urlSession: URLSession) { + self.init { url in + urlSession.dataTaskPublisher(for: url) + .eraseToAnyPublisher() } + } - func dataTaskPublisher(for url: URL) -> AnyPublisher<(data: Data, response: URLResponse), URLError> { - _dataTaskPublisher(url) - } + func dataTaskPublisher(for url: URL) -> AnyPublisher<(data: Data, response: URLResponse), URLError> { + _dataTaskPublisher(url) } +} - #if DEBUG - @available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *) - extension URLLoader { - static func mock

( - url matchingURL: URL, - withResponse response: P - ) -> Self where P: Publisher, P.Output == (data: Data, response: HTTPURLResponse), P.Failure == URLError { - Self { url in - if url != matchingURL { - XCTFail("\(Self.self).dataTaskPublisher received an unexpected URL: \(url)") - } - return response - .map { ($0, $1 as URLResponse) } - .eraseToAnyPublisher() +#if DEBUG + @available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *) + extension URLLoader { + static func mock

( + url matchingURL: URL, + withResponse response: P + ) -> Self where P: Publisher, P.Output == (data: Data, response: HTTPURLResponse), P.Failure == URLError { + Self { url in + if url != matchingURL { + XCTFail("\(Self.self).dataTaskPublisher received an unexpected URL: \(url)") } + return response + .map { ($0, $1 as URLResponse) } + .eraseToAnyPublisher() } + } - static var failing: Self { - Self { _ in - XCTFail("\(Self.self).dataTaskPublisher is unimplemented") - return Fail(error: URLError(.notConnectedToInternet)) - .eraseToAnyPublisher() - } + static var failing: Self { + Self { _ in + XCTFail("\(Self.self).dataTaskPublisher is unimplemented") + return Fail(error: URLError(.notConnectedToInternet)) + .eraseToAnyPublisher() } } - #endif + } #endif diff --git a/Sources/NetworkImage/SwiftUI/Image+OSImage.swift b/Sources/NetworkImage/SwiftUI/Image+OSImage.swift index d77c1e3..7002140 100644 --- a/Sources/NetworkImage/SwiftUI/Image+OSImage.swift +++ b/Sources/NetworkImage/SwiftUI/Image+OSImage.swift @@ -1,14 +1,12 @@ -#if canImport(SwiftUI) - import SwiftUI +import SwiftUI - @available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *) - public extension Image { - init(osImage: OSImage) { - #if os(iOS) || os(tvOS) || os(watchOS) - self.init(uiImage: osImage) - #elseif os(macOS) - self.init(nsImage: osImage) - #endif - } +@available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *) +public extension Image { + init(osImage: OSImage) { + #if os(iOS) || os(tvOS) || os(watchOS) + self.init(uiImage: osImage) + #elseif os(macOS) + self.init(nsImage: osImage) + #endif } -#endif +} diff --git a/Sources/NetworkImage/SwiftUI/NetworkImage.swift b/Sources/NetworkImage/SwiftUI/NetworkImage.swift index 5c3e01a..d701b1f 100644 --- a/Sources/NetworkImage/SwiftUI/NetworkImage.swift +++ b/Sources/NetworkImage/SwiftUI/NetworkImage.swift @@ -1,229 +1,226 @@ -#if canImport(SwiftUI) - import CombineSchedulers - import SwiftUI - - /// A view that displays an image located at a given URL. - /// - /// A network image downloads and displays an image from a given URL; the download is asynchronous, - /// and the result is cached both in disk and memory. - /// - /// You create a network image, in its simplest form, by providing the image URL. - /// - /// NetworkImage(url: URL(string: "https://picsum.photos/id/237/300/200")) - /// - /// You can also provide the name of a placeholder image that the view will display while the image is loading or, as - /// a fallback, if an error occurs or the URL is `nil`. - /// - /// NetworkImage(url: URL(string: "https://picsum.photos/id/237/300/200"), - /// placeholderSystemImage: "photo.fill") - /// - /// If you want, you can only provide a fallback image. A network image view only displays this image if an error occurs - /// or when the URL is `nil`. - /// - /// NetworkImage(url: URL(string: "https://picsum.photos/id/237/300/200"), - /// fallbackSystemImage: "photo.fill") - /// - /// It is also possible to create network images using views to compose the network image's placeholders - /// programmatically. - /// - /// NetworkImage(url: movie.posterURL) { - /// ProgressView() - /// } fallback: { - /// Text(movie.title) - /// .padding() - /// } - /// - /// ### Styling Network Images - /// - /// You can customize the appearance of network images by creating styles that conform to the - /// `NetworkImageStyle` protocol. To set a specific style for all network images within a view, use - /// the `networkImageStyle(_:)` modifier. In the following example, a custom style adds a grayscale - /// effect to all the network image views within the enclosing `VStack`: - /// - /// struct ContentView: View { - /// var body: some View { - /// VStack { - /// NetworkImage(url: URL(string: "https://picsum.photos/id/1025/300/200")) - /// NetworkImage(url: URL(string: "https://picsum.photos/id/237/300/200")) - /// } - /// .networkImageStyle(GrayscaleNetworkImageStyle()) - /// } - /// } - /// - /// struct GrayscaleNetworkImageStyle: NetworkImageStyle { - /// func makeBody(configuration: Configuration) -> some View { - /// configuration.image - /// .resizable() - /// .grayscale(0.99) - /// } - /// } - /// - @available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *) - public struct NetworkImage: View where Placeholder: View, Fallback: View { - @Environment(\.networkImageStyle) private var imageStyle - @Environment(\.networkImageLoader) private var imageLoader - @Environment(\.networkImageScheduler) private var imageScheduler - - @ObservedObject private var store: NetworkImageStore - private let placeholder: Placeholder - private let fallback: Fallback - - /// Creates a network image with custom placeholders. - /// - Parameters: - /// - url: The URL where the image is located. - /// - placeholder: A view builder that creates the view to display while the image is loading. - /// - fallback: A view builder that creates the view to display when the URL is `nil` or an error has occurred. - public init( - url: URL?, - @ViewBuilder placeholder: () -> Placeholder, - @ViewBuilder fallback: () -> Fallback - ) { - store = NetworkImageStore(url: url) - self.placeholder = placeholder() - self.fallback = fallback() - } +import CombineSchedulers +import SwiftUI + +/// A view that displays an image located at a given URL. +/// +/// A network image downloads and displays an image from a given URL; the download is asynchronous, +/// and the result is cached both in disk and memory. +/// +/// You create a network image, in its simplest form, by providing the image URL. +/// +/// NetworkImage(url: URL(string: "https://picsum.photos/id/237/300/200")) +/// +/// You can also provide the name of a placeholder image that the view will display while the image is loading or, as +/// a fallback, if an error occurs or the URL is `nil`. +/// +/// NetworkImage(url: URL(string: "https://picsum.photos/id/237/300/200"), +/// placeholderSystemImage: "photo.fill") +/// +/// If you want, you can only provide a fallback image. A network image view only displays this image if an error occurs +/// or when the URL is `nil`. +/// +/// NetworkImage(url: URL(string: "https://picsum.photos/id/237/300/200"), +/// fallbackSystemImage: "photo.fill") +/// +/// It is also possible to create network images using views to compose the network image's placeholders +/// programmatically. +/// +/// NetworkImage(url: movie.posterURL) { +/// ProgressView() +/// } fallback: { +/// Text(movie.title) +/// .padding() +/// } +/// +/// ### Styling Network Images +/// +/// You can customize the appearance of network images by creating styles that conform to the +/// `NetworkImageStyle` protocol. To set a specific style for all network images within a view, use +/// the `networkImageStyle(_:)` modifier. In the following example, a custom style adds a grayscale +/// effect to all the network image views within the enclosing `VStack`: +/// +/// struct ContentView: View { +/// var body: some View { +/// VStack { +/// NetworkImage(url: URL(string: "https://picsum.photos/id/1025/300/200")) +/// NetworkImage(url: URL(string: "https://picsum.photos/id/237/300/200")) +/// } +/// .networkImageStyle(GrayscaleNetworkImageStyle()) +/// } +/// } +/// +/// struct GrayscaleNetworkImageStyle: NetworkImageStyle { +/// func makeBody(configuration: Configuration) -> some View { +/// configuration.image +/// .resizable() +/// .grayscale(0.99) +/// } +/// } +/// +@available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *) +public struct NetworkImage: View where Placeholder: View, Fallback: View { + @Environment(\.networkImageStyle) private var imageStyle + @Environment(\.networkImageLoader) private var imageLoader + @Environment(\.networkImageScheduler) private var imageScheduler + + @ObservedObject private var store: NetworkImageStore + private let placeholder: Placeholder + private let fallback: Fallback + + /// Creates a network image with custom placeholders. + /// - Parameters: + /// - url: The URL where the image is located. + /// - placeholder: A view builder that creates the view to display while the image is loading. + /// - fallback: A view builder that creates the view to display when the URL is `nil` or an error has occurred. + public init( + url: URL?, + @ViewBuilder placeholder: () -> Placeholder, + @ViewBuilder fallback: () -> Fallback + ) { + store = NetworkImageStore(url: url) + self.placeholder = placeholder() + self.fallback = fallback() + } - /// Creates a network image that displays a placeholder image while the image is loading or as a fallback. - /// - Parameters: - /// - url: The URL where the image is located. - /// - placeholderImage: The name of the placeholder image resource. - public init(url: URL?, placeholderImage name: String) where Placeholder == Image, Fallback == Image { - store = NetworkImageStore(url: url) - placeholder = Image(name) - fallback = Image(name) - } + /// Creates a network image that displays a placeholder image while the image is loading or as a fallback. + /// - Parameters: + /// - url: The URL where the image is located. + /// - placeholderImage: The name of the placeholder image resource. + public init(url: URL?, placeholderImage name: String) where Placeholder == Image, Fallback == Image { + store = NetworkImageStore(url: url) + placeholder = Image(name) + fallback = Image(name) + } - /// Creates a network image that displays a placeholder system image while the image is loading or as a fallback. - /// - Parameters: - /// - url: The URL where the image is located. - /// - placeholderSystemImage: The name of the system image that will be used as a placeholder. - @available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) - public init(url: URL?, placeholderSystemImage name: String) where Placeholder == Image, Fallback == Image { - store = NetworkImageStore(url: url) - placeholder = Image(systemName: name) - fallback = Image(systemName: name) - } + /// Creates a network image that displays a placeholder system image while the image is loading or as a fallback. + /// - Parameters: + /// - url: The URL where the image is located. + /// - placeholderSystemImage: The name of the system image that will be used as a placeholder. + @available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) + public init(url: URL?, placeholderSystemImage name: String) where Placeholder == Image, Fallback == Image { + store = NetworkImageStore(url: url) + placeholder = Image(systemName: name) + fallback = Image(systemName: name) + } - public var body: some View { - switch store.state { - case .notRequested, .placeholder: - Color.clear - .overlay(placeholder) - .onAppear { - store.send( - .onAppear( - environment: .init( - imageLoader: imageLoader, - mainQueue: imageScheduler - ) + public var body: some View { + switch store.state { + case .notRequested, .placeholder: + Color.clear + .overlay(placeholder) + .onAppear { + store.send( + .onAppear( + environment: .init( + imageLoader: imageLoader, + mainQueue: imageScheduler ) ) - } - case let .image(osImage): - imageStyle.makeBody( - configuration: NetworkImageStyleConfiguration( - image: Image(osImage: osImage), - size: osImage.size ) + } + case let .image(osImage): + imageStyle.makeBody( + configuration: NetworkImageStyleConfiguration( + image: Image(osImage: osImage), + size: osImage.size ) - case .fallback: - fallback - } + ) + case .fallback: + fallback } } - - @available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *) - public extension NetworkImage where Fallback == EmptyView { - /// Creates a network image without placeholders. - /// - Parameter url: The URL where the image is located. - init(url: URL?) where Placeholder == EmptyView { - store = NetworkImageStore(url: url) - placeholder = EmptyView() - fallback = EmptyView() - } - - /// Creates a network image that displays a custom placeholder while the image is loading. - /// - Parameters: - /// - url: The URL where the image is located. - /// - placeholder: A view builder that creates the view to display while the image is loading. - init(url: URL?, @ViewBuilder placeholder: () -> Placeholder) { - store = NetworkImageStore(url: url) - self.placeholder = placeholder() - fallback = EmptyView() - } +} + +@available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *) +public extension NetworkImage where Fallback == EmptyView { + /// Creates a network image without placeholders. + /// - Parameter url: The URL where the image is located. + init(url: URL?) where Placeholder == EmptyView { + store = NetworkImageStore(url: url) + placeholder = EmptyView() + fallback = EmptyView() } - @available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *) - public extension NetworkImage where Placeholder == EmptyView { - /// Creates a network image with a fallback view. - /// - Parameters: - /// - url: The URL where the image is located. - /// - fallback: A view builder that creates the view to display when the URL is `nil` or an error has occurred. - init(url: URL?, @ViewBuilder fallback: () -> Fallback) { - store = NetworkImageStore(url: url) - placeholder = EmptyView() - self.fallback = fallback() - } - - /// Creates a network image with a fallback image. - /// - Parameters: - /// - url: The URL where the image is located. - /// - fallbackImage: The name of the image resource to display when the URL is `nil` or an error has occurred. - init(url: URL?, fallbackImage name: String) where Fallback == Image { - store = NetworkImageStore(url: url) - placeholder = EmptyView() - fallback = Image(name) - } - - /// Creates a network image with a fallback system image. - /// - Parameters: - /// - url: The URL where the image is located. - /// - fallbackSystemImage: The name of the system image to display when the URL is `nil` - /// or an error has occurred. - @available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) - init(url: URL?, fallbackSystemImage name: String) where Fallback == Image { - store = NetworkImageStore(url: url) - placeholder = EmptyView() - fallback = Image(systemName: name) - } + /// Creates a network image that displays a custom placeholder while the image is loading. + /// - Parameters: + /// - url: The URL where the image is located. + /// - placeholder: A view builder that creates the view to display while the image is loading. + init(url: URL?, @ViewBuilder placeholder: () -> Placeholder) { + store = NetworkImageStore(url: url) + self.placeholder = placeholder() + fallback = EmptyView() + } +} + +@available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *) +public extension NetworkImage where Placeholder == EmptyView { + /// Creates a network image with a fallback view. + /// - Parameters: + /// - url: The URL where the image is located. + /// - fallback: A view builder that creates the view to display when the URL is `nil` or an error has occurred. + init(url: URL?, @ViewBuilder fallback: () -> Fallback) { + store = NetworkImageStore(url: url) + placeholder = EmptyView() + self.fallback = fallback() } - @available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *) - public extension View { - #if DEBUG - /// Sets the image loader for network images within this view. - func networkImageLoader(_ networkImageLoader: NetworkImageLoader) -> some View { - environment(\.networkImageLoader, networkImageLoader) - } - #endif - - /// Sets the scheduler for network images within this view. - func networkImageScheduler(_ networkImageScheduler: AnySchedulerOf) -> some View { - environment(\.networkImageScheduler, networkImageScheduler) - } + /// Creates a network image with a fallback image. + /// - Parameters: + /// - url: The URL where the image is located. + /// - fallbackImage: The name of the image resource to display when the URL is `nil` or an error has occurred. + init(url: URL?, fallbackImage name: String) where Fallback == Image { + store = NetworkImageStore(url: url) + placeholder = EmptyView() + fallback = Image(name) } - @available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *) - public extension EnvironmentValues { - var networkImageLoader: NetworkImageLoader { - get { self[NetworkImageLoaderKey.self] } - set { self[NetworkImageLoaderKey.self] = newValue } + /// Creates a network image with a fallback system image. + /// - Parameters: + /// - url: The URL where the image is located. + /// - fallbackSystemImage: The name of the system image to display when the URL is `nil` + /// or an error has occurred. + @available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) + init(url: URL?, fallbackSystemImage name: String) where Fallback == Image { + store = NetworkImageStore(url: url) + placeholder = EmptyView() + fallback = Image(systemName: name) + } +} + +@available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *) +public extension View { + #if DEBUG + /// Sets the image loader for network images within this view. + func networkImageLoader(_ networkImageLoader: NetworkImageLoader) -> some View { + environment(\.networkImageLoader, networkImageLoader) } + #endif - var networkImageScheduler: AnySchedulerOf { - get { self[NetworkImageSchedulerKey.self] } - set { self[NetworkImageSchedulerKey.self] = newValue } - } + /// Sets the scheduler for network images within this view. + func networkImageScheduler(_ networkImageScheduler: AnySchedulerOf) -> some View { + environment(\.networkImageScheduler, networkImageScheduler) } +} - @available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *) - private struct NetworkImageLoaderKey: EnvironmentKey { - static let defaultValue: NetworkImageLoader = .shared +@available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *) +public extension EnvironmentValues { + var networkImageLoader: NetworkImageLoader { + get { self[NetworkImageLoaderKey.self] } + set { self[NetworkImageLoaderKey.self] = newValue } } - @available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *) - private struct NetworkImageSchedulerKey: EnvironmentKey { - static let defaultValue: AnySchedulerOf = .main.animation(.default) + var networkImageScheduler: AnySchedulerOf { + get { self[NetworkImageSchedulerKey.self] } + set { self[NetworkImageSchedulerKey.self] = newValue } } +} + +@available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *) +private struct NetworkImageLoaderKey: EnvironmentKey { + static let defaultValue: NetworkImageLoader = .shared +} -#endif +@available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *) +private struct NetworkImageSchedulerKey: EnvironmentKey { + static let defaultValue: AnySchedulerOf = .main.animation(.default) +} diff --git a/Sources/NetworkImage/SwiftUI/NetworkImageStyle.swift b/Sources/NetworkImage/SwiftUI/NetworkImageStyle.swift index 3789740..a7b6685 100644 --- a/Sources/NetworkImage/SwiftUI/NetworkImageStyle.swift +++ b/Sources/NetworkImage/SwiftUI/NetworkImageStyle.swift @@ -1,71 +1,69 @@ -#if canImport(SwiftUI) - import SwiftUI +import SwiftUI - /// A type that applies a custom appearance to all network images within a view hierarchy. +/// A type that applies a custom appearance to all network images within a view hierarchy. +/// +/// To configure the current network image style for a view hierarchy, use the `networkImageStyle(_:)` +/// modifier and specify a style that conforms to `NetworkImageStyle`. +@available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *) +public protocol NetworkImageStyle { + /// A view that represents the body of a network image. + associatedtype Body: View + + /// Creates a view that represents the body of a network image. + /// + /// The system calls this method for each `NetworkImage` instance in a view + /// hierarchy where this style is the current network image style. /// - /// To configure the current network image style for a view hierarchy, use the `networkImageStyle(_:)` - /// modifier and specify a style that conforms to `NetworkImageStyle`. - @available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *) - public protocol NetworkImageStyle { - /// A view that represents the body of a network image. - associatedtype Body: View + /// - Parameter configuration: The properties of a network image, such as + /// the actual image and its logical size. + func makeBody(configuration: Self.Configuration) -> Body - /// Creates a view that represents the body of a network image. - /// - /// The system calls this method for each `NetworkImage` instance in a view - /// hierarchy where this style is the current network image style. - /// - /// - Parameter configuration: The properties of a network image, such as - /// the actual image and its logical size. - func makeBody(configuration: Self.Configuration) -> Body + /// A type alias for the properties of a network image view instance. + typealias Configuration = NetworkImageStyleConfiguration +} - /// A type alias for the properties of a network image view instance. - typealias Configuration = NetworkImageStyleConfiguration - } +/// The properties of a network image view instance. +@available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *) +public struct NetworkImageStyleConfiguration { + /// The image presented by the network image view. + public var image: Image - /// The properties of a network image view instance. - @available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *) - public struct NetworkImageStyleConfiguration { - /// The image presented by the network image view. - public var image: Image - - /// The logical dimensions, in points, for the image. - public var size: CGSize - } + /// The logical dimensions, in points, for the image. + public var size: CGSize +} - @available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *) - public extension View { - /// Sets the style for network images within this view. - func networkImageStyle(_ networkImageStyle: S) -> some View where S: NetworkImageStyle { - environment(\.networkImageStyle, AnyNetworkImageStyle(networkImageStyle)) - } +@available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *) +public extension View { + /// Sets the style for network images within this view. + func networkImageStyle(_ networkImageStyle: S) -> some View where S: NetworkImageStyle { + environment(\.networkImageStyle, AnyNetworkImageStyle(networkImageStyle)) } +} - @available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *) - struct AnyNetworkImageStyle: NetworkImageStyle { - private let _makeBody: (Configuration) -> AnyView - - init(_ networkImageStyle: S) where S: NetworkImageStyle { - _makeBody = { - AnyView(networkImageStyle.makeBody(configuration: $0)) - } - } +@available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *) +struct AnyNetworkImageStyle: NetworkImageStyle { + private let _makeBody: (Configuration) -> AnyView - func makeBody(configuration: Configuration) -> AnyView { - _makeBody(configuration) + init(_ networkImageStyle: S) where S: NetworkImageStyle { + _makeBody = { + AnyView(networkImageStyle.makeBody(configuration: $0)) } } - @available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *) - extension EnvironmentValues { - var networkImageStyle: AnyNetworkImageStyle { - get { self[NetworkImageStyleKey.self] } - set { self[NetworkImageStyleKey.self] = newValue } - } + func makeBody(configuration: Configuration) -> AnyView { + _makeBody(configuration) } +} - @available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *) - private struct NetworkImageStyleKey: EnvironmentKey { - static let defaultValue = AnyNetworkImageStyle(ResizableNetworkImageStyle()) +@available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *) +extension EnvironmentValues { + var networkImageStyle: AnyNetworkImageStyle { + get { self[NetworkImageStyleKey.self] } + set { self[NetworkImageStyleKey.self] = newValue } } -#endif +} + +@available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *) +private struct NetworkImageStyleKey: EnvironmentKey { + static let defaultValue = AnyNetworkImageStyle(ResizableNetworkImageStyle()) +} diff --git a/Sources/NetworkImage/SwiftUI/ResizableNetworkImageStyle.swift b/Sources/NetworkImage/SwiftUI/ResizableNetworkImageStyle.swift index 8905976..1803eb2 100644 --- a/Sources/NetworkImage/SwiftUI/ResizableNetworkImageStyle.swift +++ b/Sources/NetworkImage/SwiftUI/ResizableNetworkImageStyle.swift @@ -1,21 +1,20 @@ -#if canImport(SwiftUI) - import SwiftUI - /// A network image style that applies the `resizable()` modifier to the image. +import SwiftUI + +/// A network image style that applies the `resizable()` modifier to the image. +/// +/// To apply this style to a network image, or to a view that contains network images, +/// use the `networkImageStyle(_:)` modifier. +@available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *) +public struct ResizableNetworkImageStyle: NetworkImageStyle { + /// Creates a view that represents the body of a network image. + /// + /// The system calls this method for each `NetworkImage` instance in a view + /// hierarchy where this style is the current network image style. /// - /// To apply this style to a network image, or to a view that contains network images, - /// use the `networkImageStyle(_:)` modifier. - @available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *) - public struct ResizableNetworkImageStyle: NetworkImageStyle { - /// Creates a view that represents the body of a network image. - /// - /// The system calls this method for each `NetworkImage` instance in a view - /// hierarchy where this style is the current network image style. - /// - /// - Parameter configuration: The properties of a network image, such as - /// the actual image and its logical size. - public func makeBody(configuration: Configuration) -> some View { - configuration.image.resizable() - } + /// - Parameter configuration: The properties of a network image, such as + /// the actual image and its logical size. + public func makeBody(configuration: Configuration) -> some View { + configuration.image.resizable() } -#endif +} diff --git a/Sources/NetworkImage/Unavailable.swift b/Sources/NetworkImage/Unavailable.swift index 46a490b..0fde92f 100644 --- a/Sources/NetworkImage/Unavailable.swift +++ b/Sources/NetworkImage/Unavailable.swift @@ -1,3 +1,4 @@ +import Combine import Foundation @available(*, unavailable, renamed: "NetworkImageCache") @@ -15,34 +16,25 @@ public final class ImmediateImageCache: ImageCache { public func setImage(_: OSImage, for _: URL) {} } -#if canImport(Combine) - import Combine +@available(*, unavailable, renamed: "NetworkImageLoader") +@available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *) +public final class ImageDownloader { + public static let shared = ImageDownloader( + session: .imageLoading, + imageCache: ImmediateImageCache() + ) - @available(*, unavailable, renamed: "NetworkImageLoader") - @available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *) - public final class ImageDownloader { - public static let shared = ImageDownloader( - session: .imageLoading, - imageCache: ImmediateImageCache() - ) + public init(session _: URLSession, imageCache _: ImageCache) {} - public init(session _: URLSession, imageCache _: ImageCache) {} - - public func image(for _: URL) -> AnyPublisher { - fatalError("Unavailable") - } + public func image(for _: URL) -> AnyPublisher { + fatalError("Unavailable") } -#endif - -#if canImport(SwiftUI) - import CombineSchedulers - import SwiftUI +} - @available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) - public extension NetworkImage { - @available(*, unavailable, renamed: "networkImageScheduler") - func synchronous() -> NetworkImage { - fatalError("Unavailable") - } +@available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) +public extension NetworkImage { + @available(*, unavailable, renamed: "networkImageScheduler") + func synchronous() -> NetworkImage { + fatalError("Unavailable") } -#endif +} diff --git a/Tests/NetworkImageTests/NetworkImageLoaderTests.swift b/Tests/NetworkImageTests/NetworkImageLoaderTests.swift index 47ff685..69f7e96 100644 --- a/Tests/NetworkImageTests/NetworkImageLoaderTests.swift +++ b/Tests/NetworkImageTests/NetworkImageLoaderTests.swift @@ -1,150 +1,148 @@ -#if canImport(Combine) - import Combine - import XCTest +import Combine +import XCTest - @testable import NetworkImage +@testable import NetworkImage - @available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *) - final class NetworkImageLoaderTests: XCTestCase { - private var cancellables = Set() +@available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *) +final class NetworkImageLoaderTests: XCTestCase { + private var cancellables = Set() - override func tearDownWithError() throws { - cancellables.removeAll() - } + override func tearDownWithError() throws { + cancellables.removeAll() + } - func testImageLoadsAndCachesImage() throws { - // given - let imageCache = NetworkImageCache() - let imageLoader = NetworkImageLoader( - urlLoader: .mock( - url: Fixtures.anyImageURL, - withResponse: Just( - ( - data: Fixtures.anyImageResponse, - response: HTTPURLResponse( - url: Fixtures.anyImageURL, - statusCode: 200, - httpVersion: "HTTP/1.1", - headerFields: nil - )! - ) + func testImageLoadsAndCachesImage() throws { + // given + let imageCache = NetworkImageCache() + let imageLoader = NetworkImageLoader( + urlLoader: .mock( + url: Fixtures.anyImageURL, + withResponse: Just( + ( + data: Fixtures.anyImageResponse, + response: HTTPURLResponse( + url: Fixtures.anyImageURL, + statusCode: 200, + httpVersion: "HTTP/1.1", + headerFields: nil + )! ) - .setFailureType(to: URLError.self) - ), - imageCache: imageCache - ) + ) + .setFailureType(to: URLError.self) + ), + imageCache: imageCache + ) - // when - var result: OSImage? - imageLoader.image(for: Fixtures.anyImageURL) - .assertNoFailure() - .sink(receiveValue: { - result = $0 - }) - .store(in: &cancellables) + // when + var result: OSImage? + imageLoader.image(for: Fixtures.anyImageURL) + .assertNoFailure() + .sink(receiveValue: { + result = $0 + }) + .store(in: &cancellables) - // then - let unwrappedResult = try XCTUnwrap(result) - XCTAssertTrue(unwrappedResult.isEqual(imageCache.image(for: Fixtures.anyImageURL))) - XCTAssertTrue(unwrappedResult.isEqual(imageLoader.cachedImage(for: Fixtures.anyImageURL))) - } + // then + let unwrappedResult = try XCTUnwrap(result) + XCTAssertTrue(unwrappedResult.isEqual(imageCache.image(for: Fixtures.anyImageURL))) + XCTAssertTrue(unwrappedResult.isEqual(imageLoader.cachedImage(for: Fixtures.anyImageURL))) + } - func testImageReturnsCachedImageIfAvailable() throws { - // given - let imageCache = NetworkImageCache() - let imageLoader = NetworkImageLoader(urlLoader: .failing, imageCache: imageCache) - imageCache.setImage(Fixtures.anyImage, for: Fixtures.anyImageURL) + func testImageReturnsCachedImageIfAvailable() throws { + // given + let imageCache = NetworkImageCache() + let imageLoader = NetworkImageLoader(urlLoader: .failing, imageCache: imageCache) + imageCache.setImage(Fixtures.anyImage, for: Fixtures.anyImageURL) - // when - var result: OSImage? - imageLoader.image(for: Fixtures.anyImageURL) - .assertNoFailure() - .sink(receiveValue: { - result = $0 - }) - .store(in: &cancellables) + // when + var result: OSImage? + imageLoader.image(for: Fixtures.anyImageURL) + .assertNoFailure() + .sink(receiveValue: { + result = $0 + }) + .store(in: &cancellables) - // then - let unwrappedResult = try XCTUnwrap(result) - XCTAssertTrue(unwrappedResult.isEqual(Fixtures.anyImage)) - } + // then + let unwrappedResult = try XCTUnwrap(result) + XCTAssertTrue(unwrappedResult.isEqual(Fixtures.anyImage)) + } - func testImageFailsWithBadStatusError() throws { - // given - let imageLoader = NetworkImageLoader( - urlLoader: .mock( - url: Fixtures.anyImageURL, - withResponse: Just( - ( - data: .init(), - response: HTTPURLResponse( - url: Fixtures.anyImageURL, - statusCode: 500, - httpVersion: "HTTP/1.1", - headerFields: nil - )! - ) + func testImageFailsWithBadStatusError() throws { + // given + let imageLoader = NetworkImageLoader( + urlLoader: .mock( + url: Fixtures.anyImageURL, + withResponse: Just( + ( + data: .init(), + response: HTTPURLResponse( + url: Fixtures.anyImageURL, + statusCode: 500, + httpVersion: "HTTP/1.1", + headerFields: nil + )! ) - .setFailureType(to: URLError.self) - ), - imageCache: .noop - ) - - // when - var result: Error? - imageLoader.image(for: Fixtures.anyImageURL) - .sink( - receiveCompletion: { completion in - if case let .failure(error) = completion { - result = error - } - }, - receiveValue: { _ in } ) - .store(in: &cancellables) + .setFailureType(to: URLError.self) + ), + imageCache: .noop + ) - // then - let unwrappedResult = try XCTUnwrap(result as? NetworkImageError) - XCTAssertEqual(unwrappedResult, .badStatus(500)) - } - - func testImageFailsWithInvalidDataError() throws { - // given - let imageLoader = NetworkImageLoader( - urlLoader: .mock( - url: Fixtures.anyImageURL, - withResponse: Just( - ( - data: Fixtures.anyResponse, - response: HTTPURLResponse( - url: Fixtures.anyImageURL, - statusCode: 200, - httpVersion: "HTTP/1.1", - headerFields: nil - )! - ) - ) - .setFailureType(to: URLError.self) - ), - imageCache: .noop + // when + var result: Error? + imageLoader.image(for: Fixtures.anyImageURL) + .sink( + receiveCompletion: { completion in + if case let .failure(error) = completion { + result = error + } + }, + receiveValue: { _ in } ) + .store(in: &cancellables) + + // then + let unwrappedResult = try XCTUnwrap(result as? NetworkImageError) + XCTAssertEqual(unwrappedResult, .badStatus(500)) + } - // when - var result: Error? - imageLoader.image(for: Fixtures.anyImageURL) - .sink( - receiveCompletion: { completion in - if case let .failure(error) = completion { - result = error - } - }, - receiveValue: { _ in } + func testImageFailsWithInvalidDataError() throws { + // given + let imageLoader = NetworkImageLoader( + urlLoader: .mock( + url: Fixtures.anyImageURL, + withResponse: Just( + ( + data: Fixtures.anyResponse, + response: HTTPURLResponse( + url: Fixtures.anyImageURL, + statusCode: 200, + httpVersion: "HTTP/1.1", + headerFields: nil + )! + ) ) - .store(in: &cancellables) + .setFailureType(to: URLError.self) + ), + imageCache: .noop + ) + + // when + var result: Error? + imageLoader.image(for: Fixtures.anyImageURL) + .sink( + receiveCompletion: { completion in + if case let .failure(error) = completion { + result = error + } + }, + receiveValue: { _ in } + ) + .store(in: &cancellables) - // then - let unwrappedResult = try XCTUnwrap(result as? NetworkImageError) - XCTAssertEqual(unwrappedResult, .invalidData(Fixtures.anyResponse)) - } + // then + let unwrappedResult = try XCTUnwrap(result as? NetworkImageError) + XCTAssertEqual(unwrappedResult, .invalidData(Fixtures.anyResponse)) } -#endif +} diff --git a/Tests/NetworkImageTests/NetworkImageStoreTests.swift b/Tests/NetworkImageTests/NetworkImageStoreTests.swift index 51d8566..66f92fe 100644 --- a/Tests/NetworkImageTests/NetworkImageStoreTests.swift +++ b/Tests/NetworkImageTests/NetworkImageStoreTests.swift @@ -1,113 +1,112 @@ -#if canImport(Combine) - import Combine - import CombineSchedulers - import XCTest - - @testable import NetworkImage - - @available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) - final class NetworkImageStoreTests: XCTestCase { - private var cancellables = Set() - - override func tearDownWithError() throws { - cancellables.removeAll() - } - - func testNilURLReturnsFallback() { - // given - let store = NetworkImageStore(url: nil) - - var result: [NetworkImageStore.State] = [] - - store.$state - .sink { result.append($0) } - .store(in: &cancellables) - - // when - store.send( - .onAppear( - environment: .init( - imageLoader: .failing, - mainQueue: .immediate - ) + +import Combine +import CombineSchedulers +import XCTest + +@testable import NetworkImage + +@available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) +final class NetworkImageStoreTests: XCTestCase { + private var cancellables = Set() + + override func tearDownWithError() throws { + cancellables.removeAll() + } + + func testNilURLReturnsFallback() { + // given + let store = NetworkImageStore(url: nil) + + var result: [NetworkImageStore.State] = [] + + store.$state + .sink { result.append($0) } + .store(in: &cancellables) + + // when + store.send( + .onAppear( + environment: .init( + imageLoader: .failing, + mainQueue: .immediate ) ) + ) + + // then + XCTAssertEqual([.fallback], result) + } - // then - XCTAssertEqual([.fallback], result) - } - - func testValidURLReturnsImage() { - // given - let scheduler = DispatchQueue.test - let store = NetworkImageStore(url: Fixtures.anyImageURL) - var result: [NetworkImageStore.State] = [] - - store.$state - .sink { result.append($0) } - .store(in: &cancellables) - - // when - store.send( - .onAppear( - environment: .init( - imageLoader: .mock( - url: Fixtures.anyImageURL, - withResponse: Just(Fixtures.anyImage) - .setFailureType(to: Error.self) - .delay(for: .seconds(1), scheduler: scheduler) - ), - mainQueue: scheduler.eraseToAnyScheduler() - ) + func testValidURLReturnsImage() { + // given + let scheduler = DispatchQueue.test + let store = NetworkImageStore(url: Fixtures.anyImageURL) + var result: [NetworkImageStore.State] = [] + + store.$state + .sink { result.append($0) } + .store(in: &cancellables) + + // when + store.send( + .onAppear( + environment: .init( + imageLoader: .mock( + url: Fixtures.anyImageURL, + withResponse: Just(Fixtures.anyImage) + .setFailureType(to: Error.self) + .delay(for: .seconds(1), scheduler: scheduler) + ), + mainQueue: scheduler.eraseToAnyScheduler() ) ) - scheduler.advance(by: .seconds(1)) - - // then - XCTAssertEqual( - [ - .notRequested(Fixtures.anyImageURL), - .placeholder, - .image(Fixtures.anyImage), - ], - result - ) - } - - func testFailingURLReturnsFallback() { - // given - let scheduler = DispatchQueue.test - let store = NetworkImageStore(url: Fixtures.anyImageURL) - var result: [NetworkImageStore.State] = [] - - store.$state - .sink { result.append($0) } - .store(in: &cancellables) - - // when - store.send( - .onAppear( - environment: .init( - imageLoader: .mock( - url: Fixtures.anyImageURL, - withResponse: Fail(error: Fixtures.anyError as Error) - .delay(for: .seconds(1), scheduler: scheduler) - ), - mainQueue: scheduler.eraseToAnyScheduler() - ) + ) + scheduler.advance(by: .seconds(1)) + + // then + XCTAssertEqual( + [ + .notRequested(Fixtures.anyImageURL), + .placeholder, + .image(Fixtures.anyImage), + ], + result + ) + } + + func testFailingURLReturnsFallback() { + // given + let scheduler = DispatchQueue.test + let store = NetworkImageStore(url: Fixtures.anyImageURL) + var result: [NetworkImageStore.State] = [] + + store.$state + .sink { result.append($0) } + .store(in: &cancellables) + + // when + store.send( + .onAppear( + environment: .init( + imageLoader: .mock( + url: Fixtures.anyImageURL, + withResponse: Fail(error: Fixtures.anyError as Error) + .delay(for: .seconds(1), scheduler: scheduler) + ), + mainQueue: scheduler.eraseToAnyScheduler() ) ) - scheduler.advance(by: .seconds(1)) - - // then - XCTAssertEqual( - [ - .notRequested(Fixtures.anyImageURL), - .placeholder, - .fallback, - ], - result - ) - } + ) + scheduler.advance(by: .seconds(1)) + + // then + XCTAssertEqual( + [ + .notRequested(Fixtures.anyImageURL), + .placeholder, + .fallback, + ], + result + ) } -#endif +} diff --git a/Tests/NetworkImageTests/NetworkImageTests.swift b/Tests/NetworkImageTests/NetworkImageTests.swift index 5aad0a6..5ce0961 100644 --- a/Tests/NetworkImageTests/NetworkImageTests.swift +++ b/Tests/NetworkImageTests/NetworkImageTests.swift @@ -1,4 +1,4 @@ -#if canImport(SwiftUI) && !os(macOS) && !targetEnvironment(macCatalyst) +#if !os(macOS) && !targetEnvironment(macCatalyst) import Combine import SnapshotTesting import SwiftUI