From ba8cd307cb025bc95084179fffa93ee825058c2a Mon Sep 17 00:00:00 2001 From: Javier Cicchelli Date: Sun, 16 Apr 2023 22:34:21 +0200 Subject: [PATCH 1/8] Declared the "Persistence" library in the Package file. --- Package.swift | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/Package.swift b/Package.swift index 591d13e..a97b7fe 100644 --- a/Package.swift +++ b/Package.swift @@ -46,6 +46,10 @@ let package = Package( name: "Dependencies", dependencies: [] ), + .target( + name: "Persistence", + dependencies: [] + ), // MARK: Test targets .testTarget( name: "CommunicationsTests", @@ -76,6 +80,13 @@ let package = Package( ], path: "Tests/Dependencies" ), + .testTarget( + name: "PersistenceTests", + dependencies: [ + "Persistence" + ], + path: "Tests/Persistence" + ), ] ) -- 2.47.1 From 825354ef6d11e4e5100817a19436d349c06fd82d Mon Sep 17 00:00:00 2001 From: Javier Cicchelli Date: Sun, 16 Apr 2023 23:05:10 +0200 Subject: [PATCH 2/8] Defined the Service public protocol. --- Sources/Persistence/Protocols/Service.swift | 36 +++++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 Sources/Persistence/Protocols/Service.swift diff --git a/Sources/Persistence/Protocols/Service.swift b/Sources/Persistence/Protocols/Service.swift new file mode 100644 index 0000000..d317459 --- /dev/null +++ b/Sources/Persistence/Protocols/Service.swift @@ -0,0 +1,36 @@ +// +// Service.swift +// Persistence +// +// Created by Javier Cicchelli on 13/04/2023. +// Copyright © 2023 Röck+Cöde. All rights reserved. +// + +import CoreData + +public protocol Service { + + // MARK: Properties + + /// The main managed object context. + var viewContext: NSManagedObjectContext { get } + + // MARK: Functions + + /// Create a private queue context. + /// - Returns: A concurrent `NSManagedObjectContext` context instance ready to use. + func makeTaskContext() -> NSManagedObjectContext + + /// Create a child context of the view context. + /// - Returns: A generated child `NSManagedObjectContext` context instance ready to use. + func makeChildContext() -> NSManagedObjectContext + + /// Save a given context. + /// - Parameter context: A `NSManagedObjectContext` context instance to save. + func save(context: NSManagedObjectContext) + + /// 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) + +} -- 2.47.1 From 879b6d6bbd7cca00b6a5507bbe8bc8f9c8497bd2 Mon Sep 17 00:00:00 2001 From: Javier Cicchelli Date: Mon, 17 Apr 2023 00:11:05 +0200 Subject: [PATCH 3/8] Implemented the Fetcher class. --- Sources/Persistence/Classes/Fetcher.swift | 154 ++++++++++++++++++++++ 1 file changed, 154 insertions(+) create mode 100644 Sources/Persistence/Classes/Fetcher.swift diff --git a/Sources/Persistence/Classes/Fetcher.swift b/Sources/Persistence/Classes/Fetcher.swift new file mode 100644 index 0000000..1afcaa8 --- /dev/null +++ b/Sources/Persistence/Classes/Fetcher.swift @@ -0,0 +1,154 @@ +// +// Fetcher.swift +// Persistence +// +// Created by Javier Cicchelli on 16/04/2023. +// Copyright © 2023 Röck+Cöde. All rights reserved. +// + +import Combine +import CoreData + +/// This class fetches objects from a given managed object context and it notifies of changes in the object fetched if any. +public class Fetcher: NSObject, NSFetchedResultsControllerDelegate { + + // MARK: Properties + + /// The publisher that emits the changes detected to the Location entities in a given object context. + public let didChangePublisher = PassthroughSubject<[Change], Never>() + + private let fetchedResultsController: NSFetchedResultsController + + /// The number of sections in the data. + public var numberOfSections: Int { + fetchedResultsController.sections?.count ?? 0 + } + + private var inProgressChanges: [Change] = [] + + // MARK: Initialisers + + /// Initialises the fetcher give the given parameters. + /// - Parameters: + /// - fetchRequest: The fetch request to use to get the objects. + /// - managedObjectContext: The managed object context against the fetch request is executed. + /// - sectionNameKeyPath: A key path on result objects that returns the section name. + /// - cacheName: The name of the cache file the receiver should use. + public init( + fetchRequest: NSFetchRequest, + managedObjectContext: NSManagedObjectContext, + sectionNameKeyPath: String? = nil, + cacheName: String? = nil + ) { + self.fetchedResultsController = .init( + fetchRequest: fetchRequest, + managedObjectContext: managedObjectContext, + sectionNameKeyPath: sectionNameKeyPath, + cacheName: cacheName + ) + + super.init() + + self.fetchedResultsController.delegate = self + } + + // MARK: Functions + + /// Perform the fetching. + public func fetch() throws { + try fetchedResultsController.performFetch() + } + + /// 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 + } + + 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 { + return fetchedResultsController.object(at: indexPath) + } + + // MARK: NSFetchedResultsControllerDelegate + + public func controllerWillChangeContent(_ controller: NSFetchedResultsController) { + inProgressChanges.removeAll() + } + + public func controllerDidChangeContent(_ controller: NSFetchedResultsController) { + didChangePublisher.send(inProgressChanges) + } + + public func controller( + _ controller: NSFetchedResultsController, + didChange sectionInfo: NSFetchedResultsSectionInfo, + atSectionIndex sectionIndex: Int, + for type: NSFetchedResultsChangeType + ) { + if type == .insert { + inProgressChanges.append(.section(.inserted(sectionIndex))) + } else if type == .delete { + inProgressChanges.append(.section(.deleted(sectionIndex))) + } + } + + public func controller( + _ controller: NSFetchedResultsController, + didChange anObject: Any, + at indexPath: IndexPath?, + for type: NSFetchedResultsChangeType, + newIndexPath: IndexPath? + ) { + switch type { + case .insert: + guard let newIndexPath else { return } + + inProgressChanges.append(.object(.inserted(at: newIndexPath))) + case .delete: + guard let indexPath else { return } + + inProgressChanges.append(.object(.deleted(from: indexPath))) + case .move: + guard let indexPath, let newIndexPath else { return } + + inProgressChanges.append(.object(.moved(from: indexPath, to: newIndexPath))) + case .update: + guard let indexPath else { return } + + inProgressChanges.append(.object(.updated(at: indexPath))) + default: + break + } + } + +} + +// MARK: - Enumerations + +public enum Change: Hashable { + public enum SectionUpdate: Hashable { + case inserted(Int) + case deleted(Int) + } + + public enum ObjectUpdate: Hashable { + case inserted(at: IndexPath) + case deleted(from: IndexPath) + case updated(at: IndexPath) + case moved(from: IndexPath, to: IndexPath) + } + + case section(SectionUpdate) + case object(ObjectUpdate) +} -- 2.47.1 From 7fd501f9093ff12fae5652bef893d23a15196e7a Mon Sep 17 00:00:00 2001 From: Javier Cicchelli Date: Mon, 17 Apr 2023 00:19:57 +0200 Subject: [PATCH 4/8] Implemented the "bitBucket" static constant in the URL+Devices public extension. --- .../Persistence/Extensions/URL+Devices.swift | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 Sources/Persistence/Extensions/URL+Devices.swift diff --git a/Sources/Persistence/Extensions/URL+Devices.swift b/Sources/Persistence/Extensions/URL+Devices.swift new file mode 100644 index 0000000..9297c23 --- /dev/null +++ b/Sources/Persistence/Extensions/URL+Devices.swift @@ -0,0 +1,18 @@ +// +// URL+Devices.swift +// Persistence +// +// Created by Javier Cicchelli on 17/04/2023. +// Copyright © 2023 Röck+Cöde. All rights reserved. +// + +import Foundation + +public extension URL { + + // MARK: Properties + + /// URL to a virtual device in which any data written vanishes or disappear. + static let bitBucket = URL(fileURLWithPath: "/dev/null") + +} -- 2.47.1 From 09a07ad373ac6127c481836bbada61da6d6f3a99 Mon Sep 17 00:00:00 2001 From: Javier Cicchelli Date: Mon, 17 Apr 2023 00:23:49 +0200 Subject: [PATCH 5/8] Updated a small description of the `Persistence` library in the README file. --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index c45716b..2889997 100644 --- a/README.md +++ b/README.md @@ -11,3 +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; -- 2.47.1 From 409a7170821c7f5b2d3219499fad3527683c9f52 Mon Sep 17 00:00:00 2001 From: Javier Cicchelli Date: Mon, 17 Apr 2023 17:44:11 +0200 Subject: [PATCH 6/8] Implemented some test cases for the Fetcher class and the URL+Device extension. --- README.md | 2 +- Sources/Persistence/Classes/Fetcher.swift | 27 ++- Sources/Persistence/Protocols/Service.swift | 4 +- Tests/Persistence/Classes/FetcherTests.swift | 194 ++++++++++++++++++ .../Extensions/URL+DevicesTests.swift | 30 +++ .../Helpers/NSFetchRequest+TestEntity.swift | 24 +++ .../Model.xcdatamodel/contents | 4 + .../Helpers/TestPersistenceService.swift | 127 ++++++++++++ 8 files changed, 401 insertions(+), 11 deletions(-) create mode 100644 Tests/Persistence/Classes/FetcherTests.swift create mode 100644 Tests/Persistence/Extensions/URL+DevicesTests.swift create mode 100644 Tests/Persistence/Helpers/NSFetchRequest+TestEntity.swift create mode 100644 Tests/Persistence/Helpers/TestModel.xcdatamodeld/Model.xcdatamodel/contents create mode 100644 Tests/Persistence/Helpers/TestPersistenceService.swift 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" + } +} -- 2.47.1 From a96c762b85ddbaa19e2c8d2b3751e4f96afb0573 Mon Sep 17 00:00:00 2001 From: Javier Cicchelli Date: Tue, 18 Apr 2023 01:01:45 +0200 Subject: [PATCH 7/8] Forgot to add the "Communications" and the "Persistence" targets to the "SwiftLibs" library in the Package file. --- Package.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Package.swift b/Package.swift index a97b7fe..c27328b 100644 --- a/Package.swift +++ b/Package.swift @@ -20,9 +20,11 @@ let package = Package( .library( name: "SwiftLibs", targets: [ + "Communications", "Coordination", "Core", - "Dependencies" + "Dependencies", + "Persistence" ] ), ], -- 2.47.1 From 62a07ff873861e1f7cc5a1f0ed364a4d7681e467 Mon Sep 17 00:00:00 2001 From: Javier Cicchelli Date: Tue, 18 Apr 2023 01:05:13 +0200 Subject: [PATCH 8/8] Implemented some more tests to the FetcherTests test cases. --- Sources/Persistence/Classes/Fetcher.swift | 18 +-- Tests/Persistence/Classes/FetcherTests.swift | 153 +++++++++++++++++-- 2 files changed, 148 insertions(+), 23 deletions(-) diff --git a/Sources/Persistence/Classes/Fetcher.swift b/Sources/Persistence/Classes/Fetcher.swift index f8b3db2..60bda74 100644 --- a/Sources/Persistence/Classes/Fetcher.swift +++ b/Sources/Persistence/Classes/Fetcher.swift @@ -24,7 +24,7 @@ public class Fetcher: NSObject, NSFetchedResultsControll fetchedResultsController.sections?.count ?? 0 } - private var inProgressChanges: [Change] = [] + private var changesToNotify: [Change] = [] // MARK: Initialisers @@ -87,11 +87,11 @@ public class Fetcher: NSObject, NSFetchedResultsControll // MARK: NSFetchedResultsControllerDelegate public func controllerWillChangeContent(_ controller: NSFetchedResultsController) { - inProgressChanges.removeAll() + changesToNotify.removeAll() } public func controllerDidChangeContent(_ controller: NSFetchedResultsController) { - didChangePublisher.send(inProgressChanges) + didChangePublisher.send(changesToNotify) } public func controller( @@ -101,9 +101,9 @@ public class Fetcher: NSObject, NSFetchedResultsControll for type: NSFetchedResultsChangeType ) { if type == .insert { - inProgressChanges.append(.section(.inserted(sectionIndex))) + changesToNotify.append(.section(.inserted(sectionIndex))) } else if type == .delete { - inProgressChanges.append(.section(.deleted(sectionIndex))) + changesToNotify.append(.section(.deleted(sectionIndex))) } } @@ -118,19 +118,19 @@ public class Fetcher: NSObject, NSFetchedResultsControll case .insert: guard let newIndexPath else { return } - inProgressChanges.append(.object(.inserted(at: newIndexPath))) + changesToNotify.append(.object(.inserted(at: newIndexPath))) case .delete: guard let indexPath else { return } - inProgressChanges.append(.object(.deleted(from: indexPath))) + changesToNotify.append(.object(.deleted(from: indexPath))) case .move: guard let indexPath, let newIndexPath else { return } - inProgressChanges.append(.object(.moved(from: indexPath, to: newIndexPath))) + changesToNotify.append(.object(.moved(from: indexPath, to: newIndexPath))) case .update: guard let indexPath else { return } - inProgressChanges.append(.object(.updated(at: indexPath))) + changesToNotify.append(.object(.updated(at: indexPath))) default: break } diff --git a/Tests/Persistence/Classes/FetcherTests.swift b/Tests/Persistence/Classes/FetcherTests.swift index d9bb0cc..facead0 100644 --- a/Tests/Persistence/Classes/FetcherTests.swift +++ b/Tests/Persistence/Classes/FetcherTests.swift @@ -6,6 +6,7 @@ // Copyright © 2023 Röck+Cöde. All rights reserved. // +import Combine import Persistence import XCTest @@ -13,21 +14,16 @@ final class FetcherTests: XCTestCase { // MARK: Properties - private let persistence = TestPersistenceService.shared - - private var fetcher: Fetcher! + private lazy var persistence: TestPersistenceService = .shared + private lazy var fetcher: Fetcher = .init( + fetchRequest: .allTestEntities(), + managedObjectContext: persistence.viewContext + ) // MARK: Setup - - override func setUpWithError() throws { - fetcher = .init( - fetchRequest: .allTestEntities(), - managedObjectContext: persistence.viewContext - ) - } - + override func tearDownWithError() throws { - fetcher = nil + try persistence.clean() } // MARK: Number of sections tests @@ -132,7 +128,7 @@ final class FetcherTests: XCTestCase { try fetcher.fetch() // WHEN & THEN - let indexPath = IndexPath( + let _ = IndexPath( item: 0, section: fetcher.numberOfSections - 1 ) @@ -169,7 +165,7 @@ final class FetcherTests: XCTestCase { try fetcher.fetch() // WHEN & THEN - let indexPath = IndexPath( + let _ = IndexPath( item: entities.count, section: fetcher.numberOfSections ) @@ -191,4 +187,133 @@ final class FetcherTests: XCTestCase { } } + // MARK: Did change publisher tests + + func test_didChangePublisher_whenModelIsEmpty() throws { + let expectation = self.expectation(description: "didChangePublisher when model is filled.") + + var result: [Change]? + + // GIVEN + let cancellable = fetcher + .didChangePublisher + .sink(receiveValue: { value in + result = value + + expectation.fulfill() + }) + + // WHEN + try fetcher.fetch() + + // THEN + let waiter = XCTWaiter.wait(for: [expectation], timeout: 1.0) + + guard waiter == .timedOut else { + XCTFail("Waiter expected to time out.") + return + } + + cancellable.cancel() + + XCTAssertNil(result) + } + + func test_didChangePublisher_whenModelIsFilled() throws { + let expectation = self.expectation(description: "didChangePublisher when model is filled.") + + var result: [Change]? + + // GIVEN + let context = persistence.makeChildContext() + let _ = [ + TestEntity(context: context), + TestEntity(context: context), + TestEntity(context: context) + ] + + let cancellable = fetcher + .didChangePublisher + .sink(receiveValue: { value in + result = value + + expectation.fulfill() + }) + + // WHEN + try persistence.save(childContext: context) + try fetcher.fetch() + + // THEN + let waiter = XCTWaiter.wait(for: [expectation], timeout: 1.0) + + guard waiter == .timedOut else { + XCTFail("Waiter expected to time out.") + return + } + + cancellable.cancel() + + XCTAssertNil(result) + } + + func test_didChangePublisher_whenModelIsUpdated() throws { + let expectation = self.expectation(description: "didChangePublisher when model is updated.") + + var result: [Change]? + + // GIVEN + let context = persistence.makeChildContext() + let entities = [ + TestEntity(context: context), + TestEntity(context: context), + TestEntity(context: context) + ] + + let cancellable = fetcher + .didChangePublisher + .sink(receiveValue: { value in + result = value + + expectation.fulfill() + }) + + // WHEN + try fetcher.fetch() + try persistence.save(childContext: context) + + // THEN + waitForExpectations(timeout: 1.0) + + cancellable.cancel() + + XCTAssertNotNil(result) + XCTAssertEqual(result?.count, entities.count) + XCTAssertEqual(result, [ + .object(.inserted(at: .init(item: 2, section: 0))), + .object(.inserted(at: .init(item: 1, section: 0))), + .object(.inserted(at: .init(item: 0, section: 0))), + ]) + } + +} + +// MARK: - TestPersistenceService+Functions + +private extension TestPersistenceService { + + // MARK: Functions + + func clean() throws { + let context = makeChildContext() + + try context.performAndWait { + try context + .fetch(.allTestEntities()) + .forEach(context.delete) + } + + try save(childContext: context) + } + } -- 2.47.1