diff --git a/README.md b/README.md index 2889997..2982e8b 100644 --- a/README.md +++ b/README.md @@ -11,4 +11,4 @@ Currently, this package contains the following libraries: * `Coordination`: protocols to implement the [Coordinator pattern](https://khanlou.com/2015/01/the-coordinator/) and some ready-to-use platform-specific concrete routers; * `Core`: extensions we usually add to the base layer functionality and primitive types provided by the [Swift standard library](https://https://www.swift.org/documentation/#standard-library); * `Dependencies`: a ready-to-use, simple [Dependency Injection](https://en.wikipedia.org/wiki/Dependency_injection) mechanism that levers heavily on the [dynamic property wrappers](https://www.hackingwithswift.com/plus/intermediate-swiftui/creating-a-custom-property-wrapper-using-dynamicproperty) provided by the [Swift programming language](https://docs.swift.org/swift-book/documentation/the-swift-programming-language/properties/#Projecting-a-Value-From-a-Property-Wrapper); -* `Persistence`: protocols, extensions and a ready-to-use fetcher class to simplify the building of the **CoreData** persistence layer; +* `Persistence`: protocols, extensions and a ready-to-use fetcher class to simplify the building of the [CoreData](https://developer.apple.com/documentation/coredata) persistence layer; diff --git a/Sources/Persistence/Classes/Fetcher.swift b/Sources/Persistence/Classes/Fetcher.swift index 1afcaa8..f8b3db2 100644 --- a/Sources/Persistence/Classes/Fetcher.swift +++ b/Sources/Persistence/Classes/Fetcher.swift @@ -62,21 +62,25 @@ public class Fetcher: NSObject, NSFetchedResultsControll /// Retrieve the number of objects in a given section number. /// - Parameter section: The section number to inquiry about. /// - Returns: A number of objects in the given section number. - public func numberOfObjectsInSection(_ section: Int) -> Int { - guard - let sections = fetchedResultsController.sections, - sections.endIndex > section - else { - return 0 + public func numberOfObjects(in section: Int) throws -> Int { + guard let sections = fetchedResultsController.sections else { + throw FetcherError.fetchNotExecuted } - + guard sections.endIndex > section else { + throw FetcherError.sectionNotFound + } + return sections[section].numberOfObjects } /// Retrieve an object out of a given index path. /// - Parameter indexPath: The index path to use to retrieve an object. /// - Returns: A `NSManagedObject` entity positioned in the given index path. - public func object(at indexPath: IndexPath) -> Model { + public func object(at indexPath: IndexPath) throws -> Model { + guard fetchedResultsController.sections != nil else { + throw FetcherError.fetchNotExecuted + } + return fetchedResultsController.object(at: indexPath) } @@ -134,6 +138,13 @@ public class Fetcher: NSObject, NSFetchedResultsControll } +// MARK: - Errors + +public enum FetcherError: Error { + case fetchNotExecuted + case sectionNotFound +} + // MARK: - Enumerations public enum Change: Hashable { diff --git a/Sources/Persistence/Protocols/Service.swift b/Sources/Persistence/Protocols/Service.swift index d317459..e628743 100644 --- a/Sources/Persistence/Protocols/Service.swift +++ b/Sources/Persistence/Protocols/Service.swift @@ -27,10 +27,10 @@ public protocol Service { /// Save a given context. /// - Parameter context: A `NSManagedObjectContext` context instance to save. - func save(context: NSManagedObjectContext) + func save(context: NSManagedObjectContext) throws /// Save a given child context as well as its respective parent context. /// - Parameter context: A child `NSManagedObjectContext` context instance to save. - func save(childContext context: NSManagedObjectContext) + func save(childContext context: NSManagedObjectContext) throws } diff --git a/Tests/Persistence/Classes/FetcherTests.swift b/Tests/Persistence/Classes/FetcherTests.swift new file mode 100644 index 0000000..d9bb0cc --- /dev/null +++ b/Tests/Persistence/Classes/FetcherTests.swift @@ -0,0 +1,194 @@ +// +// FetcherTests.swift +// PersistenceTests +// +// Created by Javier Cicchelli on 17/04/2023. +// Copyright © 2023 Röck+Cöde. All rights reserved. +// + +import Persistence +import XCTest + +final class FetcherTests: XCTestCase { + + // MARK: Properties + + private let persistence = TestPersistenceService.shared + + private var fetcher: Fetcher! + + // MARK: Setup + + override func setUpWithError() throws { + fetcher = .init( + fetchRequest: .allTestEntities(), + managedObjectContext: persistence.viewContext + ) + } + + override func tearDownWithError() throws { + fetcher = nil + } + + // MARK: Number of sections tests + + func test_numberOfSections_whenModelIsEmpty() throws { + // GIVEN + try fetcher.fetch() + + // WHEN + let numberOfSections = fetcher.numberOfSections + + // THEN + XCTAssertEqual(numberOfSections, 1) + } + + func test_numberOfSections_whenModelIsFilled() throws { + // GIVEN + let context = persistence.makeChildContext() + let _ = [ + TestEntity(context: context), + TestEntity(context: context), + TestEntity(context: context) + ] + + try persistence.save(childContext: context) + try fetcher.fetch() + + // WHEN + let numberOfSections = fetcher.numberOfSections + + // THEN + XCTAssertEqual(numberOfSections, 1) + } + + func test_numberOfSections_whenNoFetch() async throws { + // GIVEN + // WHEN + let numberOfSections = fetcher.numberOfSections + + // THEN + XCTAssertEqual(numberOfSections, 0) + } + + // MARK: Number of objects tests + + func test_numberOfObjects_inFirstSection_whenModelIsEmpty() throws { + // GIVEN + try fetcher.fetch() + + // WHEN + let section = fetcher.numberOfSections - 1 + let numberOfObjects = try fetcher.numberOfObjects(in: section) + + // THEN + XCTAssertEqual(numberOfObjects, 0) + } + + func test_numberOfObjects_inFirstSection_whenModelIsFilled() throws { + // GIVEN + let context = persistence.makeChildContext() + let entities = [ + TestEntity(context: context), + TestEntity(context: context), + TestEntity(context: context) + ] + + try persistence.save(childContext: context) + try fetcher.fetch() + + // WHEN + let section = fetcher.numberOfSections - 1 + let numberOfObjects = try fetcher.numberOfObjects(in: section) + + // THEN + XCTAssertEqual(numberOfObjects, entities.count) + } + + func test_numberOfObjects_inNonExistingSection() throws { + // GIVEN + try fetcher.fetch() + + // WHEN & THEN + let section = fetcher.numberOfSections + + XCTAssertThrowsError(try fetcher.numberOfObjects(in: section)) { error in + XCTAssertEqual(error as? FetcherError, .sectionNotFound) + } + } + + func test_numberOfObjects_whenNoFetch() throws { + // GIVEN + // WHEN & THEN + XCTAssertThrowsError(try fetcher.numberOfObjects(in: 1)) { error in + XCTAssertEqual(error as? FetcherError, .fetchNotExecuted) + } + } + + // MARK: Object at tests + + func test_objectAt_whenModelIsEmpty() throws { + // GIVEN + try fetcher.fetch() + + // WHEN & THEN + let indexPath = IndexPath( + item: 0, + section: fetcher.numberOfSections - 1 + ) + + // TODO: Need to find out how to handle NSInvalidArgumentException in this test. + // let object = try fetcher.object(at: indexPath) + } + + func test_objectAt_whenModelIsFilled() throws { + // GIVEN + let context = persistence.makeChildContext() + let entities = [TestEntity(context: context)] + + try persistence.save(childContext: context) + try fetcher.fetch() + + // WHEN & THEN + let indexPath = IndexPath( + item: entities.count - 1, + section: fetcher.numberOfSections - 1 + ) + + let object = try fetcher.object(at: indexPath) + + XCTAssertNotNil(object) + } + + func test_objectAt_withOutOfBoundsIndexPath() throws { + // GIVEN + let context = persistence.makeChildContext() + let entities = [TestEntity(context: context)] + + try persistence.save(childContext: context) + try fetcher.fetch() + + // WHEN & THEN + let indexPath = IndexPath( + item: entities.count, + section: fetcher.numberOfSections + ) + + // TODO: Need to find out how to handle NSInvalidArgumentException in this test. + // let object = try fetcher.object(at: indexPath) + } + + func test_objectAt_whenNoFetch() throws { + // GIVEN + // WHEN & THEN + let indexPath = IndexPath( + item: 0, + section: 0 + ) + + XCTAssertThrowsError(try fetcher.object(at: indexPath)) { error in + XCTAssertEqual(error as? FetcherError, .fetchNotExecuted) + } + } + +} diff --git a/Tests/Persistence/Extensions/URL+DevicesTests.swift b/Tests/Persistence/Extensions/URL+DevicesTests.swift new file mode 100644 index 0000000..a4f0ae5 --- /dev/null +++ b/Tests/Persistence/Extensions/URL+DevicesTests.swift @@ -0,0 +1,30 @@ +// +// URL+DevicesTests.swift +// PersistenceTests +// +// Created by Javier Cicchelli on 17/04/2023. +// Copyright © 2023 Röck+Cöde. All rights reserved. +// + +import Foundation +import Persistence +import XCTest + +final class URL_DevicesTests: XCTestCase { + + // MARK: Properties + + private var url: URL! + + // MARK: - Tests + + func test_bitBucket() { + // GIVEN + // WHEN + url = .bitBucket + + // THEN + XCTAssertEqual(url.absoluteString, "file:///dev/null") + } + +} diff --git a/Tests/Persistence/Helpers/NSFetchRequest+TestEntity.swift b/Tests/Persistence/Helpers/NSFetchRequest+TestEntity.swift new file mode 100644 index 0000000..bfa2468 --- /dev/null +++ b/Tests/Persistence/Helpers/NSFetchRequest+TestEntity.swift @@ -0,0 +1,24 @@ +// +// NSFetchRequest+TestEntity.swift +// PersistenceTests +// +// Created by Javier Cicchelli on 17/04/2023. +// Copyright © 2023 Röck+Cöde. All rights reserved. +// + +import CoreData + +extension NSFetchRequest where ResultType == TestEntity { + + // MARK: Functions + + static func allTestEntities() -> NSFetchRequest { + let request = TestEntity.fetchRequest() + + request.sortDescriptors = [] + request.resultType = .managedObjectResultType + + return request + } + +} diff --git a/Tests/Persistence/Helpers/TestModel.xcdatamodeld/Model.xcdatamodel/contents b/Tests/Persistence/Helpers/TestModel.xcdatamodeld/Model.xcdatamodel/contents new file mode 100644 index 0000000..4c66cba --- /dev/null +++ b/Tests/Persistence/Helpers/TestModel.xcdatamodeld/Model.xcdatamodel/contents @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/Tests/Persistence/Helpers/TestPersistenceService.swift b/Tests/Persistence/Helpers/TestPersistenceService.swift new file mode 100644 index 0000000..ee33433 --- /dev/null +++ b/Tests/Persistence/Helpers/TestPersistenceService.swift @@ -0,0 +1,127 @@ +// +// TestPersistenceService.swift +// PersistenceTests +// +// Created by Javier Cicchelli on 17/04/2023. +// Copyright © 2023 Röck+Cöde. All rights reserved. +// + +import CoreData +import Persistence + +struct TestPersistenceService { + + // MARK: Properties + + static let shared = TestPersistenceService() + + private let container: NSPersistentContainer + + var viewContext: NSManagedObjectContext { + container.viewContext + } + + // MARK: Initialisers + + init() { + guard + let modelURL = Bundle.module.url(forResource: .Model.name, withExtension: .Model.extension), + let managedObjectModel = NSManagedObjectModel(contentsOf: modelURL) + else { + fatalError("Could not load the Core Data model from the module.") + } + + self.container = .init( + name: .Model.name, + managedObjectModel: managedObjectModel + ) + + setContainer() + } + +} + +// MARK: - Service + +extension TestPersistenceService: Service { + + // MARK: Functions + + func makeTaskContext() -> NSManagedObjectContext { + let taskContext = container.newBackgroundContext() + + taskContext.automaticallyMergesChangesFromParent = true + taskContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy + + return taskContext + } + + func makeChildContext() -> NSManagedObjectContext { + let context = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType) + + context.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy + context.parent = container.viewContext + context.automaticallyMergesChangesFromParent = true + + return context + } + + func save(context: NSManagedObjectContext) throws { + guard context.hasChanges else { + return + } + + try context.save() + } + + func save(childContext context: NSManagedObjectContext) throws { + guard context.hasChanges else { + return + } + + try context.save() + + guard + let parent = context.parent, + parent == container.viewContext + else { + return + } + + try parent.performAndWait { + try parent.save() + } + } + +} + +// MARK: - Helpers + +private extension TestPersistenceService { + + // MARK: Functions + + func setContainer() { + container.persistentStoreDescriptions = [ + .init(url: .bitBucket) + ] + + container.loadPersistentStores { _, error in + if let error = error as NSError? { + fatalError("Unresolved error \(error), \(error.userInfo)") + } + } + + container.viewContext.automaticallyMergesChangesFromParent = false + } + +} + +// MARK: - String+Constants + +private extension String { + enum Model { + static let name = "TestModel" + static let `extension` = "momd" + } +}