Skip to content

Commit

Permalink
CanopyResultRecord (#22)
Browse files Browse the repository at this point in the history
* New type stubs

* CanopyRecordValueProtocol

* CanopyResultRecord with tests

* MockValueStore Codable scaffolding

* Encoding NSNumber (even though it’s never hit)

* Force NSType

* MockValueStore Array support

* Int and UInt

* NSString coding

* date and nsdate encoding

* float, data, nsdata coding

* Doc and test about NSArray coding

* MockValueStore is done

* Formatting

* MockCanopyResultRecord codable

* CanopyResultRecord Codable

* MockValueStore moved inside MockCanopyResultRecord

* Start with ReplayingMockContainer and Database

* Moved more types around

* Remove unneeded print

* Removed unneeded prints

* ReplayingMockContainer done

* Fewer warnings for error casting

* Renamed

* Better name for MockValueStoreTests

* Converted Canopy API to CanopyResultRecord

* ReplayingMockDatabase queryRecords

* ModifyRecordsResult Codable

* Modify and delete records results

* FetchRecordsResult Codable

* ReplayingMockDatabase WIP

* ModifyZonesResult Codable

* More results Codable and tests

* MockDatabase done, todo tests

* All ReplayingMockDatabase tests

* Fixed test failure in Sequoia with Xcode 16 beta 4

* Moved types around between modules

* Static blank result for fetch database changes

* Canopy type conformance in extension

* Removed MockCKRecord

* Mock container is more resilient now

* Adjustments

* More MOckCanopyResultRecord tests

* More CanopyResultRecord tests

* sleepBeforeEachOperation

* Default values for some parameters

* SleepIfNeeded unit test

* Maybe Github CI is happier with this?

* Maybe this will satisfy Github CI

* Shorter code, produces the same crash

* Trying with a different signature for subscript

* How about matching signatures in the other direction

* Updated encryptedValues API name

* Warning about documentation site

* Some documentation comments

* Updated version in readme

---------

Co-authored-by: Jaanus Kase <[email protected]>
  • Loading branch information
jaanus and Jaanus Kase authored Aug 20, 2024
1 parent f03c7ed commit 4d77e64
Show file tree
Hide file tree
Showing 65 changed files with 3,400 additions and 251 deletions.
93 changes: 93 additions & 0 deletions .swiftpm/xcode/xcshareddata/xcschemes/Canopy-Package.xcscheme
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1600"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"
buildArchitectures = "Automatic">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "Canopy"
BuildableName = "Canopy"
BlueprintName = "Canopy"
ReferencedContainer = "container:">
</BuildableReference>
</BuildActionEntry>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "CanopyTestTools"
BuildableName = "CanopyTestTools"
BlueprintName = "CanopyTestTools"
ReferencedContainer = "container:">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
<Testables>
<TestableReference
skipped = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "CanopyTests"
BuildableName = "CanopyTests"
BlueprintName = "CanopyTests"
ReferencedContainer = "container:">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "Canopy"
BuildableName = "Canopy"
BlueprintName = "Canopy"
ReferencedContainer = "container:">
</BuildableReference>
</MacroExpansion>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>
2 changes: 1 addition & 1 deletion .swiftpm/xcode/xcshareddata/xcschemes/Canopy.xcscheme
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1430"
LastUpgradeVersion = "1600"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1430"
LastUpgradeVersion = "1600"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
Expand Down
5 changes: 4 additions & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,10 @@ let package = Package(
.testTarget(
name: "CanopyTests",
dependencies: ["Canopy", "CanopyTestTools"],
path: "Targets/Canopy/Tests"
path: "Targets/Canopy/Tests",
resources: [
.process("Fixtures")
]
),
.target(
name: "CanopyTypes",
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ If you use SPM `Package.swift`, add this:
dependencies: [
.package(
url: "https://github.com/Tact/Canopy",
from: "0.2.0"
from: "0.5.0"
)
]
```
Expand Down
6 changes: 3 additions & 3 deletions Targets/Canopy/Sources/CKDatabaseAPI/CKDatabaseAPI.swift
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ actor CKDatabaseAPI: CKDatabaseAPIType {
in zoneID: CKRecordZone.ID?,
resultsLimit: Int?,
qos: QualityOfService
) async -> Result<[CKRecord], CKRecordError> {
) async -> Result<[CanopyResultRecord], CKRecordError> {
await QueryRecords.with(
query,
recordZoneID: zoneID,
Expand Down Expand Up @@ -160,7 +160,7 @@ actor CKDatabaseAPI: CKDatabaseAPIType {
continuation.resume(
returning: .success(
.init(
foundRecords: foundRecords,
foundRecords: foundRecords.map(\.canopyResultRecord),
notFoundRecordIDs: notFoundRecordIDs
)
)
Expand Down Expand Up @@ -567,7 +567,7 @@ actor CKDatabaseAPI: CKDatabaseAPIType {
continuation.resume(
returning: .success(
.init(
records: records,
records: records.map { $0.canopyResultRecord },
deletedRecords: deleted
)
)
Expand Down
2 changes: 2 additions & 0 deletions Targets/Canopy/Sources/Canopy.docc/Canopy.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

Write better, testable CloudKit apps.

_⚠️ This documentation site is currently half-broken, and does not include documentation for several Canopy types. Conceptual documentation works fine. Canopy types and code are spread across several Swift Package Manager modules, and DocC does not easily support this scenario out of the box, for generating a documentation site. [Work is in progress](https://forums.swift.org/t/are-there-updates-on-using-swift-docc-with-multiple-targets/73072) to address this._

Canopy helps you write better, more testable CloudKit apps. It isolates the CloudKit dependency so you can write fast and reliable tests for your CloudKit-related features, and implements standard CloudKit-related behaviors.

Canopy is built as part of [Tact](https://justtact.com). The Canopy source code and installation instructions (including the source for this site) are available on [GitHub](https://github.com/Tact/Canopy).
Expand Down
3 changes: 3 additions & 0 deletions Targets/Canopy/Sources/Canopy/Canopy.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ import CanopyTypes
import CloudKit
import Foundation

// Re-export the types, so `import Canopy` also imports the types.
@_exported import CanopyTypes

/// Main Canopy implementation.
///
/// You construct Canopy with injected CloudKit container and databases, token store, and settings provider.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,55 +5,59 @@ import CloudKit
/// where you need to isolate the CloudKit dependency and provide a
/// deterministic view of CloudKit data with simulated mock data.
///
/// You initialize MockCanopy with instances of mock container and databases.
/// You initialize MockCanopy with instances of mock CKContainer and CKDatabases.
/// The Canopy API then receives API calls and plays back the responses to those
/// requests, without any interaction with real CloudKit.
///
/// You only need to inject the containers and databases that your tests actually use.
/// If you try to use a dependency that’s not been injected correctly, MockCanopy crashes
/// with an error message indicating that.
///
/// MockCanopyWithCKMocks is mostly appropriate to use as a testing tool for Canopy’s
/// own logic, or when you need to inject your own Canopy settings for various behaviors.
/// For using in your own tests, `MockCanopy` is more appropriate and simpler to use.
@available(iOS 16.4, macOS 13.3, *)
public struct MockCanopy: CanopyType {
private let mockPrivateDatabase: CKDatabaseType?
private let mockSharedDatabase: CKDatabaseType?
private let mockPublicDatabase: CKDatabaseType?
private let mockContainer: CKContainerType?
public struct MockCanopyWithCKMocks: CanopyType {
private let mockPrivateCKDatabase: CKDatabaseType?
private let mockSharedCKDatabase: CKDatabaseType?
private let mockPublicCKDatabase: CKDatabaseType?
private let mockCKContainer: CKContainerType?
private let settingsProvider: @Sendable () async -> CanopySettingsType

public init(
mockPrivateDatabase: CKDatabaseType? = nil,
mockSharedDatabase: CKDatabaseType? = nil,
mockPublicDatabase: CKDatabaseType? = nil,
mockContainer: CKContainerType? = nil,
mockPrivateCKDatabase: CKDatabaseType? = nil,
mockSharedCKDatabase: CKDatabaseType? = nil,
mockPublicCKDatabase: CKDatabaseType? = nil,
mockCKContainer: CKContainerType? = nil,
settingsProvider: @escaping @Sendable () async -> CanopySettingsType = { CanopySettings() }
) {
self.mockPublicDatabase = mockPublicDatabase
self.mockSharedDatabase = mockSharedDatabase
self.mockPrivateDatabase = mockPrivateDatabase
self.mockContainer = mockContainer
self.mockPublicCKDatabase = mockPublicCKDatabase
self.mockSharedCKDatabase = mockSharedCKDatabase
self.mockPrivateCKDatabase = mockPrivateCKDatabase
self.mockCKContainer = mockCKContainer
self.settingsProvider = settingsProvider
}

public func databaseAPI(usingDatabaseScope scope: CKDatabase.Scope) -> CKDatabaseAPIType {
switch scope {
case .public:
guard let db = mockPublicDatabase else { fatalError("Requested public database which wasn’t correctly injected") }
guard let db = mockPublicCKDatabase else { fatalError("Requested public database which wasn’t correctly injected") }
return CKDatabaseAPI(
database: db,
databaseScope: .public,
settingsProvider: settingsProvider,
tokenStore: TestTokenStore()
)
case .private:
guard let db = mockPrivateDatabase else { fatalError("Requested private database which wasn’t correctly injected") }
guard let db = mockPrivateCKDatabase else { fatalError("Requested private database which wasn’t correctly injected") }
return CKDatabaseAPI(
database: db,
databaseScope: .private,
settingsProvider: settingsProvider,
tokenStore: TestTokenStore()
)
case .shared:
guard let db = mockSharedDatabase else { fatalError("Requested shared database which wasn’t correctly injected") }
guard let db = mockSharedCKDatabase else { fatalError("Requested shared database which wasn’t correctly injected") }
return CKDatabaseAPI(
database: db,
databaseScope: .shared,
Expand All @@ -66,7 +70,7 @@ public struct MockCanopy: CanopyType {
}

public func containerAPI() -> CKContainerAPIType {
guard let container = mockContainer else { fatalError("Requested CKContainer which wasn’t correctly injected") }
guard let container = mockCKContainer else { fatalError("Requested CKContainer which wasn’t correctly injected") }
return CKContainerAPI(
container: container,
accountChangedSequence: .mock(elementsToProduce: 1)
Expand Down
2 changes: 0 additions & 2 deletions Targets/Canopy/Sources/Dependency/Canopy+Dependency.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,6 @@ import Dependencies
@available(iOS 16.4, macOS 13.3, *)
private enum CanopyKey: DependencyKey, Sendable {
static let liveValue: CanopyType = Canopy()
static let testValue: CanopyType = MockCanopy()
static let previewValue: CanopyType = MockCanopy()
}

@available(iOS 16.4, macOS 13.3, *)
Expand Down
6 changes: 3 additions & 3 deletions Targets/Canopy/Sources/Features/ModifyRecords.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ struct ModifyRecords {
autoBatchToSmallerWhenLimitExceeded: Bool = true,
autoRetryForRetriableErrors: Bool = true
) async -> Result<ModifyRecordsResult, CKRecordError> {
var savedRecords: [CKRecord] = []
var savedRecords: [CanopyResultRecord] = []
var deletedRecordIDs: [CKRecord.ID] = []
var currentBatchSize = customBatchSize ?? CKBatchSize

Expand Down Expand Up @@ -68,7 +68,7 @@ struct ModifyRecords {

switch result {
case let .success(result):
savedRecords += result.savedRecords
savedRecords.append(contentsOf: result.savedRecords)
deletedRecordIDs += result.deletedRecordIDs
case let .failure(error):
if error == CKRecordError(from: CKError(CKError.Code.limitExceeded)), autoBatchToSmallerWhenLimitExceeded {
Expand Down Expand Up @@ -197,7 +197,7 @@ struct ModifyRecords {
continuation.resume(
returning: .success(
ModifyRecordsResult(
savedRecords: savedRecords,
savedRecords: savedRecords.map(\.canopyResultRecord),
deletedRecordIDs: deletedRecordIDs
)
)
Expand Down
8 changes: 5 additions & 3 deletions Targets/Canopy/Sources/Features/QueryRecords.swift
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ struct QueryRecords {
desiredKeys: [CKRecord.FieldKey]? = nil,
resultsLimit: Int? = nil,
qualityOfService: QualityOfService = .default
) async -> Result<[CKRecord], CKRecordError> {
) async -> Result<[CanopyResultRecord], CKRecordError> {
var startingPoint = QueryOperationStartingPoint.query(query)
var records: [CKRecord] = []

Expand All @@ -50,14 +50,16 @@ struct QueryRecords {
case let .error(error):
return .failure(error)
case let .records(newRecords):
return .success(records + newRecords)
let ckRecords = records + newRecords
return .success(ckRecords.map(\.canopyResultRecord))
case let .recordsAndCursor(newRecords, cursor):
guard !Task.isCancelled else {
return .failure(.init(from: CKError(CKError.Code.operationCancelled)))
}
// If there was a results limit, just return the result even if there was a cursor
if resultsLimit != nil {
return .success(records + newRecords)
let ckRecords = records + newRecords
return .success(ckRecords.map(\.canopyResultRecord))
}

startingPoint = QueryOperationStartingPoint.cursor(cursor)
Expand Down
26 changes: 0 additions & 26 deletions Targets/Canopy/Sources/Results/FetchDatabaseChangesResult.swift

This file was deleted.

15 changes: 0 additions & 15 deletions Targets/Canopy/Sources/Results/FetchRecordsResult.swift

This file was deleted.

16 changes: 0 additions & 16 deletions Targets/Canopy/Sources/Results/ModifyRecordsResult.swift

This file was deleted.

11 changes: 0 additions & 11 deletions Targets/Canopy/Sources/Results/ModifySubscriptionsResult.swift

This file was deleted.

Loading

0 comments on commit 4d77e64

Please sign in to comment.