From 2346ca1b4973947e2e469b6d0ecb28e7305b0369 Mon Sep 17 00:00:00 2001 From: Oschly Date: Sun, 24 May 2026 20:40:25 +0200 Subject: [PATCH] unit tests --- .../Peered.xcodeproj/project.pbxproj | 138 +++++++++++++++++- .../Peered/NotesList/NotesStorage.swift | 47 ++++-- .../Helpers/InMemoryStorageProvider.swift | 27 ++++ .../PeeredTests/NoteContentCodableTests.swift | 28 ++++ .../PeeredTests/NoteInvitationTests.swift | 88 +++++++++++ .../PeeredTests/NoteMessageCodableTests.swift | 37 +++++ .../PeeredTests/NotesStorageTests.swift | 74 ++++++++++ 7 files changed, 427 insertions(+), 12 deletions(-) create mode 100644 Implementation/PeeredTests/Helpers/InMemoryStorageProvider.swift create mode 100644 Implementation/PeeredTests/NoteContentCodableTests.swift create mode 100644 Implementation/PeeredTests/NoteInvitationTests.swift create mode 100644 Implementation/PeeredTests/NoteMessageCodableTests.swift create mode 100644 Implementation/PeeredTests/NotesStorageTests.swift diff --git a/Implementation/Peered.xcodeproj/project.pbxproj b/Implementation/Peered.xcodeproj/project.pbxproj index 7202ae9..3e9f73d 100644 --- a/Implementation/Peered.xcodeproj/project.pbxproj +++ b/Implementation/Peered.xcodeproj/project.pbxproj @@ -6,8 +6,19 @@ objectVersion = 77; objects = { +/* Begin PBXContainerItemProxy section */ + 47D771C32FC2141600C4C002 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 479E819F2DD09F9400B82386 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 479E81A62DD09F9400B82386; + remoteInfo = Peered; + }; +/* End PBXContainerItemProxy section */ + /* Begin PBXFileReference section */ 479E81A72DD09F9400B82386 /* Peered.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Peered.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 47D771BF2FC2141600C4C002 /* PeeredTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = PeeredTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ @@ -29,6 +40,11 @@ path = Peered; sourceTree = ""; }; + 47D771C02FC2141600C4C002 /* PeeredTests */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = PeeredTests; + sourceTree = ""; + }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ @@ -39,6 +55,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 47D771BC2FC2141600C4C002 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -46,6 +69,7 @@ isa = PBXGroup; children = ( 479E81A92DD09F9400B82386 /* Peered */, + 47D771C02FC2141600C4C002 /* PeeredTests */, 479E81A82DD09F9400B82386 /* Products */, ); sourceTree = ""; @@ -54,6 +78,7 @@ isa = PBXGroup; children = ( 479E81A72DD09F9400B82386 /* Peered.app */, + 47D771BF2FC2141600C4C002 /* PeeredTests.xctest */, ); name = Products; sourceTree = ""; @@ -83,6 +108,29 @@ productReference = 479E81A72DD09F9400B82386 /* Peered.app */; productType = "com.apple.product-type.application"; }; + 47D771BE2FC2141600C4C002 /* PeeredTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 47D771C52FC2141600C4C002 /* Build configuration list for PBXNativeTarget "PeeredTests" */; + buildPhases = ( + 47D771BB2FC2141600C4C002 /* Sources */, + 47D771BC2FC2141600C4C002 /* Frameworks */, + 47D771BD2FC2141600C4C002 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 47D771C42FC2141600C4C002 /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + 47D771C02FC2141600C4C002 /* PeeredTests */, + ); + name = PeeredTests; + packageProductDependencies = ( + ); + productName = PeeredTests; + productReference = 47D771BF2FC2141600C4C002 /* PeeredTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -90,12 +138,16 @@ isa = PBXProject; attributes = { BuildIndependentTargetsInParallel = 1; - LastSwiftUpdateCheck = 1630; + LastSwiftUpdateCheck = 2650; LastUpgradeCheck = 1640; TargetAttributes = { 479E81A62DD09F9400B82386 = { CreatedOnToolsVersion = 16.3; }; + 47D771BE2FC2141600C4C002 = { + CreatedOnToolsVersion = 26.5; + TestTargetID = 479E81A62DD09F9400B82386; + }; }; }; buildConfigurationList = 479E81A22DD09F9400B82386 /* Build configuration list for PBXProject "Peered" */; @@ -113,6 +165,7 @@ projectRoot = ""; targets = ( 479E81A62DD09F9400B82386 /* Peered */, + 47D771BE2FC2141600C4C002 /* PeeredTests */, ); }; /* End PBXProject section */ @@ -125,6 +178,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 47D771BD2FC2141600C4C002 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -135,8 +195,23 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 47D771BB2FC2141600C4C002 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ +/* Begin PBXTargetDependency section */ + 47D771C42FC2141600C4C002 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 479E81A62DD09F9400B82386 /* Peered */; + targetProxy = 47D771C32FC2141600C4C002 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + /* Begin XCBuildConfiguration section */ 479E81C72DD09F9500B82386 /* Debug */ = { isa = XCBuildConfiguration; @@ -364,6 +439,58 @@ }; name = Release; }; + 47D771C62FC2141600C4C002 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = LTFJ368N25; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 26.5; + MACOSX_DEPLOYMENT_TARGET = 26.5; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = me.oschly.PeeredTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = auto; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator"; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2,7"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Peered.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Peered"; + XROS_DEPLOYMENT_TARGET = 26.5; + }; + name = Debug; + }; + 47D771C72FC2141600C4C002 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = LTFJ368N25; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 26.5; + MACOSX_DEPLOYMENT_TARGET = 26.5; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = me.oschly.PeeredTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = auto; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator"; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2,7"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Peered.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Peered"; + XROS_DEPLOYMENT_TARGET = 26.5; + }; + name = Release; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -385,6 +512,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + 47D771C52FC2141600C4C002 /* Build configuration list for PBXNativeTarget "PeeredTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 47D771C62FC2141600C4C002 /* Debug */, + 47D771C72FC2141600C4C002 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ }; rootObject = 479E819F2DD09F9400B82386 /* Project object */; diff --git a/Implementation/Peered/NotesList/NotesStorage.swift b/Implementation/Peered/NotesList/NotesStorage.swift index f6702b3..8ff9772 100644 --- a/Implementation/Peered/NotesList/NotesStorage.swift +++ b/Implementation/Peered/NotesList/NotesStorage.swift @@ -7,19 +7,37 @@ import Foundation -struct NotesStorage { - let storageProvider: FileManager = FileManager.default +protocol StorageProvider { + func contentsOfDirectory(atPath path: String) throws -> [String] + @discardableResult + func createFile(atPath path: String, contents data: Data?, attributes attr: [FileAttributeKey: Any]?) -> Bool +} + +extension FileManager: StorageProvider {} + +struct NotesStorage { + let storageProvider: StorageProvider + let rootDirectory: URL + + init( + storageProvider: StorageProvider = FileManager.default, + rootDirectory: URL = .documentsDirectory + ) { + self.storageProvider = storageProvider + self.rootDirectory = rootDirectory + } + func loadNotes() -> [Note] { let files = try! storageProvider - .contentsOfDirectory(atPath: URL.documentsDirectory.path) + .contentsOfDirectory(atPath: rootDirectory.path) var notes = [Note]() - + for file in files.compactMap({ URL( filePath: $0, directoryHint: .notDirectory, - relativeTo: URL.documentsDirectory + relativeTo: rootDirectory ) }) { let name = file.lastPathComponent @@ -27,20 +45,19 @@ struct NotesStorage { name: name, path: file ) - notes.append(note) } - + return notes } - + func createNote(name: String) { let currentNotes = loadNotes() var index: Int? = nil var proposedName: String { index.map { name + " \($0)" } ?? name } - + while currentNotes.contains(where: { $0.name == proposedName }) { if let _index = index { index = _index + 1 @@ -48,7 +65,15 @@ struct NotesStorage { index = 1 } } - let pathToWrite = URL.documentsDirectory.appendingPathComponent(proposedName).appendingPathExtension(for: .text) - try! String().write(to: pathToWrite, atomically: true, encoding: .utf8) + + let pathToWrite = rootDirectory + .appendingPathComponent(proposedName) + .appendingPathExtension(for: .text) + + storageProvider.createFile( + atPath: pathToWrite.path, + contents: Data(), + attributes: nil + ) } } diff --git a/Implementation/PeeredTests/Helpers/InMemoryStorageProvider.swift b/Implementation/PeeredTests/Helpers/InMemoryStorageProvider.swift new file mode 100644 index 0000000..cf77b1d --- /dev/null +++ b/Implementation/PeeredTests/Helpers/InMemoryStorageProvider.swift @@ -0,0 +1,27 @@ +import Foundation + +@testable import Peered + +final class InMemoryStorageProvider: StorageProvider { + private(set) var files: [String: Data] = [:] + + func contentsOfDirectory(atPath path: String) throws -> [String] { + return files.keys.compactMap { filePath -> String? in + guard filePath.hasPrefix(path) else { return nil } + let remainder = String(filePath.dropFirst(path.count)) + let stripped = remainder.hasPrefix("/") ? String(remainder.dropFirst()) : remainder + guard !stripped.isEmpty, !stripped.contains("/") else { return nil } + return stripped + } + } + + @discardableResult + func createFile( + atPath path: String, + contents data: Data?, + attributes attr: [FileAttributeKey: Any]? + ) -> Bool { + files[path] = data ?? Data() + return true + } +} diff --git a/Implementation/PeeredTests/NoteContentCodableTests.swift b/Implementation/PeeredTests/NoteContentCodableTests.swift new file mode 100644 index 0000000..d94112d --- /dev/null +++ b/Implementation/PeeredTests/NoteContentCodableTests.swift @@ -0,0 +1,28 @@ +import Foundation +import Testing + +@testable import Peered + +@Suite +struct NoteContentCodableTests { + @Test("encode → decode") + func coding() throws { + let original = NoteInvitation.NoteContent( + title: "My note", noteSnapshot: "Note Sample Snapshot") + let data = try JSONEncoder().encode(original) + let decoded = try JSONDecoder().decode(NoteInvitation.NoteContent.self, from: data) + + #expect(decoded.title == original.title) + #expect(decoded.noteSnapshot == original.noteSnapshot) + } + + @Test + func containsExpectedKeys() throws { + let content = NoteInvitation.NoteContent(title: "Titlee", noteSnapshot: "Snapshott") + let data = try JSONEncoder().encode(content) + let json = try #require(try? JSONSerialization.jsonObject(with: data) as? [String: String]) + + #expect(json["title"] == "Titlee") + #expect(json["noteSnapshot"] == "Snapshott") + } +} diff --git a/Implementation/PeeredTests/NoteInvitationTests.swift b/Implementation/PeeredTests/NoteInvitationTests.swift new file mode 100644 index 0000000..7e9532a --- /dev/null +++ b/Implementation/PeeredTests/NoteInvitationTests.swift @@ -0,0 +1,88 @@ +import MultipeerConnectivity +import Testing + +@testable import Peered + +extension NoteInvitation { + fileprivate init(handler: @escaping (Bool) -> Void) { + self.init( + invitatorID: .init( + displayName: "host" + ), + note: .init(title: "Shared note", noteSnapshot: "Snapshot"), + invitationHandler: handler + ) + } +} + +@Suite +struct NoteInvitationTests { + @Test + func acceptCallsHandlerWithTrue() { + var received: Bool? = nil + var invitation = NoteInvitation { received = $0 } + + invitation.accept() + + #expect(received == true) + } + + @Test + func declineCallsHandlerWithFalse() { + var received: Bool? = nil + var invitation = NoteInvitation { received = $0 } + + invitation.decline() + + #expect(received == false) + } + + @Test + func acceptIsIdempotent() { + var callCount = 0 + var invitation = NoteInvitation { _ in callCount += 1 } + + invitation.accept() + invitation.accept() + + #expect(callCount == 1) + } + + @Test + func declineIsIdempotent() { + var callCount = 0 + var invitation = NoteInvitation { _ in callCount += 1 } + + invitation.decline() + invitation.decline() + + #expect(callCount == 1) + } + + @Test + func declineAfterAcceptIsNoOp() { + var callCount = 0 + var invitation = NoteInvitation { _ in callCount += 1 } + + invitation.accept() + invitation.decline() + + #expect(callCount == 1) + } + + @Test + func noteNameReturnsTitle() { + let invitation = NoteInvitation { _ in } + #expect(invitation.noteName == "Shared note") + } + + @Test + func idIsInvitatorPeerID() { + let peerID = MCPeerID(displayName: "host") + let invitation = NoteInvitation( + invitatorID: peerID, + note: .init(title: "T", noteSnapshot: "S") + ) + #expect(invitation.id == peerID) + } +} diff --git a/Implementation/PeeredTests/NoteMessageCodableTests.swift b/Implementation/PeeredTests/NoteMessageCodableTests.swift new file mode 100644 index 0000000..e82cd92 --- /dev/null +++ b/Implementation/PeeredTests/NoteMessageCodableTests.swift @@ -0,0 +1,37 @@ +import Foundation +import Testing + +@testable import Peered + +@Suite +struct NoteMessageCodableTests { + @Test + func encodesToJSON() throws { + let message = NoteMessage(senderID: "host", content: "Content") + let data = try JSONEncoder().encode(message) + let json = try #require(try? JSONSerialization.jsonObject(with: data) as? [String: String]) + + #expect(json["senderID"] == "host") + #expect(json["content"] == "Content") + } + + @Test + func decodesFromJSON() throws { + let json = #"{"senderID":"host","content":"contentt"}"# + let data = try #require(json.data(using: .utf8)) + let message = try JSONDecoder().decode(NoteMessage.self, from: data) + + #expect(message.senderID == "host") + #expect(message.content == "contentt") + } + + @Test("encode → decode") + func coding() throws { + let original = NoteMessage(senderID: "host", content: "coding test") + let data = try JSONEncoder().encode(original) + let decoded = try JSONDecoder().decode(NoteMessage.self, from: data) + + #expect(decoded.senderID == original.senderID) + #expect(decoded.content == original.content) + } +} diff --git a/Implementation/PeeredTests/NotesStorageTests.swift b/Implementation/PeeredTests/NotesStorageTests.swift new file mode 100644 index 0000000..727b14d --- /dev/null +++ b/Implementation/PeeredTests/NotesStorageTests.swift @@ -0,0 +1,74 @@ +import Foundation +import Testing + +@testable import Peered + +@Suite +struct NotesStorageTests { + private func makeStorage(root: URL = URL(filePath: "/test")) -> ( + NotesStorage, InMemoryStorageProvider + ) { + let provider = InMemoryStorageProvider() + let storage = NotesStorage(storageProvider: provider, rootDirectory: root) + return (storage, provider) + } + + @Test + func loadNotesReturnsEmptyArrayWhenNoFiles() { + let (storage, _) = makeStorage() + let notes = storage.loadNotes() + #expect(notes.isEmpty) + } + + @Test + func createNoteCreatesFile() { + let root = URL(filePath: "/test") + let (storage, provider) = makeStorage(root: root) + + storage.createNote(name: "My note") + + #expect(!provider.files.isEmpty) + } + + @Test + func loadNotesReturnCreatedNote() { + let root = URL(filePath: "/test") + let (storage, provider) = makeStorage(root: root) + + let filePath = root.appendingPathComponent("Note").path + provider.createFile(atPath: filePath, contents: Data(), attributes: nil) + + let notes = storage.loadNotes() + + #expect(notes.count == 1) + #expect(notes.first?.name == "Note") + } + + @Test + func createNoteDeduplicatesNames() { + let root = URL(filePath: "/test") + let (storage, provider) = makeStorage(root: root) + + let existingPath = root.appendingPathComponent("Note").path + provider.createFile(atPath: existingPath, contents: Data(), attributes: nil) + + storage.createNote(name: "Note") + + #expect(provider.files.count == 2) + + let paths = provider.files.keys + #expect(paths.contains(where: { $0.contains("Note 1") })) + } + + @Test + func createNoteTwiceCreatesTwoFiles() { + let root = URL(filePath: "/test") + let (storage, _) = makeStorage(root: root) + + storage.createNote(name: "Note") + storage.createNote(name: "Note") + + let notes = storage.loadNotes() + #expect(notes.count == 2) + } +}