Compare commits

...

8 Commits

Author SHA1 Message Date
8efd109a46 updated test cases for new Testing Framework macros.
Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
2025-01-21 09:20:41 -06:00
6f7a43d15e commented code
Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
2025-01-21 09:15:12 -06:00
069cc8c06a commented the code and refactored to new shared.
Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
2025-01-21 09:14:15 -06:00
c127a6c465 commented the code and removed self.
Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
2025-01-21 09:13:41 -06:00
f5c1a64e40 add shared property
commented the code

Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
2025-01-21 09:13:21 -06:00
f07089a1fc commented the code.
Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
2025-01-21 09:12:56 -06:00
0ad993ca6b refactored to use 1 method and commented the code.
Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
2025-01-21 09:11:06 -06:00
43a3c456f7 only using portrait mode for devices
Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
2025-01-21 09:10:34 -06:00
14 changed files with 184 additions and 52 deletions

View File

@ -301,8 +301,7 @@
INFOPLIST_FILE = EmployeeDirectory/Info.plist; INFOPLIST_FILE = EmployeeDirectory/Info.plist;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
@ -328,8 +327,7 @@
INFOPLIST_FILE = EmployeeDirectory/Info.plist; INFOPLIST_FILE = EmployeeDirectory/Info.plist;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",

View File

@ -12,11 +12,9 @@ extension String {
/// Non-numeric characters are removed, and formatting is applied based on the length of the string. /// Non-numeric characters are removed, and formatting is applied based on the length of the string.
/// - Returns: A formatted phone number as a string. /// - Returns: A formatted phone number as a string.
internal func formatUSNumber() -> String { internal func formatUSNumber() -> String {
// format the number // mask for the phone numver
return format(with: "XXX-XXX-XXXX", phone: self) let mask = "XXX-XXX-XXXX"
}
internal func format(with mask: String, phone: String) -> String {
let numbers = filter { $0.isNumber } let numbers = filter { $0.isNumber }
var result = "" var result = ""
var index = numbers.startIndex // numbers iterator var index = numbers.startIndex // numbers iterator

View File

@ -8,6 +8,8 @@ import Foundation
import CryptoKit import CryptoKit
extension URL { extension URL {
/// This will has the URL absoluteString into a that is consistent.
internal var uniqueIdentifier: String { internal var uniqueIdentifier: String {
let data = Data(absoluteString.utf8) let data = Data(absoluteString.utf8)
let hash = SHA256.hash(data: data) let hash = SHA256.hash(data: data)

View File

@ -8,6 +8,8 @@ import Foundation
/// Wrapper JSON Class for the Employees /// Wrapper JSON Class for the Employees
public struct Employees: Codable { public struct Employees: Codable {
/// Array of Employees
public var employees: [Employee] public var employees: [Employee]
private enum CodingKeys: String, CodingKey { private enum CodingKeys: String, CodingKey {

View File

@ -5,6 +5,12 @@
// Created by Matt Bruce on 1/20/25. // Created by Matt Bruce on 1/20/25.
// //
/// This will be the interface for the API for Employees
public protocol EmployeeServiceProtocol { public protocol EmployeeServiceProtocol {
/// This will get a list of all employees
/// - Parameter serviceMode: Mode in which to hit.
/// - Returns: An Employees struct
func getEmployees(_ serviceMode: EmployeeServiceMode) async throws -> Employees func getEmployees(_ serviceMode: EmployeeServiceMode) async throws -> Employees
} }

View File

@ -12,6 +12,7 @@ public enum EmployeeServiceMode: String, CaseIterable {
case malformed case malformed
case empty case empty
/// Enpoint in which to grabe employees from.
public var endpoint: String { public var endpoint: String {
switch self { switch self {
case .production: case .production:
@ -26,11 +27,18 @@ public enum EmployeeServiceMode: String, CaseIterable {
/// Service Layer for Employees /// Service Layer for Employees
public class EmployeeService: EmployeeServiceProtocol { public class EmployeeService: EmployeeServiceProtocol {
// MARK: - Properties
public static let shared = EmployeeService() // Default shared instance
// MARK: - Initializer
public init() {} public init() {}
/// Service to get Employees // MARK: - Public Methods
/// - Returns: Array of Employee Structs
/// This will get a list of all employees
/// - Parameter serviceMode: Mode in which to hit.
/// - Returns: An Employees struct
public func getEmployees(_ serviceMode: EmployeeServiceMode = .production) async throws -> Employees { public func getEmployees(_ serviceMode: EmployeeServiceMode = .production) async throws -> Employees {
return try await NetworkService.shared.fetchData(from: serviceMode.endpoint, as: Employees.self) return try await NetworkService.shared.fetchData(from: serviceMode.endpoint, as: Employees.self)
} }

View File

@ -8,49 +8,80 @@
import Foundation import Foundation
import UIKit import UIKit
/// A service that handles image caching using memory, disk, and network in priority order.
public class ImageCacheService { public class ImageCacheService {
// MARK: - Properties
public static let shared = ImageCacheService() // Default shared instance public static let shared = ImageCacheService() // Default shared instance
/// Memory cache for storing images in RAM.
private let memoryCache = NSCache<NSString, UIImage>() private let memoryCache = NSCache<NSString, UIImage>()
/// File manager for handling disk operations.
private let fileManager = FileManager.default private let fileManager = FileManager.default
/// Directory where cached images are stored on disk.
private let cacheDirectory: URL = { private let cacheDirectory: URL = {
FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0] FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0]
}() }()
// MARK: - Initializer
public init() {} public init() {}
// MARK: - Public Methods
/// Loads an image from memory, disk, or network.
///
/// - Parameter url: The URL of the image to load.
/// - Returns: The loaded `UIImage` or `nil` if the image could not be loaded.
public func loadImage(from url: URL) async -> UIImage? { public func loadImage(from url: URL) async -> UIImage? {
let uniqueKey = url.uniqueIdentifier let uniqueKey = url.uniqueIdentifier
let cacheKey = uniqueKey as NSString let cacheKey = uniqueKey as NSString
// Step 1: Check the memory cache
if let cachedImage = memoryCache.object(forKey: cacheKey) { if let cachedImage = memoryCache.object(forKey: cacheKey) {
return cachedImage return cachedImage
} }
// Step 2: Check the disk cache
let diskImagePath = cacheDirectory.appendingPathComponent(uniqueKey) let diskImagePath = cacheDirectory.appendingPathComponent(uniqueKey)
if let diskImage = UIImage(contentsOfFile: diskImagePath.path) { if let diskImage = UIImage(contentsOfFile: diskImagePath.path) {
memoryCache.setObject(diskImage, forKey: cacheKey) memoryCache.setObject(diskImage, forKey: cacheKey) // Cache in memory for faster access next time
return diskImage return diskImage
} }
// Step 3: Fetch the image from the network
if let networkImage = await fetchFromNetwork(url: url) { if let networkImage = await fetchFromNetwork(url: url) {
memoryCache.setObject(networkImage, forKey: cacheKey) memoryCache.setObject(networkImage, forKey: cacheKey) // Cache in memory
try? saveImageToDisk(image: networkImage, at: diskImagePath) try? saveImageToDisk(image: networkImage, at: diskImagePath) // Cache on disk
return networkImage return networkImage
} }
// Step 4: Return nil if all options fail
return nil return nil
} }
// MARK: - Private Methods
/// Fetches an image from the network.
///
/// - Parameter url: The URL of the image to fetch.
/// - Returns: The fetched `UIImage` or `nil` if the network request fails.
private func fetchFromNetwork(url: URL) async -> UIImage? { private func fetchFromNetwork(url: URL) async -> UIImage? {
do { do {
let (data, _) = try await URLSession.shared.data(from: url) let (data, _) = try await URLSession.shared.data(from: url)
return UIImage(data: data) return UIImage(data: data)
} catch { } catch {
print("Failed to fetch image from network for URL: \(url). Error: \(error)")
return nil return nil
} }
} }
/// Saves an image to disk at the specified path.
///
/// - Parameters:
/// - image: The `UIImage` to save.
/// - path: The file path where the image should be saved.
private func saveImageToDisk(image: UIImage, at path: URL) throws { private func saveImageToDisk(image: UIImage, at path: URL) throws {
guard let data = image.pngData() else { return } guard let data = image.pngData() else { return }
try data.write(to: path) try data.write(to: path)

View File

@ -26,15 +26,22 @@ public enum NetworkServiceError: Error {
public class NetworkService { public class NetworkService {
// MARK: - Properties
public static let shared = NetworkService() // Default shared instance public static let shared = NetworkService() // Default shared instance
private let session: URLSession private let session: URLSession
// MARK: - Initializer
/// Public initializer to allow customization /// Public initializer to allow customization
public init(session: URLSession = .shared) { public init(session: URLSession = .shared) {
self.session = session self.session = session
} }
// MARK: - Public Methods
/// Fetches data from a URL and decodes it into a generic Decodable type. /// Fetches data from a URL and decodes it into a generic Decodable type.
/// - Parameters: /// - Parameters:
/// - endpoint: The url to fetch data from. /// - endpoint: The url to fetch data from.

View File

@ -9,13 +9,30 @@ import UIKit
import Combine import Combine
class EmployeesViewController: UIViewController { class EmployeesViewController: UIViewController {
// MARK: - Properties
/// List for the employees
private let tableView = UITableView() private let tableView = UITableView()
/// Will only show when fetching data occurs
private let activityIndicator = UIActivityIndicatorView(style: .large) private let activityIndicator = UIActivityIndicatorView(style: .large)
/// Allows the user to pick between service modes
private let modeSegmentedControl = UISegmentedControl(items: EmployeeServiceMode.allCases.map{ $0.rawValue } ) private let modeSegmentedControl = UISegmentedControl(items: EmployeeServiceMode.allCases.map{ $0.rawValue } )
/// ViewModel in which drives the screen
private let viewModel = EmployeesViewModel() private let viewModel = EmployeesViewModel()
/// Holds onto the ViewModels Subscribers
private var cancellables = Set<AnyCancellable>() private var cancellables = Set<AnyCancellable>()
/// Will show specific state of the viewModel
private var footerView: TableFooterView? private var footerView: TableFooterView?
// MARK: - Public Methods
public override func viewDidLoad() { public override func viewDidLoad() {
super.viewDidLoad() super.viewDidLoad()
setupUI() setupUI()
@ -23,6 +40,9 @@ class EmployeesViewController: UIViewController {
viewModel.fetchEmployees() viewModel.fetchEmployees()
} }
// MARK: - Private Methods
/// Setup the UI by adding the views to the main view
private func setupUI() { private func setupUI() {
view.backgroundColor = .white view.backgroundColor = .white
@ -46,6 +66,7 @@ class EmployeesViewController: UIViewController {
navigationItem.titleView = modeSegmentedControl navigationItem.titleView = modeSegmentedControl
} }
/// Using the ViewModel setup combine handlers
private func bindViewModel() { private func bindViewModel() {
viewModel.$employees viewModel.$employees
.receive(on: RunLoop.main) .receive(on: RunLoop.main)
@ -75,6 +96,7 @@ class EmployeesViewController: UIViewController {
.store(in: &cancellables) .store(in: &cancellables)
} }
/// Show state in specific use-cases for the EmployeesViewModel
private func updateFooter() { private func updateFooter() {
var message: String? { var message: String? {
guard !viewModel.isLoading else { return nil } guard !viewModel.isLoading else { return nil }
@ -96,11 +118,16 @@ class EmployeesViewController: UIViewController {
} }
} }
// Mark: - Objective-C Methods
extension EmployeesViewController { extension EmployeesViewController {
/// Fetch the Employees
@objc private func didPullToRefresh() { @objc private func didPullToRefresh() {
viewModel.fetchEmployees() viewModel.fetchEmployees()
} }
/// This will handle services changes to test conditions to ensure UI works correctly.
/// - Parameter sender: Mode in which to test
@objc private func onServiceModeChange(_ sender: UISegmentedControl) { @objc private func onServiceModeChange(_ sender: UISegmentedControl) {
let selectedMode: EmployeeServiceMode let selectedMode: EmployeeServiceMode
switch sender.selectedSegmentIndex { switch sender.selectedSegmentIndex {
@ -113,6 +140,7 @@ extension EmployeesViewController {
} }
} }
/// Mark: - UITableViewDataSource
extension EmployeesViewController: UITableViewDataSource { extension EmployeesViewController: UITableViewDataSource {
public func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { public func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return viewModel.employees.count return viewModel.employees.count

View File

@ -8,8 +8,11 @@
import Foundation import Foundation
import UIKit import UIKit
/// ViewModel that will be used along with the EmployeeTableViewCell.
@MainActor @MainActor
public class EmployeeCellViewModel: ObservableObject { public class EmployeeCellViewModel: ObservableObject {
// MARK: - Properties
private let employee: Employee private let employee: Employee
public private(set) var uuid: String public private(set) var uuid: String
@ -21,18 +24,21 @@ public class EmployeeCellViewModel: ObservableObject {
public private(set) var employeeType: String public private(set) var employeeType: String
@Published public private(set) var smallPhoto: UIImage? @Published public private(set) var smallPhoto: UIImage?
// MARK: - Initializer
public init(employee: Employee) { public init(employee: Employee) {
self.employee = employee self.employee = employee
// Initialize properties // Initialize properties
self.uuid = employee.uuid.uuidString uuid = employee.uuid.uuidString
self.fullName = employee.fullName fullName = employee.fullName
self.phoneNumber = employee.phoneNumber?.formatUSNumber() phoneNumber = employee.phoneNumber?.formatUSNumber()
self.emailAddress = employee.emailAddress emailAddress = employee.emailAddress
self.biography = employee.biography biography = employee.biography
self.team = employee.team team = employee.team
self.employeeType = employee.employeeType.description employeeType = employee.employeeType.description
// Fetch the image for the url if it exists
if let endpoint = employee.photoURLSmall { if let endpoint = employee.photoURLSmall {
Task{ Task{
if let smallPhotoURL = URL(string: endpoint) { if let smallPhotoURL = URL(string: endpoint) {

View File

@ -7,18 +7,17 @@
import Foundation import Foundation
/// ViewModel that will be bound to an Employees model and used
/// specifically with the EmployeesViewController.
@MainActor @MainActor
public class EmployeesViewModel: ObservableObject { public class EmployeesViewModel: ObservableObject {
private let service: EmployeeService
private var serviceMode: EmployeeServiceMode = .production private var serviceMode: EmployeeServiceMode = .production
@Published public private(set) var employees: [Employee] = [] @Published public private(set) var employees: [Employee] = []
@Published public private(set) var errorMessage: String? = nil @Published public private(set) var errorMessage: String? = nil
@Published public private(set) var isLoading: Bool = false @Published public private(set) var isLoading: Bool = false
public init(service: EmployeeService = EmployeeService()) { public init() {}
self.service = service
}
public func fetchEmployees() { public func fetchEmployees() {
// resetting values out the values before fetching new data // resetting values out the values before fetching new data
@ -28,17 +27,17 @@ public class EmployeesViewModel: ObservableObject {
Task { Task {
do { do {
// Fetch employees using the async method // Fetch employees using the async method
let wrapper = try await service.getEmployees(serviceMode) let wrapper = try await EmployeeService.shared.getEmployees(serviceMode)
// Update published properties // Update published properties
self.employees = wrapper.employees employees = wrapper.employees
self.isLoading = false isLoading = false
} catch { } catch {
// Handle errors // Handle errors
self.employees = [] employees = []
self.isLoading = false isLoading = false
self.errorMessage = "An unexpected error occurred, please try to refresh" errorMessage = "An unexpected error occurred, please try to refresh"
} }
} }

View File

@ -7,9 +7,17 @@
import UIKit import UIKit
import Combine import Combine
/// This is the Cell used in the EmployeesTableViewController to show
/// the properties of an Employee model.
public class EmployeeTableViewCell: UITableViewCell { public class EmployeeTableViewCell: UITableViewCell {
/// Used in the TableView registration
static let identifier = "EmployeeTableViewCell" static let identifier = "EmployeeTableViewCell"
// MARK: - Properties
/// UI Elements
private let photoImageView = UIImageView() private let photoImageView = UIImageView()
private let nameLabel = UILabel() private let nameLabel = UILabel()
private let emailLabel = UILabel() private let emailLabel = UILabel()
@ -19,7 +27,10 @@ public class EmployeeTableViewCell: UITableViewCell {
private let bioLabel = UILabel() private let bioLabel = UILabel()
private let stackView = UIStackView() private let stackView = UIStackView()
private var cancellable: AnyCancellable? /// Used for grabbing the photo
private var smallPhotoSubscriber: AnyCancellable?
// MARK: - Initializer
public override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { public override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier) super.init(style: style, reuseIdentifier: reuseIdentifier)
@ -30,6 +41,8 @@ public class EmployeeTableViewCell: UITableViewCell {
fatalError("init(coder:) has not been implemented") fatalError("init(coder:) has not been implemented")
} }
// MARK: - Private Methods
private func setupUI() { private func setupUI() {
// Configure photoImageView // Configure photoImageView
photoImageView.contentMode = .scaleAspectFill photoImageView.contentMode = .scaleAspectFill
@ -93,9 +106,13 @@ public class EmployeeTableViewCell: UITableViewCell {
]) ])
} }
// MARK: - Public Methods
/// Override for setting back to a default state
public override func prepareForReuse() { public override func prepareForReuse() {
super.prepareForReuse() super.prepareForReuse()
cancellable = nil smallPhotoSubscriber = nil
photoImageView.image = UIImage(systemName: "person.crop.circle") photoImageView.image = UIImage(systemName: "person.crop.circle")
nameLabel.text = nil nameLabel.text = nil
emailLabel.text = nil emailLabel.text = nil
@ -105,10 +122,12 @@ public class EmployeeTableViewCell: UITableViewCell {
bioLabel.text = nil bioLabel.text = nil
} }
/// Configures the UI Elements with the properties of the EmployeeCellViewModel.
/// - Parameter viewModel: Employee Model wrapped for Cell.
public func configure(with viewModel: EmployeeCellViewModel) { public func configure(with viewModel: EmployeeCellViewModel) {
// Bind the image to the photoImageView // Bind the image to the photoImageView
cancellable = viewModel.$smallPhoto smallPhotoSubscriber = viewModel.$smallPhoto
.receive(on: DispatchQueue.main) .receive(on: DispatchQueue.main)
.sink { [weak self] image in .sink { [weak self] image in
self?.photoImageView.image = image self?.photoImageView.image = image

View File

@ -6,8 +6,11 @@
// //
import UIKit import UIKit
/// Meant to be used as a Message for State in a TableView.
public class TableFooterView: UIView { public class TableFooterView: UIView {
// MARK: - Properties
/// Label used to show the message /// Label used to show the message
private let messageLabel: UILabel = { private let messageLabel: UILabel = {
let label = UILabel() let label = UILabel()
@ -20,6 +23,8 @@ public class TableFooterView: UIView {
return label return label
}() }()
// MARK: - Initializer
init(message: String) { init(message: String) {
super.init(frame: .zero) super.init(frame: .zero)
setupUI() setupUI()
@ -30,6 +35,8 @@ public class TableFooterView: UIView {
fatalError("init(coder:) has not been implemented") fatalError("init(coder:) has not been implemented")
} }
// MARK: - Private Methods
/// Setup the UI /// Setup the UI
private func setupUI() { private func setupUI() {
addSubview(messageLabel) addSubview(messageLabel)
@ -41,6 +48,7 @@ public class TableFooterView: UIView {
]) ])
} }
// MARK: - Public Methods
/// Updates the Current Message /// Updates the Current Message
/// - Parameter message: message to show /// - Parameter message: message to show

View File

@ -6,7 +6,6 @@
// //
import Testing import Testing
import XCTest
@testable import EmployeeDirectory @testable import EmployeeDirectory
@ -14,36 +13,57 @@ struct EmployeeDirectoryTests {
@Test func getEmployeesValid() async throws { @Test func getEmployeesValid() async throws {
do { do {
let wrapper = try await EmployeeService().getEmployees(.production) let wrapper = try await EmployeeService.shared.getEmployees(.production)
#expect(wrapper.employees.count == 11) #expect(wrapper.employees.count == 11)
} catch { } catch {
XCTFail("Unexpected error: \(error)") #expect(Bool(false), "Unexpected error: \(error)")
} }
} }
@Test func getEmployeesMalformed() async throws { @Test func getEmployeesMalformed() async throws {
do { do {
let wrapper = try await EmployeeService().getEmployees(.malformed) _ = try await EmployeeService.shared.getEmployees(.malformed)
XCTFail("Expected to throw, but got \(wrapper.employees.count) employees") #expect(Bool(false), "Expected invalidResponse error, but no error was thrown")
} catch let error as NetworkServiceError { } catch let error as NetworkServiceError {
switch error { switch error {
case .invalidResponse: case .decodingError(let decodingError):
XCTAssertTrue(true, "Correctly threw invalidResponse error") #expect(Bool(true), "Expected NetworkServiceError.decodingError, but got \(decodingError)")
default: default:
XCTFail("Expected NetworkServiceError.invalidResponse, but got \(error)") #expect(Bool(false), "Expected NetworkServiceError.decodingError, but got \(error)")
} }
} catch { } catch {
XCTFail("Unexpected error: \(error)") #expect(Bool(false), "Unexpected error: \(error)")
} }
} }
@Test func getEmployeesEmpty() async throws { @Test func getEmployeesEmpty() async throws {
do { do {
let wrapper = try await EmployeeService().getEmployees(.empty) let wrapper = try await EmployeeService.shared.getEmployees(.empty)
#expect(wrapper.employees.count == 0) #expect(wrapper.employees.count == 0)
} catch { } catch {
XCTFail("Unexpected error: \(error)") #expect(Bool(false), "Unexpected error: \(error)")
} }
} }
} }
// Testing extension since this isn't needed in the code.
extension NetworkServiceError: @retroactive Equatable {
public static func == (lhs: NetworkServiceError, rhs: NetworkServiceError) -> Bool {
switch (lhs, rhs) {
case (.invalidResponse, .invalidResponse):
return true
case (.invalidURL, .invalidURL):
return true
case (.decodingError(let lhsError), .decodingError(let rhsError)):
return lhsError.localizedDescription == rhsError.localizedDescription
case (.networkError(let lhsError), .networkError(let rhsError)):
return lhsError.code == rhsError.code
case (.unknownError(let lhsError), .unknownError(let rhsError)):
return lhsError.localizedDescription == rhsError.localizedDescription
default:
return false
}
}
}