diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml index d652d72..793f905 100644 --- a/.github/workflows/pull_request.yml +++ b/.github/workflows/pull_request.yml @@ -34,6 +34,19 @@ jobs: bundler-cache: true - run: bundle exec rubocop + test: + name: Test + runs-on: macos-26 + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + - uses: ruby/setup-ruby@v1 + with: + bundler-cache: true + - name: Install XcodeGen + run: brew install xcodegen + - run: bundle exec fastlane ios test + build-ios-debug: name: Build iOS/iPadOS Debug runs-on: macos-26 diff --git a/.licenseignore b/.licenseignore index 10ece98..ce6ea79 100644 --- a/.licenseignore +++ b/.licenseignore @@ -9,6 +9,7 @@ README.md *.jpg Jottre.xcodeproj Resources +Tests/Resources hooks fastlane Gemfile diff --git a/Sources/CloudMigrationPage/CloudMigrationCoordinator.swift b/Sources/CloudMigrationPage/CloudMigrationCoordinator.swift index 2e93e32..d09ac3d 100644 --- a/Sources/CloudMigrationPage/CloudMigrationCoordinator.swift +++ b/Sources/CloudMigrationPage/CloudMigrationCoordinator.swift @@ -18,9 +18,14 @@ import UIKit +@MainActor protocol CloudMigrationCoordinatorProtocol: Coordinator { func shouldStart() -> Bool + + func showInfoAlert(title: String, message: String) + + func dismiss() } final class CloudMigrationCoordinator: CloudMigrationCoordinatorProtocol { diff --git a/Sources/CloudMigrationPage/CloudMigrationViewModel.swift b/Sources/CloudMigrationPage/CloudMigrationViewModel.swift index 3faa04b..080dc0d 100644 --- a/Sources/CloudMigrationPage/CloudMigrationViewModel.swift +++ b/Sources/CloudMigrationPage/CloudMigrationViewModel.swift @@ -25,7 +25,7 @@ final class CloudMigrationViewModel: PageViewModel, Sendable { private let itemsContinuation: AsyncStream<[PageCellItem]>.Continuation private let repository: CloudMigrationRepositoryProtocol - private weak var coordinator: CloudMigrationCoordinator? + private weak var coordinator: CloudMigrationCoordinatorProtocol? private(set) lazy var actions = [ PageCallToActionView.ActionConfiguration( @@ -41,7 +41,7 @@ final class CloudMigrationViewModel: PageViewModel, Sendable { init( repository: CloudMigrationRepositoryProtocol, - coordinator: CloudMigrationCoordinator + coordinator: CloudMigrationCoordinatorProtocol ) { self.repository = repository self.coordinator = coordinator diff --git a/Sources/EditJotPage/EditJotCoordinator.swift b/Sources/EditJotPage/EditJotCoordinator.swift index ac1a917..23e1f0e 100644 --- a/Sources/EditJotPage/EditJotCoordinator.swift +++ b/Sources/EditJotPage/EditJotCoordinator.swift @@ -18,7 +18,28 @@ import UIKit -final class EditJotCoordinator: NavigationCoordinator { +protocol EditJotCoordinatorProtocol: NavigationCoordinator { + + func showShareJot( + jotFileInfo: JotFile.Info, + format: ShareFormat, + configurePopoverAnchor: PopoverAnchor? + ) + func showRenameAlert(jotFileInfo: JotFile.Info) + func openDeleteJot(jotFileInfo: JotFile.Info) + func openJot(jotFileInfo: JotFile.Info) + func showInFiles(jotFileInfo: JotFile.Info) + func showJotConflictPage( + jotFileInfo: JotFile.Info, + jotFileVersions: [JotFileVersion], + onResult: @Sendable @escaping (_ result: JotConflictResult) -> Void + ) + func canGoBack() -> Bool + func goBack() + func showInfoAlert(title: String, message: String) +} + +final class EditJotCoordinator: NavigationCoordinator, EditJotCoordinatorProtocol { private var retainedInfoAlertCoordinator: Coordinator? diff --git a/Sources/EditJotPage/EditJotViewControllerFactory.swift b/Sources/EditJotPage/EditJotViewControllerFactory.swift index d5d55b0..ac63fa6 100644 --- a/Sources/EditJotPage/EditJotViewControllerFactory.swift +++ b/Sources/EditJotPage/EditJotViewControllerFactory.swift @@ -23,7 +23,7 @@ protocol EditJotViewControllerFactoryProtocol: Sendable { func make( jotFileInfo: JotFile.Info, - coordinator: EditJotCoordinator + coordinator: EditJotCoordinatorProtocol ) -> UIViewController } @@ -35,7 +35,7 @@ struct EditJotViewControllerFactory: EditJotViewControllerFactoryProtocol { func make( jotFileInfo: JotFile.Info, - coordinator: EditJotCoordinator, + coordinator: EditJotCoordinatorProtocol, ) -> UIViewController { EditJotViewController( viewModel: EditJotViewModel( diff --git a/Sources/EditJotPage/EditJotViewModel.swift b/Sources/EditJotPage/EditJotViewModel.swift index 3b829af..0aa502d 100644 --- a/Sources/EditJotPage/EditJotViewModel.swift +++ b/Sources/EditJotPage/EditJotViewModel.swift @@ -93,13 +93,13 @@ final class EditJotViewModel: Sendable { private let jotFileInfo: JotFile.Info private let repository: EditJotRepositoryProtocol - private weak var coordinator: EditJotCoordinator? + private weak var coordinator: EditJotCoordinatorProtocol? private let menuConfigurationFactory: JotMenuConfigurationFactory init( jotFileInfo: JotFile.Info, repository: EditJotRepositoryProtocol, - coordinator: EditJotCoordinator, + coordinator: EditJotCoordinatorProtocol, menuConfigurationFactory: JotMenuConfigurationFactory ) { self.jotFileInfo = jotFileInfo diff --git a/Sources/EnableCloudPage/EnableCloudCoordinator.swift b/Sources/EnableCloudPage/EnableCloudCoordinator.swift index 68b2d55..db22edc 100644 --- a/Sources/EnableCloudPage/EnableCloudCoordinator.swift +++ b/Sources/EnableCloudPage/EnableCloudCoordinator.swift @@ -18,7 +18,13 @@ import UIKit -final class EnableCloudCoordinator: Coordinator { +protocol EnableCloudCoordinatorProtocol: Coordinator { + + func openLearnHowToEnable() + func dismiss() +} + +final class EnableCloudCoordinator: Coordinator, EnableCloudCoordinatorProtocol { var onEnd: (() -> Void)? diff --git a/Sources/EnableCloudPage/EnableCloudViewControllerFactory.swift b/Sources/EnableCloudPage/EnableCloudViewControllerFactory.swift index ecb18f2..45cb6c4 100644 --- a/Sources/EnableCloudPage/EnableCloudViewControllerFactory.swift +++ b/Sources/EnableCloudPage/EnableCloudViewControllerFactory.swift @@ -21,7 +21,7 @@ import UIKit @MainActor protocol EnableCloudViewControllerFactoryProtocol: Sendable { - func make(coordinator: EnableCloudCoordinator) -> UIViewController + func make(coordinator: EnableCloudCoordinatorProtocol) -> UIViewController } struct EnableCloudViewControllerFactory: EnableCloudViewControllerFactoryProtocol { @@ -29,7 +29,7 @@ struct EnableCloudViewControllerFactory: EnableCloudViewControllerFactoryProtoco let textBarButtonItemFactory: TextBarButtonItemFactory let symbolBarButtonItemFactory: SymbolBarButtonItemFactory - func make(coordinator: EnableCloudCoordinator) -> UIViewController { + func make(coordinator: EnableCloudCoordinatorProtocol) -> UIViewController { PageViewController( viewModel: EnableCloudViewModel( coordinator: coordinator diff --git a/Sources/EnableCloudPage/EnableCloudViewModel.swift b/Sources/EnableCloudPage/EnableCloudViewModel.swift index fc82312..4786795 100644 --- a/Sources/EnableCloudPage/EnableCloudViewModel.swift +++ b/Sources/EnableCloudPage/EnableCloudViewModel.swift @@ -27,7 +27,7 @@ final class EnableCloudViewModel: PageViewModel, Sendable { let items: AsyncStream<[PageCellItem]> private let itemsContinuation: AsyncStream<[PageCellItem]>.Continuation - private weak var coordinator: EnableCloudCoordinator? + private weak var coordinator: EnableCloudCoordinatorProtocol? private(set) lazy var actions = [ PageCallToActionView.ActionConfiguration( @@ -39,7 +39,7 @@ final class EnableCloudViewModel: PageViewModel, Sendable { } ] - init(coordinator: EnableCloudCoordinator) { + init(coordinator: EnableCloudCoordinatorProtocol) { self.coordinator = coordinator (items, itemsContinuation) = AsyncStream.makeStream( diff --git a/Sources/JotConflictPage/JotConflictCoordinator.swift b/Sources/JotConflictPage/JotConflictCoordinator.swift index 62401bc..eba26aa 100644 --- a/Sources/JotConflictPage/JotConflictCoordinator.swift +++ b/Sources/JotConflictPage/JotConflictCoordinator.swift @@ -18,7 +18,13 @@ import UIKit -final class JotConflictCoordinator: Coordinator { +protocol JotConflictCoordinatorProtocol: Coordinator { + + func showInfoAlert(title: String, message: String) + func dismiss(completion: @Sendable @escaping () -> Void) +} + +final class JotConflictCoordinator: Coordinator, JotConflictCoordinatorProtocol { private var retainedInfoAlertCoordinator: Coordinator? diff --git a/Sources/JotConflictPage/JotConflictViewModel.swift b/Sources/JotConflictPage/JotConflictViewModel.swift index 72c14ea..3eafd83 100644 --- a/Sources/JotConflictPage/JotConflictViewModel.swift +++ b/Sources/JotConflictPage/JotConflictViewModel.swift @@ -85,14 +85,14 @@ final class JotConflictViewModel: PageViewModel, Sendable { private let jotFileInfo: JotFile.Info private let jotFileVersions: [JotFileVersion] private let repository: JotConflictRepositoryProtocol - private weak var coordinator: JotConflictCoordinator? + private weak var coordinator: JotConflictCoordinatorProtocol? private let onResult: @Sendable (_ result: JotConflictResult) -> Void init( jotFileInfo: JotFile.Info, jotFileVersions: [JotFileVersion], repository: JotConflictRepositoryProtocol, - coordinator: JotConflictCoordinator, + coordinator: JotConflictCoordinatorProtocol, onResult: @Sendable @escaping (_ result: JotConflictResult) -> Void ) { assert(jotFileVersions.count >= 1, "Resolving a version conflict between less than two files is not logical.") diff --git a/Sources/JotsPage/JotsCoordinator.swift b/Sources/JotsPage/JotsCoordinator.swift index b3c4877..4c6bbc9 100644 --- a/Sources/JotsPage/JotsCoordinator.swift +++ b/Sources/JotsPage/JotsCoordinator.swift @@ -18,8 +18,25 @@ import UIKit +protocol JotsCoordinatorProtocol: NavigationCoordinator { + + func openSettings() + func openCreateJot() + func openJot(jotFileInfo: JotFile.Info, prefersNewWindow: Bool) + func openEnableCloudPage() + func showShareJot( + jotFileInfo: JotFile.Info, + format: ShareFormat, + configurePopoverAnchor: PopoverAnchor? + ) + func showRenameAlert(jotFileInfo: JotFile.Info) + func openDeleteJot(jotFileInfo: JotFile.Info) + func showInfoAlert(title: String, message: String) + func showInFiles(jotFileInfo: JotFile.Info) +} + @MainActor -final class JotsCoordinator: NavigationCoordinator { +final class JotsCoordinator: NavigationCoordinator, JotsCoordinatorProtocol { private var cloudMigrationTask: Task? @@ -40,11 +57,11 @@ final class JotsCoordinator: NavigationCoordinator { ] private let navigation: Navigation - private let jotsViewControllerFactory: JotsViewControllerFactory - private let settingsCoordinatorFactory: SettingsCoordinatorFactory - private let enableCloudCoordinatorFactory: EnableCloudCoordinatorFactory - private let editJotCoordinatorFactory: EditJotCoordinatorFactory - private let cloudMigrationCoordinatorFactory: CloudMigrationCoordinatorFactory + private let jotsViewControllerFactory: JotsViewControllerFactoryProtocol + private let settingsCoordinatorFactory: SettingsCoordinatorFactoryProtocol + private let enableCloudCoordinatorFactory: EnableCloudCoordinatorFactoryProtocol + private let editJotCoordinatorFactory: EditJotCoordinatorFactoryProtocol + private let cloudMigrationCoordinatorFactory: CloudMigrationCoordinatorFactoryProtocol private let createJotCoordinatorFactory: CreateJotCoordinatorFactoryProtocol private let deleteJotCoordinatorFactory: DeleteJotCoordinatorFactoryProtocol private let renameJotCoordinatorFactory: RenameJotCoordinatorFactoryProtocol @@ -53,11 +70,11 @@ final class JotsCoordinator: NavigationCoordinator { init( navigation: Navigation, - jotsViewControllerFactory: JotsViewControllerFactory, - settingsCoordinatorFactory: SettingsCoordinatorFactory, - enableCloudCoordinatorFactory: EnableCloudCoordinatorFactory, - editJotCoordinatorFactory: EditJotCoordinatorFactory, - cloudMigrationCoordinatorFactory: CloudMigrationCoordinatorFactory, + jotsViewControllerFactory: JotsViewControllerFactoryProtocol, + settingsCoordinatorFactory: SettingsCoordinatorFactoryProtocol, + enableCloudCoordinatorFactory: EnableCloudCoordinatorFactoryProtocol, + editJotCoordinatorFactory: EditJotCoordinatorFactoryProtocol, + cloudMigrationCoordinatorFactory: CloudMigrationCoordinatorFactoryProtocol, createJotCoordinatorFactory: CreateJotCoordinatorFactoryProtocol, deleteJotCoordinatorFactory: DeleteJotCoordinatorFactoryProtocol, renameJotCoordinatorFactory: RenameJotCoordinatorFactoryProtocol, diff --git a/Sources/JotsPage/JotsViewControllerFactory.swift b/Sources/JotsPage/JotsViewControllerFactory.swift index 1f16f61..7a6a5d3 100644 --- a/Sources/JotsPage/JotsViewControllerFactory.swift +++ b/Sources/JotsPage/JotsViewControllerFactory.swift @@ -21,7 +21,7 @@ import UIKit @MainActor protocol JotsViewControllerFactoryProtocol: Sendable { - func make(coordinator: JotsCoordinator) -> UIViewController + func make(coordinator: JotsCoordinatorProtocol) -> UIViewController } struct JotsViewControllerFactory: JotsViewControllerFactoryProtocol { @@ -31,7 +31,7 @@ struct JotsViewControllerFactory: JotsViewControllerFactoryProtocol { let textBarButtonItemFactory: TextBarButtonItemFactory let symbolBarButtonItemFactory: SymbolBarButtonItemFactory - func make(coordinator: JotsCoordinator) -> UIViewController { + func make(coordinator: JotsCoordinatorProtocol) -> UIViewController { let viewController = PageViewController( viewModel: JotsViewModel( coordinator: coordinator, diff --git a/Sources/JotsPage/JotsViewModel.swift b/Sources/JotsPage/JotsViewModel.swift index 42239ed..532fea3 100644 --- a/Sources/JotsPage/JotsViewModel.swift +++ b/Sources/JotsPage/JotsViewModel.swift @@ -42,13 +42,13 @@ final class JotsViewModel: PageViewModel { private var jotsTask: Task? - private weak var coordinator: JotsCoordinator? + private weak var coordinator: JotsCoordinatorProtocol? private let repository: JotsRepositoryProtocol private let menuConfigurationFactory: JotMenuConfigurationFactory init( - coordinator: JotsCoordinator, + coordinator: JotsCoordinatorProtocol, repository: JotsRepositoryProtocol, menuConfigurationFactory: JotMenuConfigurationFactory ) { diff --git a/Sources/Navigation/NavigationCoordinator.swift b/Sources/Navigation/NavigationCoordinator.swift index a0f218f..f6aaa93 100644 --- a/Sources/Navigation/NavigationCoordinator.swift +++ b/Sources/Navigation/NavigationCoordinator.swift @@ -19,7 +19,7 @@ import UIKit @MainActor -protocol NavigationCoordinator: Sendable { +protocol NavigationCoordinator: Sendable, AnyObject { /// Whether this coordinator is capable of navigating to the given ``URL``. func shouldHandle(url: URL) -> Bool diff --git a/Sources/SettingsPage/SettingsCoordinator.swift b/Sources/SettingsPage/SettingsCoordinator.swift index 6e2d42d..8504542 100644 --- a/Sources/SettingsPage/SettingsCoordinator.swift +++ b/Sources/SettingsPage/SettingsCoordinator.swift @@ -18,7 +18,13 @@ import UIKit -final class SettingsCoordinator: Coordinator { +protocol SettingsCoordinatorProtocol: Coordinator { + + func openExternalLink(url: URL) + func dismiss() +} + +final class SettingsCoordinator: Coordinator, SettingsCoordinatorProtocol { var onEnd: (() -> Void)? diff --git a/Sources/SettingsPage/SettingsViewControllerFactory.swift b/Sources/SettingsPage/SettingsViewControllerFactory.swift index 1dc5bfb..d74d7ef 100644 --- a/Sources/SettingsPage/SettingsViewControllerFactory.swift +++ b/Sources/SettingsPage/SettingsViewControllerFactory.swift @@ -21,7 +21,7 @@ import UIKit @MainActor protocol SettingsViewControllerFactoryProtocol: Sendable { - func make(coordinator: SettingsCoordinator) -> UIViewController + func make(coordinator: SettingsCoordinatorProtocol) -> UIViewController } struct SettingsViewControllerFactory: SettingsViewControllerFactoryProtocol { @@ -30,7 +30,7 @@ struct SettingsViewControllerFactory: SettingsViewControllerFactoryProtocol { let textBarButtonItemFactory: TextBarButtonItemFactory let symbolBarButtonItemFactory: SymbolBarButtonItemFactory - func make(coordinator: SettingsCoordinator) -> UIViewController { + func make(coordinator: SettingsCoordinatorProtocol) -> UIViewController { let viewController = PageViewController( viewModel: SettingsViewModel( repository: repository, diff --git a/Sources/SettingsPage/SettingsViewModel.swift b/Sources/SettingsPage/SettingsViewModel.swift index 10fc1ad..77fd30f 100644 --- a/Sources/SettingsPage/SettingsViewModel.swift +++ b/Sources/SettingsPage/SettingsViewModel.swift @@ -37,13 +37,13 @@ final class SettingsViewModel: PageViewModel { private let itemsContinuation: AsyncStream<[PageCellItem]>.Continuation private let repository: SettingsRepositoryProtocol - private weak var coordinator: SettingsCoordinator? + private weak var coordinator: SettingsCoordinatorProtocol? private var loadingTask: Task? init( repository: SettingsRepositoryProtocol, - coordinator: SettingsCoordinator + coordinator: SettingsCoordinatorProtocol ) { self.repository = repository self.coordinator = coordinator diff --git a/Tests/CloudMigrationPage/CloudImageCellViewModelTests.swift b/Tests/CloudMigrationPage/CloudImageCellViewModelTests.swift new file mode 100644 index 0000000..2272232 --- /dev/null +++ b/Tests/CloudMigrationPage/CloudImageCellViewModelTests.swift @@ -0,0 +1,36 @@ +/* + Jottre: Minimalistic jotting for iPhone, iPad and Mac. + Copyright (C) 2021-2026 Anton Lorani + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +import XCTest + +@testable import Jottre + +@MainActor +final class CloudImageCellViewModelTests: XCTestCase { + + func test_handleAction_givenTap_doesNothing() { + // Given + let viewModel = CloudImageCellViewModel() + + // When + viewModel.handle(action: .tap) + + // Then + XCTAssertNotNil(viewModel) + } +} diff --git a/Tests/CloudMigrationPage/CloudMigrationCoordinatorTests.swift b/Tests/CloudMigrationPage/CloudMigrationCoordinatorTests.swift new file mode 100644 index 0000000..24dff31 --- /dev/null +++ b/Tests/CloudMigrationPage/CloudMigrationCoordinatorTests.swift @@ -0,0 +1,143 @@ +/* + Jottre: Minimalistic jotting for iPhone, iPad and Mac. + Copyright (C) 2021-2026 Anton Lorani + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +import UIKit +import XCTest + +@testable import Jottre + +@MainActor +final class CloudMigrationCoordinatorTests: XCTestCase { + + func test_shouldStart_givenRepositoryReturnsTrue_returnsTrue() { + // Given + let coordinator = CloudMigrationCoordinator( + repository: CloudMigrationRepositoryMock(getShouldShowCloudMigrationProvider: { true }), + navigation: Navigation.test(), + cloudMigrationViewControllerFactory: CloudMigrationViewControllerFactoryMock() + ) + + // Then + XCTAssertTrue(coordinator.shouldStart()) + } + + func test_shouldStart_givenRepositoryReturnsFalse_returnsFalse() { + // Given + let coordinator = CloudMigrationCoordinator( + repository: CloudMigrationRepositoryMock(getShouldShowCloudMigrationProvider: { false }), + navigation: Navigation.test(), + cloudMigrationViewControllerFactory: CloudMigrationViewControllerFactoryMock() + ) + + // Then + XCTAssertFalse(coordinator.shouldStart()) + } + + func test_start_givenInvoked_presentsNavigationControllerWithFactoryProducedRoot() { + // Given + let presentExpectation = XCTestExpectation(description: "Navigation.present is called.") + let madeViewController = UIViewController() + let navigation = Navigation.test( + presentViewControllerProvider: { viewController, _ in + MainActor.assumeIsolated { + let navigationController = viewController as? UINavigationController + XCTAssertEqual(navigationController?.viewControllers.first, madeViewController) + presentExpectation.fulfill() + } + } + ) + let coordinator = CloudMigrationCoordinator( + repository: CloudMigrationRepositoryMock(), + navigation: navigation, + cloudMigrationViewControllerFactory: CloudMigrationViewControllerFactoryMock( + makeProvider: { _ in madeViewController } + ) + ) + + // When + coordinator.start() + + // Then + wait(for: [presentExpectation], timeout: 1) + } + + func test_dismiss_givenInvoked_invokesNavigationDismiss() { + // Given + let dismissExpectation = XCTestExpectation(description: "Navigation.dismiss is called.") + let navigation = Navigation.test( + dismissViewControllerProvider: { _, _ in dismissExpectation.fulfill() } + ) + let coordinator = CloudMigrationCoordinator( + repository: CloudMigrationRepositoryMock(), + navigation: navigation, + cloudMigrationViewControllerFactory: CloudMigrationViewControllerFactoryMock() + ) + + // When + coordinator.dismiss() + + // Then + wait(for: [dismissExpectation], timeout: 1) + } + + func test_dismiss_givenCompletion_invokesOnEnd() async { + // Given + let onEndExpectation = XCTestExpectation(description: "CloudMigrationCoordinator.onEnd is called.") + let navigation = Navigation.test( + dismissViewControllerProvider: { _, completion in + completion?() + } + ) + let coordinator = CloudMigrationCoordinator( + repository: CloudMigrationRepositoryMock(), + navigation: navigation, + cloudMigrationViewControllerFactory: CloudMigrationViewControllerFactoryMock() + ) + coordinator.onEnd = { onEndExpectation.fulfill() } + + // When + coordinator.dismiss() + + // Then + await fulfillment(of: [onEndExpectation], timeout: 1) + } + + func test_showInfoAlert_givenInvoked_presentsAlertController() { + // Given + let presentExpectation = XCTestExpectation(description: "Navigation.present is called.") + let navigation = Navigation.test( + presentViewControllerProvider: { viewController, _ in + MainActor.assumeIsolated { + XCTAssertTrue(viewController is UIAlertController) + presentExpectation.fulfill() + } + } + ) + let coordinator = CloudMigrationCoordinator( + repository: CloudMigrationRepositoryMock(), + navigation: navigation, + cloudMigrationViewControllerFactory: CloudMigrationViewControllerFactoryMock() + ) + + // When + coordinator.showInfoAlert(title: "title", message: "message") + + // Then + wait(for: [presentExpectation], timeout: 1) + } +} diff --git a/Tests/CloudMigrationPage/CloudMigrationJotBusinessModelTests.swift b/Tests/CloudMigrationPage/CloudMigrationJotBusinessModelTests.swift new file mode 100644 index 0000000..0ba6da8 --- /dev/null +++ b/Tests/CloudMigrationPage/CloudMigrationJotBusinessModelTests.swift @@ -0,0 +1,99 @@ +/* + Jottre: Minimalistic jotting for iPhone, iPad and Mac. + Copyright (C) 2021-2026 Anton Lorani + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +import XCTest + +@testable import Jottre + +final class CloudMigrationJotBusinessModelTests: XCTestCase { + + func test_init_givenLocalInfo_isUbiquitousFalseAndIsDownloadedTrue() { + // Given + let info = JotFile.Info( + url: URL(staticString: "file:///tmp/note.jot"), + name: "note", + modificationDate: nil, + ubiquitousInfo: nil + ) + + // When + let model = CloudMigrationJotBusinessModel(jotFileInfo: info) + + // Then + XCTAssertEqual(model.name, "note") + XCTAssertFalse(model.isUbiquitous) + XCTAssertTrue(model.isDownloaded) + XCTAssertFalse(model.isDownloading) + XCTAssertEqual(model.lastModifiedText, "") + } + + func test_init_givenUbiquitousInfoNotDownloaded_isUbiquitousTrueAndIsDownloadedFalse() { + // Given + let info = JotFile.Info( + url: URL(staticString: "file:///cloud/note.jot"), + name: "note", + modificationDate: nil, + ubiquitousInfo: UbiquitousInfo(downloadStatus: .notDownloaded, isDownloading: true) + ) + + // When + let model = CloudMigrationJotBusinessModel(jotFileInfo: info) + + // Then + XCTAssertTrue(model.isUbiquitous) + XCTAssertFalse(model.isDownloaded) + XCTAssertTrue(model.isDownloading) + } + + func test_init_givenModificationDate_formatsLastModifiedText() { + // Given + let date = Date(timeIntervalSince1970: 0) + let info = JotFile.Info( + url: URL(staticString: "file:///tmp/note.jot"), + name: "note", + modificationDate: date, + ubiquitousInfo: nil + ) + + // When + let model = CloudMigrationJotBusinessModel(jotFileInfo: info) + + // Then + XCTAssertFalse(model.lastModifiedText.isEmpty) + XCTAssertEqual( + model.lastModifiedText, + DateFormatter.localizedString(from: date, dateStyle: .long, timeStyle: .short) + ) + } + + func test_toJotFileInfo_returnsOriginalInfo() { + // Given + let info = JotFile.Info( + url: URL(staticString: "file:///tmp/note.jot"), + name: "note", + modificationDate: nil, + ubiquitousInfo: nil + ) + + // When + let model = CloudMigrationJotBusinessModel(jotFileInfo: info) + + // Then + XCTAssertEqual(model.toJotFileInfo(), info) + } +} diff --git a/Tests/CloudMigrationPage/CloudMigrationJotCellViewModelTests.swift b/Tests/CloudMigrationPage/CloudMigrationJotCellViewModelTests.swift new file mode 100644 index 0000000..b0e2c9a --- /dev/null +++ b/Tests/CloudMigrationPage/CloudMigrationJotCellViewModelTests.swift @@ -0,0 +1,136 @@ +/* + Jottre: Minimalistic jotting for iPhone, iPad and Mac. + Copyright (C) 2021-2026 Anton Lorani + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +import UIKit +import XCTest + +@testable import Jottre + +@MainActor +final class CloudMigrationJotCellViewModelTests: XCTestCase { + + func test_init_storesNameInfoTextAndCloudFlagsFromBusinessModel() { + // Given + let date = Date(timeIntervalSince1970: 1_700_000_000) + let jotFileInfo = JotFile.Info( + url: URL(staticString: "file:///cloud/note.jot"), + name: "note", + modificationDate: date, + ubiquitousInfo: UbiquitousInfo(downloadStatus: .current, isDownloading: false) + ) + let businessModel = CloudMigrationJotBusinessModel(jotFileInfo: jotFileInfo) + let expectedInfoText = DateFormatter.localizedString( + from: date, + dateStyle: .long, + timeStyle: .short + ) + + // When + let viewModel = CloudMigrationJotCellViewModel( + cloudMigrationJot: businessModel, + repository: CloudMigrationRepositoryMock(), + onTap: {} + ) + + // Then + XCTAssertEqual(viewModel.name, "note") + XCTAssertEqual(viewModel.infoText, expectedInfoText) + XCTAssertTrue(viewModel.isCloudCheckboxOn) + XCTAssertFalse(viewModel.isDownloading) + } + + func test_init_givenLocalJotFile_setsCloudCheckboxOff() { + // Given + let jotFileInfo = JotFile.Info( + url: URL(staticString: "file:///tmp/note.jot"), + name: "note", + modificationDate: nil, + ubiquitousInfo: nil + ) + + // When + let viewModel = CloudMigrationJotCellViewModel( + cloudMigrationJot: CloudMigrationJotBusinessModel(jotFileInfo: jotFileInfo), + repository: CloudMigrationRepositoryMock(), + onTap: {} + ) + + // Then + XCTAssertFalse(viewModel.isCloudCheckboxOn) + XCTAssertEqual(viewModel.infoText, "") + } + + func test_handleAction_givenTap_invokesOnTap() async { + // Given + let onTapExpectation = XCTestExpectation(description: "onTap is called.") + let jotFileInfo = JotFile.Info( + url: URL(staticString: "file:///tmp/note.jot"), + name: "note", + modificationDate: nil, + ubiquitousInfo: nil + ) + let viewModel = CloudMigrationJotCellViewModel( + cloudMigrationJot: CloudMigrationJotBusinessModel(jotFileInfo: jotFileInfo), + repository: CloudMigrationRepositoryMock(), + onTap: { onTapExpectation.fulfill() } + ) + + // When + viewModel.handle(action: .tap) + + // Then + await fulfillment(of: [onTapExpectation], timeout: 0.2) + } + + func test_getPreviewImage_forwardsJotFileInfoToRepository() async throws { + // Given + let getPreviewImageExpectation = XCTestExpectation( + description: "CloudMigrationRepositoryMock.getPreviewImageProvider is called." + ) + let expectedImage = UIImage() + let expectedFileURL = URL(staticString: "file:///tmp/note.jot") + let jotFileInfo = JotFile.Info( + url: expectedFileURL, + name: "note", + modificationDate: nil, + ubiquitousInfo: nil + ) + let repositoryMock = CloudMigrationRepositoryMock( + getPreviewImageProvider: { receivedInfo, receivedStyle, receivedScale in + // Then + XCTAssertEqual(receivedInfo.url, expectedFileURL) + XCTAssertEqual(receivedStyle, .light) + XCTAssertEqual(receivedScale, 2.0) + getPreviewImageExpectation.fulfill() + return expectedImage + } + ) + let viewModel = CloudMigrationJotCellViewModel( + cloudMigrationJot: CloudMigrationJotBusinessModel(jotFileInfo: jotFileInfo), + repository: repositoryMock, + onTap: {} + ) + + // When + let image = await viewModel.getPreviewImage(userInterfaceStyle: .light, displayScale: 2.0) + + // Then + XCTAssertIdentical(image, expectedImage) + await fulfillment(of: [getPreviewImageExpectation], timeout: 0.2) + } +} diff --git a/Tests/CloudMigrationPage/CloudMigrationRepositoryTests.swift b/Tests/CloudMigrationPage/CloudMigrationRepositoryTests.swift new file mode 100644 index 0000000..b7f2f5c --- /dev/null +++ b/Tests/CloudMigrationPage/CloudMigrationRepositoryTests.swift @@ -0,0 +1,185 @@ +/* + Jottre: Minimalistic jotting for iPhone, iPad and Mac. + Copyright (C) 2021-2026 Anton Lorani + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +import XCTest + +@testable import Jottre + +final class CloudMigrationRepositoryTests: XCTestCase { + + private static let localInfo = JotFile.Info( + url: URL(staticString: "file:///tmp/local.jot"), + name: "local", + modificationDate: Date(timeIntervalSince1970: 1_000), + ubiquitousInfo: nil + ) + private static let ubiquitousNewer = JotFile.Info( + url: URL(staticString: "file:///cloud/newer.jot"), + name: "newer", + modificationDate: Date(timeIntervalSince1970: 3_000), + ubiquitousInfo: UbiquitousInfo(downloadStatus: .current, isDownloading: false) + ) + private static let ubiquitousOlder = JotFile.Info( + url: URL(staticString: "file:///cloud/older.jot"), + name: "older", + modificationDate: Date(timeIntervalSince1970: 2_000), + ubiquitousInfo: UbiquitousInfo(downloadStatus: .current, isDownloading: false) + ) + + func test_getJotFiles_yieldsLocalsBeforeUbiquitousAndUbiquitousSortedNewestFirst() async throws { + // Given + let jotFileServiceMock = JotFileServiceMock( + documentsDirectoryContentsProvider: { + AsyncThrowingStream { continuation in + continuation.yield([Self.ubiquitousNewer, Self.localInfo, Self.ubiquitousOlder]) + continuation.finish() + } + } + ) + let repository = CloudMigrationRepository( + ubiquitousFileService: FileServiceMock(), + jotFileService: jotFileServiceMock, + jotFilePreviewImageService: JotFilePreviewImageServiceMock(), + defaultsService: DefaultsServiceMock() + ) + + // When + var iterator = repository.getJotFiles().makeAsyncIterator() + let first = try await XCTUnwrapAsync(try await iterator.next()) + + // Then + XCTAssertEqual(first.map(\.name), ["local", "newer", "older"]) + } + + func test_moveJotFile_forwardsToJotFileService() async throws { + // Given + let moveProviderExpectation = XCTestExpectation(description: "moveProvider is called.") + let jotFileServiceMock = JotFileServiceMock( + moveProvider: { receivedInfo, receivedShouldBecomeUbiquitous in + // Then + XCTAssertEqual(receivedInfo, Self.localInfo) + XCTAssertTrue(receivedShouldBecomeUbiquitous) + moveProviderExpectation.fulfill() + } + ) + let repository = CloudMigrationRepository( + ubiquitousFileService: FileServiceMock(), + jotFileService: jotFileServiceMock, + jotFilePreviewImageService: JotFilePreviewImageServiceMock(), + defaultsService: DefaultsServiceMock() + ) + + // When + try await repository.moveJotFile(jotFileInfo: Self.localInfo, shouldBecomeUbiquitous: true) + + // Then + await fulfillment(of: [moveProviderExpectation], timeout: 0.2) + } + + func test_getShouldShowCloudMigration_givenAlreadyDone_returnsFalse() { + // Given + let defaultsServiceMock = DefaultsServiceMock( + initialValues: [DefaultsKey.hasDoneCloudMigration.description: true] + ) + let repository = CloudMigrationRepository( + ubiquitousFileService: FileServiceMock(isEnabledProvider: { false }), + jotFileService: JotFileServiceMock(), + jotFilePreviewImageService: JotFilePreviewImageServiceMock(), + defaultsService: defaultsServiceMock + ) + + // Then + XCTAssertFalse(repository.getShouldShowCloudMigration()) + } + + func test_getShouldShowCloudMigration_givenStoredFlagDiffersFromCurrentUbiquitousState_returnsTrue() { + // Given + let defaultsServiceMock = DefaultsServiceMock( + initialValues: [DefaultsKey.isICloudEnabled.description: false] + ) + let repository = CloudMigrationRepository( + ubiquitousFileService: FileServiceMock(isEnabledProvider: { true }), + jotFileService: JotFileServiceMock(), + jotFilePreviewImageService: JotFilePreviewImageServiceMock(), + defaultsService: defaultsServiceMock + ) + + // Then + XCTAssertTrue(repository.getShouldShowCloudMigration()) + } + + func test_getShouldShowCloudMigration_givenStoredFlagMatchesCurrentUbiquitousState_returnsFalse() { + // Given + let defaultsServiceMock = DefaultsServiceMock( + initialValues: [DefaultsKey.isICloudEnabled.description: true] + ) + let repository = CloudMigrationRepository( + ubiquitousFileService: FileServiceMock(isEnabledProvider: { true }), + jotFileService: JotFileServiceMock(), + jotFilePreviewImageService: JotFilePreviewImageServiceMock(), + defaultsService: defaultsServiceMock + ) + + // Then + XCTAssertFalse(repository.getShouldShowCloudMigration()) + } + + func test_getShouldShowCloudMigration_givenNoStoredFlagAndUbiquitousDisabled_persistsFalseAndReturnsFalse() { + // Given + let defaultsServiceMock = DefaultsServiceMock() + let repository = CloudMigrationRepository( + ubiquitousFileService: FileServiceMock(isEnabledProvider: { false }), + jotFileService: JotFileServiceMock(), + jotFilePreviewImageService: JotFilePreviewImageServiceMock(), + defaultsService: defaultsServiceMock + ) + + // When + let result = repository.getShouldShowCloudMigration() + + // Then + XCTAssertFalse(result) + XCTAssertEqual(defaultsServiceMock.getValue(.isICloudEnabled), false) + } + + func test_markCloudMigrationPageDone_setsHasDoneCloudMigrationToTrue() { + // Given + let defaultsServiceMock = DefaultsServiceMock() + let repository = CloudMigrationRepository( + ubiquitousFileService: FileServiceMock(), + jotFileService: JotFileServiceMock(), + jotFilePreviewImageService: JotFilePreviewImageServiceMock(), + defaultsService: defaultsServiceMock + ) + + // When + repository.markCloudMigrationPageDone() + + // Then + XCTAssertEqual(defaultsServiceMock.getValue(.hasDoneCloudMigration), true) + } +} + +private func XCTUnwrapAsync( + _ expression: @autoclosure () async throws -> T?, + file: StaticString = #filePath, + line: UInt = #line +) async throws -> T { + let value = try await expression() + return try XCTUnwrap(value, file: file, line: line) +} diff --git a/Tests/CloudMigrationPage/CloudMigrationViewModelTests.swift b/Tests/CloudMigrationPage/CloudMigrationViewModelTests.swift new file mode 100644 index 0000000..9b76964 --- /dev/null +++ b/Tests/CloudMigrationPage/CloudMigrationViewModelTests.swift @@ -0,0 +1,105 @@ +/* + Jottre: Minimalistic jotting for iPhone, iPad and Mac. + Copyright (C) 2021-2026 Anton Lorani + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +import XCTest + +@testable import Jottre + +@MainActor +final class CloudMigrationViewModelTests: XCTestCase { + + func test_didLoad_givenEmptyJotFiles_yieldsCloudImageAndHeader() async throws { + // Given + let stream = AsyncThrowingStream<[CloudMigrationJotBusinessModel], Error> { continuation in + continuation.yield([]) + continuation.finish() + } + let viewModel = CloudMigrationViewModel( + repository: CloudMigrationRepositoryMock(getJotFilesProvider: { stream }), + coordinator: CloudMigrationCoordinatorMock() + ) + + // When + viewModel.didLoad() + let items = try await firstValue(of: viewModel.items) + + // Then + XCTAssertEqual(items.count, 2) + } + + func test_didLoad_givenOneJot_yieldsHeaderAndJotItem() async throws { + // Given + let businessModel = CloudMigrationJotBusinessModel( + jotFileInfo: JotFile.Info( + url: URL(staticString: "file:///tmp/foo.jot"), + name: "foo", + modificationDate: Date(timeIntervalSince1970: 0), + ubiquitousInfo: nil + ) + ) + let stream = AsyncThrowingStream<[CloudMigrationJotBusinessModel], Error> { continuation in + continuation.yield([businessModel]) + continuation.finish() + } + let viewModel = CloudMigrationViewModel( + repository: CloudMigrationRepositoryMock(getJotFilesProvider: { stream }), + coordinator: CloudMigrationCoordinatorMock() + ) + + // When + viewModel.didLoad() + let items = try await firstValue(of: viewModel.items) + + // Then + XCTAssertEqual(items.count, 2) + } + + func test_actions_givenDoneTap_marksDoneAndDismissesViaCoordinator() async { + // Given + let markDoneExpectation = XCTestExpectation(description: "Repository.markCloudMigrationPageDone is called.") + let dismissExpectation = + XCTestExpectation(description: "CloudMigrationCoordinatorMock.dismiss is called.") + let coordinator = CloudMigrationCoordinatorMock( + dismissProvider: { dismissExpectation.fulfill() } + ) + let viewModel = CloudMigrationViewModel( + repository: CloudMigrationRepositoryMock( + markCloudMigrationPageDoneProvider: { markDoneExpectation.fulfill() } + ), + coordinator: coordinator + ) + + // When + XCTAssertEqual(viewModel.actions.count, 1) + viewModel.actions[0].action() + + // Then + await fulfillment(of: [markDoneExpectation, dismissExpectation], timeout: 1) + } +} + +@MainActor +private func firstValue( + of sequence: S +) async throws -> S.Element where S.Element: Sendable { + var iterator = sequence.makeAsyncIterator() + guard let value = try await iterator.next() else { + throw NSError(domain: "CloudMigrationViewModelTests", code: 0) + } + return value +} diff --git a/Tests/Defaults/DefaultsContinuationStorageTests.swift b/Tests/Defaults/DefaultsContinuationStorageTests.swift new file mode 100644 index 0000000..55a839e --- /dev/null +++ b/Tests/Defaults/DefaultsContinuationStorageTests.swift @@ -0,0 +1,93 @@ +/* + Jottre: Minimalistic jotting for iPhone, iPad and Mac. + Copyright (C) 2021-2026 Anton Lorani + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +import XCTest + +@testable import Jottre + +final class DefaultsContinuationStorageTests: XCTestCase { + + func test_continuations_givenNoRegistrations_returnsNil() { + // Given + let storage = DefaultsContinuationStorage() + let key = DefaultsKey("none") + + // When + let result = storage.continuations(defaultsKey: key) + + // Then + XCTAssertNil(result) + } + + func test_add_givenContinuationForKey_continuationsReturnsIt() throws { + // Given + let storage = DefaultsContinuationStorage() + let key = DefaultsKey("k") + let stream = AsyncStream { continuation in + storage.add(continuation, defaultsKey: key) + } + let iterator = stream.makeAsyncIterator() + _ = iterator + + // When + let result = try XCTUnwrap(storage.continuations(defaultsKey: key)) + + // Then + XCTAssertEqual(result.count, 1) + } + + func test_addMultiple_givenSameKey_continuationsReturnsAll() throws { + // Given + let storage = DefaultsContinuationStorage() + let key = DefaultsKey("k") + let stream1 = AsyncStream { continuation in + storage.add(continuation, defaultsKey: key) + } + let stream2 = AsyncStream { continuation in + storage.add(continuation, defaultsKey: key) + } + let iterator1 = stream1.makeAsyncIterator() + let iterator2 = stream2.makeAsyncIterator() + _ = iterator1 + _ = iterator2 + + // When + let result = try XCTUnwrap(storage.continuations(defaultsKey: key)) + + // Then + XCTAssertEqual(result.count, 2) + } + + func test_continuations_givenDifferentKey_returnsNil() { + // Given + let storage = DefaultsContinuationStorage() + let registeredKey = DefaultsKey("k1") + let otherKey = DefaultsKey("k2") + let stream = AsyncStream { continuation in + storage.add(continuation, defaultsKey: registeredKey) + } + let iterator = stream.makeAsyncIterator() + _ = iterator + + // When + let result = storage.continuations(defaultsKey: otherKey) + + // Then + XCTAssertNil(result) + } +} diff --git a/Tests/Defaults/DefaultsKeyTests.swift b/Tests/Defaults/DefaultsKeyTests.swift new file mode 100644 index 0000000..ff95077 --- /dev/null +++ b/Tests/Defaults/DefaultsKeyTests.swift @@ -0,0 +1,64 @@ +/* + Jottre: Minimalistic jotting for iPhone, iPad and Mac. + Copyright (C) 2021-2026 Anton Lorani + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +import XCTest + +@testable import Jottre + +final class DefaultsKeyTests: XCTestCase { + + func test_init_givenString_setsDescription() { + // When + let key = DefaultsKey("someKey") + + // Then + XCTAssertEqual(key.description, "someKey") + } + + func test_initStringLiteral_setsDescription() { + // When + let key: DefaultsKey = "literalKey" + + // Then + XCTAssertEqual(key.description, "literalKey") + } + + func test_isICloudEnabled_returnsExpectedDescription() { + // When + let key = DefaultsKey.isICloudEnabled + + // Then + XCTAssertEqual(key.description, "isICloudEnabled") + } + + func test_userInterfaceStyle_returnsExpectedDescription() { + // When + let key = DefaultsKey.userInterfaceStyle + + // Then + XCTAssertEqual(key.description, "userInterfaceStyle") + } + + func test_hasDoneCloudMigration_returnsExpectedDescription() { + // When + let key = DefaultsKey.hasDoneCloudMigration + + // Then + XCTAssertEqual(key.description, "hasDoneCloudMigration") + } +} diff --git a/Tests/Defaults/DefaultsServiceTests.swift b/Tests/Defaults/DefaultsServiceTests.swift new file mode 100644 index 0000000..3a8f6d1 --- /dev/null +++ b/Tests/Defaults/DefaultsServiceTests.swift @@ -0,0 +1,116 @@ +/* + Jottre: Minimalistic jotting for iPhone, iPad and Mac. + Copyright (C) 2021-2026 Anton Lorani + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +import XCTest + +@testable import Jottre + +final class DefaultsServiceTests: XCTestCase { + + private var userDefaults: UserDefaults! + private var suiteName: String! + + override func setUp() { + super.setUp() + suiteName = "DefaultsServiceTests-\(UUID().uuidString)" + userDefaults = try! XCTUnwrap(UserDefaults(suiteName: suiteName)) + } + + override func tearDown() { + userDefaults.removePersistentDomain(forName: suiteName) + userDefaults = nil + suiteName = nil + super.tearDown() + } + + func test_getValue_givenNoStoredValue_returnsNil() { + // Given + let service = DefaultsService(userDefaults: userDefaults) + + // When + let value: Bool? = service.getValue(DefaultsKey("missing")) + + // Then + XCTAssertNil(value) + } + + func test_set_givenBoolValue_persistsAsRoundTrippableString() throws { + // Given + let service = DefaultsService(userDefaults: userDefaults) + let key = DefaultsKey("flag") + + // When + service.set(key, value: true) + + // Then + let stored = try XCTUnwrap(service.getValue(key)) + XCTAssertTrue(stored) + } + + func test_set_givenNilValue_clearsValue() { + // Given + let service = DefaultsService(userDefaults: userDefaults) + let key = DefaultsKey("count") + service.set(key, value: 42) + + // When + service.set(key, value: nil) + + // Then + XCTAssertNil(service.getValue(key)) + } + + func test_getValueStream_yieldsCurrentValueImmediately() async throws { + // Given + let service = DefaultsService(userDefaults: userDefaults) + let key = DefaultsKey("preset") + service.set(key, value: 7) + + // When + var iterator = service.getValueStream(key).makeAsyncIterator() + let first = try await XCTUnwrapAsync(await iterator.next()) + + // Then + XCTAssertEqual(first, 7) + } + + func test_getValueStream_yieldsValueOnSubsequentSet() async throws { + // Given + let service = DefaultsService(userDefaults: userDefaults) + let key = DefaultsKey("counter") + + var iterator = service.getValueStream(key).makeAsyncIterator() + _ = await iterator.next() + + // When + service.set(key, value: 99) + let next = try await XCTUnwrapAsync(await iterator.next()) + + // Then + XCTAssertEqual(next, 99) + } +} + +private func XCTUnwrapAsync( + _ expression: @autoclosure () async throws -> T?, + file: StaticString = #filePath, + line: UInt = #line +) async throws -> T { + let value = try await expression() + return try XCTUnwrap(value, file: file, line: line) +} diff --git a/Tests/EditJotPage/EditJotCoordinatorTests.swift b/Tests/EditJotPage/EditJotCoordinatorTests.swift new file mode 100644 index 0000000..f4a4361 --- /dev/null +++ b/Tests/EditJotPage/EditJotCoordinatorTests.swift @@ -0,0 +1,311 @@ +/* + Jottre: Minimalistic jotting for iPhone, iPad and Mac. + Copyright (C) 2021-2026 Anton Lorani + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +import UIKit +import XCTest + +@testable import Jottre + +@MainActor +final class EditJotCoordinatorTests: XCTestCase { + + func test_shouldHandle_givenEditJotURL_returnsTrue() { + // Given + let coordinator = makeCoordinator() + let url = EditJotURL( + jotFileInfo: JotFile.Info( + url: URL(staticString: "file:///tmp/note.jot"), + name: "note", + modificationDate: nil, + ubiquitousInfo: nil + ) + ).toURL() + + // Then + XCTAssertTrue(coordinator.shouldHandle(url: url)) + } + + func test_shouldHandle_givenNonEditJotURL_returnsFalse() { + // Given + let coordinator = makeCoordinator() + + // Then + XCTAssertFalse(coordinator.shouldHandle(url: URL(staticString: "https://example.com"))) + } + + func test_handle_givenValidURL_returnsViewControllerFromFactory() { + // Given + let madeViewController = UIViewController() + let coordinator = makeCoordinator( + editJotViewControllerFactory: EditJotViewControllerFactoryMock( + makeProvider: { _, _ in madeViewController } + ) + ) + let url = EditJotURL( + jotFileInfo: JotFile.Info( + url: URL(staticString: "file:///tmp/note.jot"), + name: "note", + modificationDate: nil, + ubiquitousInfo: nil + ) + ).toURL() + + // When + let viewControllers = coordinator.handle(url: url) + + // Then + XCTAssertEqual(viewControllers, [madeViewController]) + } + + func test_handle_givenUnparsableURL_returnsEmpty() { + // Given + let coordinator = makeCoordinator() + + // When + let viewControllers = coordinator.handle(url: URL(staticString: "https://example.com")) + + // Then + XCTAssertTrue(viewControllers.isEmpty) + } + + func test_openJot_givenInvoked_invokesNavigationOpenWithEditJotURL() { + // Given + let openExpectation = XCTestExpectation(description: "Navigation.open is called.") + let info = JotFile.Info( + url: URL(staticString: "file:///tmp/note.jot"), + name: "note", + modificationDate: nil, + ubiquitousInfo: nil + ) + let navigation = Navigation.test( + openURLProvider: { receivedURL in + XCTAssertEqual(receivedURL, EditJotURL(jotFileInfo: info).toURL()) + openExpectation.fulfill() + } + ) + let coordinator = makeCoordinator(navigation: navigation) + + // When + coordinator.openJot(jotFileInfo: info) + + // Then + wait(for: [openExpectation], timeout: 1) + } + + func test_canGoBack_givenMultipleViewControllers_returnsTrue() { + // Given + let navigation = Navigation.test( + getViewControllersProvider: { [UIViewController(), UIViewController()] } + ) + let coordinator = makeCoordinator(navigation: navigation) + + // Then + XCTAssertTrue(coordinator.canGoBack()) + } + + func test_canGoBack_givenSingleViewController_returnsFalse() { + // Given + let navigation = Navigation.test( + getViewControllersProvider: { [UIViewController()] } + ) + let coordinator = makeCoordinator(navigation: navigation) + + // Then + XCTAssertFalse(coordinator.canGoBack()) + } + + func test_goBack_givenInvoked_invokesNavigationPop() { + // Given + let popExpectation = XCTestExpectation(description: "Navigation.popViewController is called.") + let navigation = Navigation.test( + popViewControllerProvider: { animated in + XCTAssertTrue(animated) + popExpectation.fulfill() + } + ) + let coordinator = makeCoordinator(navigation: navigation) + + // When + coordinator.goBack() + + // Then + wait(for: [popExpectation], timeout: 1) + } + + func test_showShareJot_givenInvoked_startsShareCoordinator() { + // Given + let startExpectation = XCTestExpectation(description: "ShareJot Coordinator.start is called.") + let info = JotFile.Info( + url: URL(staticString: "file:///tmp/note.jot"), + name: "note", + modificationDate: nil, + ubiquitousInfo: nil + ) + let coordinator = makeCoordinator( + shareJotCoordinatorFactory: ShareJotCoordinatorFactoryMock( + makeProvider: { _, _, _, _ in + CoordinatorMock(startProvider: { startExpectation.fulfill() }) + } + ) + ) + + // When + coordinator.showShareJot(jotFileInfo: info, format: .pdf, configurePopoverAnchor: nil) + + // Then + wait(for: [startExpectation], timeout: 1) + } + + func test_openDeleteJot_givenInvoked_startsDeleteCoordinator() { + // Given + let startExpectation = XCTestExpectation(description: "DeleteJot Coordinator.start is called.") + let info = JotFile.Info( + url: URL(staticString: "file:///tmp/note.jot"), + name: "note", + modificationDate: nil, + ubiquitousInfo: nil + ) + let coordinator = makeCoordinator( + deleteJotCoordinatorFactory: DeleteJotCoordinatorFactoryMock( + makeProvider: { _, _ in + CoordinatorMock(startProvider: { startExpectation.fulfill() }) + } + ) + ) + + // When + coordinator.openDeleteJot(jotFileInfo: info) + + // Then + wait(for: [startExpectation], timeout: 1) + } + + func test_showRenameAlert_givenInvoked_startsRenameCoordinator() { + // Given + let startExpectation = XCTestExpectation(description: "RenameJot Coordinator.start is called.") + let info = JotFile.Info( + url: URL(staticString: "file:///tmp/note.jot"), + name: "note", + modificationDate: nil, + ubiquitousInfo: nil + ) + let coordinator = makeCoordinator( + renameJotCoordinatorFactory: RenameJotCoordinatorFactoryMock( + makeProvider: { _, _, _ in + CoordinatorMock(startProvider: { startExpectation.fulfill() }) + } + ) + ) + + // When + coordinator.showRenameAlert(jotFileInfo: info) + + // Then + wait(for: [startExpectation], timeout: 1) + } + + func test_showInFiles_givenInvoked_startsRevealFileCoordinator() { + // Given + let startExpectation = XCTestExpectation(description: "RevealFile Coordinator.start is called.") + let info = JotFile.Info( + url: URL(staticString: "file:///tmp/note.jot"), + name: "note", + modificationDate: nil, + ubiquitousInfo: nil + ) + let coordinator = makeCoordinator( + revealFileCoordinatorFactory: RevealFileCoordinatorFactoryMock( + makeProvider: { _, _ in + CoordinatorMock(startProvider: { startExpectation.fulfill() }) + } + ) + ) + + // When + coordinator.showInFiles(jotFileInfo: info) + + // Then + wait(for: [startExpectation], timeout: 1) + } + + func test_showJotConflictPage_givenInvoked_startsJotConflictCoordinator() { + // Given + let startExpectation = XCTestExpectation(description: "JotConflict Coordinator.start is called.") + let info = JotFile.Info( + url: URL(staticString: "file:///tmp/note.jot"), + name: "note", + modificationDate: nil, + ubiquitousInfo: nil + ) + let coordinator = makeCoordinator( + jotConflictCoordinatorFactory: JotConflictCoordinatorFactoryMock( + makeProvider: { _, _, _, _ in + CoordinatorMock(startProvider: { startExpectation.fulfill() }) + } + ) + ) + + // When + coordinator.showJotConflictPage(jotFileInfo: info, jotFileVersions: []) { _ in } + + // Then + wait(for: [startExpectation], timeout: 1) + } + + func test_showInfoAlert_givenInvoked_presentsAlertController() { + // Given + let presentExpectation = XCTestExpectation(description: "Navigation.present is called.") + let navigation = Navigation.test( + presentViewControllerProvider: { viewController, _ in + MainActor.assumeIsolated { + XCTAssertTrue(viewController is UIAlertController) + presentExpectation.fulfill() + } + } + ) + let coordinator = makeCoordinator(navigation: navigation) + + // When + coordinator.showInfoAlert(title: "title", message: "message") + + // Then + wait(for: [presentExpectation], timeout: 1) + } + + private func makeCoordinator( + navigation: Navigation = .test(), + repository: EditJotRepositoryProtocol = EditJotRepositoryMock(), + editJotViewControllerFactory: EditJotViewControllerFactoryProtocol = EditJotViewControllerFactoryMock(), + jotConflictCoordinatorFactory: JotConflictCoordinatorFactoryProtocol = JotConflictCoordinatorFactoryMock(), + renameJotCoordinatorFactory: RenameJotCoordinatorFactoryProtocol = RenameJotCoordinatorFactoryMock(), + deleteJotCoordinatorFactory: DeleteJotCoordinatorFactoryProtocol = DeleteJotCoordinatorFactoryMock(), + shareJotCoordinatorFactory: ShareJotCoordinatorFactoryProtocol = ShareJotCoordinatorFactoryMock(), + revealFileCoordinatorFactory: RevealFileCoordinatorFactoryProtocol = RevealFileCoordinatorFactoryMock() + ) -> EditJotCoordinator { + EditJotCoordinator( + navigation: navigation, + repository: repository, + editJotViewControllerFactory: editJotViewControllerFactory, + jotConflictCoordinatorFactory: jotConflictCoordinatorFactory, + renameJotCoordinatorFactory: renameJotCoordinatorFactory, + deleteJotCoordinatorFactory: deleteJotCoordinatorFactory, + shareJotCoordinatorFactory: shareJotCoordinatorFactory, + revealFileCoordinatorFactory: revealFileCoordinatorFactory + ) + } +} diff --git a/Tests/EditJotPage/EditJotRepositoryTests.swift b/Tests/EditJotPage/EditJotRepositoryTests.swift new file mode 100644 index 0000000..7700840 --- /dev/null +++ b/Tests/EditJotPage/EditJotRepositoryTests.swift @@ -0,0 +1,169 @@ +/* + Jottre: Minimalistic jotting for iPhone, iPad and Mac. + Copyright (C) 2021-2026 Anton Lorani + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +@preconcurrency import PencilKit +import XCTest + +@testable import Jottre + +final class EditJotRepositoryTests: XCTestCase { + + func test_ubiquitousInfo_forwardsToUbiquitousFileService() { + // Given + let expectedInfo = UbiquitousInfo(downloadStatus: .current, isDownloading: false) + let url = URL(staticString: "file:///cloud/note.jot") + let repository = EditJotRepository( + ubiquitousFileService: FileServiceMock( + ubiquitousInfoProvider: { receivedURL in + XCTAssertEqual(receivedURL, url) + return expectedInfo + } + ), + jotFileService: JotFileServiceMock(), + jotFileConflictService: JotFileConflictServiceMock() + ) + + // When + let result = repository.ubiquitousInfo(url: url) + + // Then + XCTAssertEqual(result, expectedInfo) + } + + func test_readDrawing_givenValidJotFile_returnsDrawingAndWidth() async throws { + // Given + let drawing = PKDrawing() + let drawingData = drawing.dataRepresentation() + let info = JotFile.Info( + url: URL(staticString: "file:///tmp/note.jot"), + name: "note", + modificationDate: nil, + ubiquitousInfo: nil + ) + let repository = EditJotRepository( + ubiquitousFileService: FileServiceMock(), + jotFileService: JotFileServiceMock( + readJotFileProvider: { _ in + JotFile( + info: info, + jot: Jot(version: 3, drawing: drawingData, width: 800) + ) + } + ), + jotFileConflictService: JotFileConflictServiceMock() + ) + + // When + let result = try await repository.readDrawing(jotFileInfo: info) + + // Then + XCTAssertEqual(result.width, 800) + XCTAssertEqual(result.drawing.strokes.count, drawing.strokes.count) + } + + func test_writeDrawing_writesEncodedDrawingViaJotFileService() async throws { + // Given + let writeProviderExpectation = XCTestExpectation(description: "JotFileServiceMock.writeProvider is called.") + let drawing = PKDrawing() + let info = JotFile.Info( + url: URL(staticString: "file:///tmp/note.jot"), + name: "note", + modificationDate: nil, + ubiquitousInfo: nil + ) + let repository = EditJotRepository( + ubiquitousFileService: FileServiceMock(), + jotFileService: JotFileServiceMock( + writeProvider: { jotFile in + // Then + XCTAssertEqual(jotFile.info, info) + XCTAssertEqual(jotFile.jot.drawing, drawing.dataRepresentation()) + writeProviderExpectation.fulfill() + } + ), + jotFileConflictService: JotFileConflictServiceMock() + ) + + // When + try await repository.writeDrawing(jotFileInfo: info, drawing: drawing) + + // Then + await fulfillment(of: [writeProviderExpectation], timeout: 0.2) + } + + func test_getConflictingVersions_forwardsToConflictService() { + // Given + let info = JotFile.Info( + url: URL(staticString: "file:///tmp/note.jot"), + name: "note", + modificationDate: nil, + ubiquitousInfo: nil + ) + let expected = [ + JotFileVersion(localizedNameOfSavingComputer: "Mac", info: info) + ] + let repository = EditJotRepository( + ubiquitousFileService: FileServiceMock(), + jotFileService: JotFileServiceMock(), + jotFileConflictService: JotFileConflictServiceMock( + getConfictingVersionsProvider: { receivedInfo in + XCTAssertEqual(receivedInfo, info) + return expected + } + ) + ) + + // When + let result = repository.getConflictingVersions(jotFileInfo: info) + + // Then + XCTAssertEqual(result, expected) + } + + func test_duplicate_forwardsToJotFileService() throws { + // Given + let original = JotFile.Info( + url: URL(staticString: "file:///tmp/note.jot"), + name: "note", + modificationDate: nil, + ubiquitousInfo: nil + ) + let duplicated = JotFile.Info( + url: URL(staticString: "file:///tmp/note-1.jot"), + name: "note-1", + modificationDate: nil, + ubiquitousInfo: nil + ) + let repository = EditJotRepository( + ubiquitousFileService: FileServiceMock(), + jotFileService: JotFileServiceMock( + duplicateProvider: { receivedInfo in + XCTAssertEqual(receivedInfo, original) + return duplicated + } + ), + jotFileConflictService: JotFileConflictServiceMock() + ) + + // When + let result = try repository.duplicate(jotFileInfo: original) + + // Then + XCTAssertEqual(result, duplicated) + } +} diff --git a/Tests/EditJotPage/EditJotViewModelTests.swift b/Tests/EditJotPage/EditJotViewModelTests.swift new file mode 100644 index 0000000..8a832c1 --- /dev/null +++ b/Tests/EditJotPage/EditJotViewModelTests.swift @@ -0,0 +1,275 @@ +/* + Jottre: Minimalistic jotting for iPhone, iPad and Mac. + Copyright (C) 2021-2026 Anton Lorani + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +@preconcurrency import PencilKit +import XCTest + +@testable import Jottre + +@MainActor +final class EditJotViewModelTests: XCTestCase { + + func test_didLoad_givenNoConflictingVersions_yieldsDrawingFromRepository() async throws { + // Given + let drawing = PKDrawing() + let info = JotFile.Info( + url: URL(staticString: "file:///tmp/note.jot"), + name: "note", + modificationDate: nil, + ubiquitousInfo: nil + ) + let viewModel = EditJotViewModel( + jotFileInfo: info, + repository: EditJotRepositoryMock( + readDrawingProvider: { _ in (drawing, 1024) } + ), + coordinator: EditJotCoordinatorMock(), + menuConfigurationFactory: JotMenuConfigurationFactory() + ) + + // When + viewModel.didLoad() + + // Then + var iterator = viewModel.drawing.makeAsyncIterator() + let nextValue = await iterator.next() + let received = try XCTUnwrap(nextValue) + XCTAssertEqual(received.width, 1024) + } + + func test_didLoad_givenConflictingVersions_invokesShowJotConflictPage() async { + // Given + let conflictExpectation = XCTestExpectation(description: "Coordinator.showJotConflictPage is called.") + let info = JotFile.Info( + url: URL(staticString: "file:///tmp/note.jot"), + name: "note", + modificationDate: nil, + ubiquitousInfo: nil + ) + let coordinator = EditJotCoordinatorMock( + showJotConflictPageProvider: { _, _, _ in conflictExpectation.fulfill() } + ) + let viewModel = EditJotViewModel( + jotFileInfo: info, + repository: EditJotRepositoryMock( + getConflictingVersionsProvider: { _ in + [JotFileVersion(localizedNameOfSavingComputer: nil, info: info)] + } + ), + coordinator: coordinator, + menuConfigurationFactory: JotMenuConfigurationFactory() + ) + + // When + viewModel.didLoad() + + // Then + await fulfillment(of: [conflictExpectation], timeout: 1) + } + + func test_didTapBackButton_givenNoConflicts_invokesGoBack() async { + // Given + let goBackExpectation = XCTestExpectation(description: "Coordinator.goBack is called.") + let info = JotFile.Info( + url: URL(staticString: "file:///tmp/note.jot"), + name: "note", + modificationDate: nil, + ubiquitousInfo: nil + ) + let coordinator = EditJotCoordinatorMock( + goBackProvider: { goBackExpectation.fulfill() } + ) + let viewModel = EditJotViewModel( + jotFileInfo: info, + repository: EditJotRepositoryMock(), + coordinator: coordinator, + menuConfigurationFactory: JotMenuConfigurationFactory() + ) + + // When + viewModel.didTapBackButton() + + // Then + await fulfillment(of: [goBackExpectation], timeout: 1) + } + + func test_didTapBackButton_givenConflicts_invokesShowJotConflictPage() async { + // Given + let conflictExpectation = XCTestExpectation(description: "Coordinator.showJotConflictPage is called.") + let info = JotFile.Info( + url: URL(staticString: "file:///tmp/note.jot"), + name: "note", + modificationDate: nil, + ubiquitousInfo: nil + ) + let coordinator = EditJotCoordinatorMock( + showJotConflictPageProvider: { _, _, _ in conflictExpectation.fulfill() } + ) + let viewModel = EditJotViewModel( + jotFileInfo: info, + repository: EditJotRepositoryMock( + getConflictingVersionsProvider: { _ in + [JotFileVersion(localizedNameOfSavingComputer: nil, info: info)] + } + ), + coordinator: coordinator, + menuConfigurationFactory: JotMenuConfigurationFactory() + ) + + // When + viewModel.didTapBackButton() + + // Then + await fulfillment(of: [conflictExpectation], timeout: 1) + } + + func test_showsBackButton_givenDidLoadAndCanGoBack_yieldsTrue() async throws { + // Given + let info = JotFile.Info( + url: URL(staticString: "file:///tmp/note.jot"), + name: "note", + modificationDate: nil, + ubiquitousInfo: nil + ) + let coordinator = EditJotCoordinatorMock( + canGoBackProvider: { true } + ) + let viewModel = EditJotViewModel( + jotFileInfo: info, + repository: EditJotRepositoryMock(), + coordinator: coordinator, + menuConfigurationFactory: JotMenuConfigurationFactory() + ) + + // When + viewModel.didLoad() + var iterator = viewModel.showsBackButton.makeAsyncIterator() + let nextValue = await iterator.next() + let value = try XCTUnwrap(nextValue) + + // Then + XCTAssertTrue(value) + } + + func test_didTapToggleEditingButton_givenFalse_yieldsTrue() async throws { + // Given + let info = JotFile.Info( + url: URL(staticString: "file:///tmp/note.jot"), + name: "note", + modificationDate: nil, + ubiquitousInfo: nil + ) + let viewModel = EditJotViewModel( + jotFileInfo: info, + repository: EditJotRepositoryMock(), + coordinator: EditJotCoordinatorMock(), + menuConfigurationFactory: JotMenuConfigurationFactory() + ) + + // When + viewModel.didTapToggleEditingButton(isEditing: false) + + // Then + var iterator = viewModel.isEditing.makeAsyncIterator() + let nextValue = await iterator.next() + let firstValue = try XCTUnwrap(nextValue) + let value = try XCTUnwrap(firstValue) + XCTAssertTrue(value) + } + + func test_menuConfigurations_givenDuplicateActionThrows_invokesShowInfoAlert() async { + // Given + let alertExpectation = XCTestExpectation(description: "Coordinator.showInfoAlert is called.") + let info = JotFile.Info( + url: URL(staticString: "file:///tmp/note.jot"), + name: "note", + modificationDate: nil, + ubiquitousInfo: nil + ) + let coordinator = EditJotCoordinatorMock( + showInfoAlertProvider: { _, _ in alertExpectation.fulfill() } + ) + let viewModel = EditJotViewModel( + jotFileInfo: info, + repository: EditJotRepositoryMock( + duplicateProvider: { _ in throw NSError(domain: "test", code: 0) } + ), + coordinator: coordinator, + menuConfigurationFactory: JotMenuConfigurationFactory() + ) + + // When (find duplicate action and invoke) + let configurations = viewModel.menuConfigurations.make(popoverAnchorProvider: { nil }) + let duplicateAction = configurations.compactMap { configuration -> JotMenuConfiguration.Action? in + if case let .action(action) = configuration, action.systemImageName == "plus.square.on.square" { + return action + } + return nil + }.first + try? XCTUnwrap(duplicateAction).handler() + await Task.yield() + + // Then + await fulfillment(of: [alertExpectation], timeout: 1) + } + + func test_menuConfigurations_givenDuplicateActionSucceeds_invokesOpenJot() async { + // Given + let openJotExpectation = XCTestExpectation(description: "Coordinator.openJot is called.") + let info = JotFile.Info( + url: URL(staticString: "file:///tmp/note.jot"), + name: "note", + modificationDate: nil, + ubiquitousInfo: nil + ) + let duplicatedInfo = JotFile.Info( + url: URL(staticString: "file:///tmp/note 1.jot"), + name: "note 1", + modificationDate: nil, + ubiquitousInfo: nil + ) + let coordinator = EditJotCoordinatorMock( + openJotProvider: { received in + XCTAssertEqual(received, duplicatedInfo) + openJotExpectation.fulfill() + } + ) + let viewModel = EditJotViewModel( + jotFileInfo: info, + repository: EditJotRepositoryMock( + duplicateProvider: { _ in duplicatedInfo } + ), + coordinator: coordinator, + menuConfigurationFactory: JotMenuConfigurationFactory() + ) + + // When + let configurations = viewModel.menuConfigurations.make(popoverAnchorProvider: { nil }) + let duplicateAction = configurations.compactMap { configuration -> JotMenuConfiguration.Action? in + if case let .action(action) = configuration, action.systemImageName == "plus.square.on.square" { + return action + } + return nil + }.first + try? XCTUnwrap(duplicateAction).handler() + await Task.yield() + + // Then + await fulfillment(of: [openJotExpectation], timeout: 1) + } +} diff --git a/Tests/EnableCloudPage/EnableCloudCoordinatorTests.swift b/Tests/EnableCloudPage/EnableCloudCoordinatorTests.swift new file mode 100644 index 0000000..5c73561 --- /dev/null +++ b/Tests/EnableCloudPage/EnableCloudCoordinatorTests.swift @@ -0,0 +1,121 @@ +/* + Jottre: Minimalistic jotting for iPhone, iPad and Mac. + Copyright (C) 2021-2026 Anton Lorani + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +import UIKit +import XCTest + +@testable import Jottre + +@MainActor +final class EnableCloudCoordinatorTests: XCTestCase { + + func test_start_givenInvoked_presentsNavigationControllerWithFactoryProducedRoot() { + // Given + let presentExpectation = XCTestExpectation(description: "Navigation.present is called.") + let madeViewController = UIViewController() + let navigation = Navigation.test( + presentViewControllerProvider: { viewController, animated in + MainActor.assumeIsolated { + // Then + XCTAssertTrue(viewController is UINavigationController) + let navigationController = viewController as? UINavigationController + XCTAssertEqual(navigationController?.viewControllers.first, madeViewController) + XCTAssertTrue(animated) + presentExpectation.fulfill() + } + } + ) + let coordinator = EnableCloudCoordinator( + navigation: navigation, + enableCloudViewControllerFactory: EnableCloudViewControllerFactoryMock( + makeProvider: { _ in madeViewController } + ) + ) + + // When + coordinator.start() + + // Then + wait(for: [presentExpectation], timeout: 1) + } + + func test_openLearnHowToEnable_givenInvoked_invokesNavigationOpenExternalWithSupportURL() { + // Given + let openExpectation = XCTestExpectation(description: "Navigation.openExternal is called.") + let navigation = Navigation.test( + openExternalURLProvider: { receivedURL in + // Then + XCTAssertEqual(receivedURL, EnableICloudSupportURL().toURL()) + openExpectation.fulfill() + } + ) + let coordinator = EnableCloudCoordinator( + navigation: navigation, + enableCloudViewControllerFactory: EnableCloudViewControllerFactoryMock() + ) + + // When + coordinator.openLearnHowToEnable() + + // Then + wait(for: [openExpectation], timeout: 1) + } + + func test_dismiss_givenInvoked_invokesNavigationDismissAnimated() { + // Given + let dismissExpectation = XCTestExpectation(description: "Navigation.dismiss is called.") + let navigation = Navigation.test( + dismissViewControllerProvider: { animated, _ in + // Then + XCTAssertTrue(animated) + dismissExpectation.fulfill() + } + ) + let coordinator = EnableCloudCoordinator( + navigation: navigation, + enableCloudViewControllerFactory: EnableCloudViewControllerFactoryMock() + ) + + // When + coordinator.dismiss() + + // Then + wait(for: [dismissExpectation], timeout: 1) + } + + func test_dismiss_givenCompletion_invokesOnEnd() async { + // Given + let onEndExpectation = XCTestExpectation(description: "EnableCloudCoordinator.onEnd is called.") + let navigation = Navigation.test( + dismissViewControllerProvider: { _, completion in + completion?() + } + ) + let coordinator = EnableCloudCoordinator( + navigation: navigation, + enableCloudViewControllerFactory: EnableCloudViewControllerFactoryMock() + ) + coordinator.onEnd = { onEndExpectation.fulfill() } + + // When + coordinator.dismiss() + + // Then + await fulfillment(of: [onEndExpectation], timeout: 1) + } +} diff --git a/Tests/EnableCloudPage/EnableCloudViewModelTests.swift b/Tests/EnableCloudPage/EnableCloudViewModelTests.swift new file mode 100644 index 0000000..4fcf091 --- /dev/null +++ b/Tests/EnableCloudPage/EnableCloudViewModelTests.swift @@ -0,0 +1,103 @@ +/* + Jottre: Minimalistic jotting for iPhone, iPad and Mac. + Copyright (C) 2021-2026 Anton Lorani + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +import XCTest + +@testable import Jottre + +@MainActor +final class EnableCloudViewModelTests: XCTestCase { + + func test_items_givenInit_yieldsHeaderAndTwoFeatureRows() async throws { + // Given + let viewModel = EnableCloudViewModel(coordinator: EnableCloudCoordinatorMock()) + + // When + let items = try await firstValue(of: viewModel.items) + + // Then + XCTAssertEqual(items.count, 3) + } + + func test_rightNavigationItems_givenInit_yieldsXmarkSymbol() async throws { + // Given + let viewModel = EnableCloudViewModel(coordinator: EnableCloudCoordinatorMock()) + + // When + let items = try await firstValue(of: viewModel.rightNavigationItems) + + // Then + XCTAssertEqual(items.count, 1) + guard case let .symbol(systemImageName, _) = items[0] else { + XCTFail("Expected .symbol") + return + } + XCTAssertEqual(systemImageName, "xmark") + } + + func test_rightNavigationItem_givenTap_invokesCoordinatorDismiss() async throws { + // Given + let dismissExpectation = XCTestExpectation(description: "EnableCloudCoordinatorMock.dismiss is called.") + let coordinator = EnableCloudCoordinatorMock( + dismissProvider: { dismissExpectation.fulfill() } + ) + let viewModel = EnableCloudViewModel(coordinator: coordinator) + + // When + let items = try await firstValue(of: viewModel.rightNavigationItems) + guard case let .symbol(_, onAction) = items[0] else { + XCTFail("Expected .symbol") + return + } + onAction() + await Task.yield() + + // Then + await fulfillment(of: [dismissExpectation], timeout: 1) + } + + func test_actions_givenLearnHowToEnableTap_invokesCoordinatorOpenLearnHowToEnable() async throws { + // Given + let openExpectation = XCTestExpectation( + description: "EnableCloudCoordinatorMock.openLearnHowToEnable is called." + ) + let coordinator = EnableCloudCoordinatorMock( + openLearnHowToEnableProvider: { openExpectation.fulfill() } + ) + let viewModel = EnableCloudViewModel(coordinator: coordinator) + + // When + XCTAssertEqual(viewModel.actions.count, 1) + viewModel.actions[0].action() + await Task.yield() + + // Then + await fulfillment(of: [openExpectation], timeout: 1) + } +} + +@MainActor +private func firstValue( + of sequence: S +) async throws -> S.Element where S.Element: Sendable { + var iterator = sequence.makeAsyncIterator() + guard let value = try await iterator.next() else { + throw NSError(domain: "EnableCloudViewModelTests", code: 0) + } + return value +} diff --git a/Tests/EnableCloudPage/FeatureRowCellViewModelTests.swift b/Tests/EnableCloudPage/FeatureRowCellViewModelTests.swift new file mode 100644 index 0000000..2065b6c --- /dev/null +++ b/Tests/EnableCloudPage/FeatureRowCellViewModelTests.swift @@ -0,0 +1,37 @@ +/* + Jottre: Minimalistic jotting for iPhone, iPad and Mac. + Copyright (C) 2021-2026 Anton Lorani + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +import XCTest + +@testable import Jottre + +@MainActor +final class FeatureRowCellViewModelTests: XCTestCase { + + func test_init_storesSystemImageNameAndText() { + // When + let viewModel = FeatureRowCellViewModel( + systemImageName: "icloud", + text: "Sync across devices" + ) + + // Then + XCTAssertEqual(viewModel.systemImageName, "icloud") + XCTAssertEqual(viewModel.text, "Sync across devices") + } +} diff --git a/Tests/FileService/LocalFileServiceTests.swift b/Tests/FileService/LocalFileServiceTests.swift new file mode 100644 index 0000000..dabb350 --- /dev/null +++ b/Tests/FileService/LocalFileServiceTests.swift @@ -0,0 +1,170 @@ +/* + Jottre: Minimalistic jotting for iPhone, iPad and Mac. + Copyright (C) 2021-2026 Anton Lorani + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +import Foundation +import XCTest + +@testable import Jottre + +final class LocalFileServiceTests: XCTestCase { + + private var rootDirectory: URL! + private var fileService: LocalFileService! + + override func setUpWithError() throws { + try super.setUpWithError() + rootDirectory = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: rootDirectory, withIntermediateDirectories: true) + fileService = LocalFileService(fileManager: FileManager.default) + } + + override func tearDownWithError() throws { + try? FileManager.default.removeItem(at: rootDirectory) + rootDirectory = nil + fileService = nil + try super.tearDownWithError() + } + + func test_isEnabled_alwaysReturnsTrue() { + // Then + XCTAssertTrue(fileService.isEnabled()) + } + + func test_temporaryDirectory_returnsFileManagerTemporaryDirectory() { + // When + let url = fileService.temporaryDirectory() + + // Then + XCTAssertEqual(url, FileManager.default.temporaryDirectory) + } + + func test_writeFileThenReadFile_roundTripsBytes() throws { + // Given + let fileURL = rootDirectory.appendingPathComponent("note.jot") + let payload = Data("Hello".utf8) + + // When + try fileService.writeFile(fileURL: fileURL, data: payload) + let readBack = try fileService.readFile(fileURL: fileURL) + + // Then + XCTAssertEqual(readBack, payload) + } + + func test_fileExists_givenWrittenFile_returnsTrue() throws { + // Given + let fileURL = rootDirectory.appendingPathComponent("note.jot") + try Data().write(to: fileURL) + + // Then + XCTAssertTrue(fileService.fileExists(fileURL: fileURL)) + } + + func test_fileExists_givenMissingFile_returnsFalse() { + // Given + let fileURL = rootDirectory.appendingPathComponent("missing.jot") + + // Then + XCTAssertFalse(fileService.fileExists(fileURL: fileURL)) + } + + func test_listContents_givenMixOfFiles_returnsAllURLs() throws { + // Given + let fileURL1 = rootDirectory.appendingPathComponent("a.jot") + let fileURL2 = rootDirectory.appendingPathComponent("b.txt") + try Data().write(to: fileURL1) + try Data().write(to: fileURL2) + + // When + let urls = try fileService.listContents(directory: rootDirectory, properties: []) + + // Then + let names = Set(urls.map(\.lastPathComponent)) + XCTAssertEqual(names, ["a.jot", "b.txt"]) + } + + func test_moveFile_movesFileToDestinationAndDeletesOriginal() throws { + // Given + let originalURL = rootDirectory.appendingPathComponent("original.jot") + let destinationURL = rootDirectory.appendingPathComponent("destination.jot") + try Data("payload".utf8).write(to: originalURL) + + // When + try fileService.moveFile(fileURL: originalURL, newFileURL: destinationURL) + + // Then + XCTAssertFalse(FileManager.default.fileExists(atPath: originalURL.path)) + XCTAssertTrue(FileManager.default.fileExists(atPath: destinationURL.path)) + XCTAssertEqual(try Data(contentsOf: destinationURL), Data("payload".utf8)) + } + + func test_removeFile_deletesFile() throws { + // Given + let fileURL = rootDirectory.appendingPathComponent("note.jot") + try Data().write(to: fileURL) + + // When + try fileService.removeFile(fileURL: fileURL) + + // Then + XCTAssertFalse(FileManager.default.fileExists(atPath: fileURL.path)) + } + + func test_duplicateFile_givenNoExistingDuplicate_writesPlainNamedCopy() throws { + // Given + let originalURL = rootDirectory.appendingPathComponent("note.jot") + try Data("payload".utf8).write(to: originalURL) + + // When + let duplicatedURL = try fileService.duplicateFile(fileURL: originalURL) + + // Then + XCTAssertEqual(duplicatedURL.pathExtension, "jot") + XCTAssertNotEqual(duplicatedURL, originalURL) + XCTAssertTrue(FileManager.default.fileExists(atPath: duplicatedURL.path)) + XCTAssertEqual(try Data(contentsOf: duplicatedURL), Data("payload".utf8)) + } + + func test_duplicateFile_givenExistingDuplicate_writesIncrementedNamedCopy() throws { + // Given + let originalURL = rootDirectory.appendingPathComponent("note.jot") + try Data("payload".utf8).write(to: originalURL) + + // When + let firstDuplicate = try fileService.duplicateFile(fileURL: originalURL) + let secondDuplicate = try fileService.duplicateFile(fileURL: originalURL) + + // Then + XCTAssertNotEqual(firstDuplicate, secondDuplicate) + XCTAssertTrue(FileManager.default.fileExists(atPath: firstDuplicate.path)) + XCTAssertTrue(FileManager.default.fileExists(atPath: secondDuplicate.path)) + } + + func test_directoryChanges_emitsInitialEventOnSubscribe() async throws { + // Given + let stream = fileService.directoryChanges(directory: rootDirectory) + var iterator = stream.makeAsyncIterator() + + // When + let first: Void? = await iterator.next() + + // Then + XCTAssertNotNil(first) + } +} diff --git a/Tests/FileService/UbiquitousInfoTests.swift b/Tests/FileService/UbiquitousInfoTests.swift new file mode 100644 index 0000000..51610b4 --- /dev/null +++ b/Tests/FileService/UbiquitousInfoTests.swift @@ -0,0 +1,60 @@ +/* + Jottre: Minimalistic jotting for iPhone, iPad and Mac. + Copyright (C) 2021-2026 Anton Lorani + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +import XCTest + +@testable import Jottre + +final class UbiquitousInfoTests: XCTestCase { + + func test_equality_givenSameValues_returnsEqual() { + // Given + let lhs = UbiquitousInfo(downloadStatus: .current, isDownloading: false) + let rhs = UbiquitousInfo(downloadStatus: .current, isDownloading: false) + + // Then + XCTAssertEqual(lhs, rhs) + } + + func test_equality_givenDifferentDownloadStatus_returnsNotEqual() { + // Given + let lhs = UbiquitousInfo(downloadStatus: .current, isDownloading: false) + let rhs = UbiquitousInfo(downloadStatus: .notDownloaded, isDownloading: false) + + // Then + XCTAssertNotEqual(lhs, rhs) + } + + func test_equality_givenDifferentIsDownloading_returnsNotEqual() { + // Given + let lhs = UbiquitousInfo(downloadStatus: .current, isDownloading: false) + let rhs = UbiquitousInfo(downloadStatus: .current, isDownloading: true) + + // Then + XCTAssertNotEqual(lhs, rhs) + } + + func test_equality_givenNilDownloadStatusOnBoth_returnsEqual() { + // Given + let lhs = UbiquitousInfo(downloadStatus: nil, isDownloading: true) + let rhs = UbiquitousInfo(downloadStatus: nil, isDownloading: true) + + // Then + XCTAssertEqual(lhs, rhs) + } +} diff --git a/Tests/Helpers/Navigation+test.swift b/Tests/Helpers/Navigation+test.swift new file mode 100644 index 0000000..15c030e --- /dev/null +++ b/Tests/Helpers/Navigation+test.swift @@ -0,0 +1,46 @@ +/* + Jottre: Minimalistic jotting for iPhone, iPad and Mac. + Copyright (C) 2021-2026 Anton Lorani + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +import UIKit + +@testable import Jottre + +extension Navigation { + + static func test( + openURLProvider: @Sendable @escaping (_ url: URL) -> Void = { _ in }, + openExternalURLProvider: @Sendable @escaping (_ url: URL) -> Void = { _ in }, + openSceneProvider: @Sendable @escaping (_ url: URL) -> Void = { _ in }, + presentViewControllerProvider: + @Sendable @escaping (_ viewController: UIViewController, _ animated: Bool) -> Void = { _, _ in }, + dismissViewControllerProvider: + @Sendable @escaping (_ animated: Bool, _ completion: (@Sendable () -> Void)?) -> Void = { _, _ in }, + popViewControllerProvider: @Sendable @escaping (_ animated: Bool) -> Void = { _ in }, + getViewControllersProvider: @MainActor @escaping () -> [UIViewController] = { [] } + ) -> Navigation { + Navigation( + openURLProvider: openURLProvider, + openExternalURLProvider: openExternalURLProvider, + openSceneProvider: openSceneProvider, + presentViewControllerProvider: presentViewControllerProvider, + dismissViewControllerProvider: dismissViewControllerProvider, + popViewControllerProvider: popViewControllerProvider, + getViewControllersProvider: getViewControllersProvider + ) + } +} diff --git a/Tests/Helpers/UIAlertAction+invoke.swift b/Tests/Helpers/UIAlertAction+invoke.swift new file mode 100644 index 0000000..f90361c --- /dev/null +++ b/Tests/Helpers/UIAlertAction+invoke.swift @@ -0,0 +1,31 @@ +/* + Jottre: Minimalistic jotting for iPhone, iPad and Mac. + Copyright (C) 2021-2026 Anton Lorani + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +import UIKit + +extension UIAlertAction { + + func invokeHandler() { + typealias Handler = @convention(block) (UIAlertAction) -> Void + guard let block = value(forKey: "handler") else { + return + } + let handler = unsafeBitCast(block as AnyObject, to: Handler.self) + handler(self) + } +} diff --git a/Tests/Helpers/URL+staticString.swift b/Tests/Helpers/URL+staticString.swift new file mode 100644 index 0000000..626dd70 --- /dev/null +++ b/Tests/Helpers/URL+staticString.swift @@ -0,0 +1,29 @@ +/* + Jottre: Minimalistic jotting for iPhone, iPad and Mac. + Copyright (C) 2021-2026 Anton Lorani + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +import Foundation + +extension URL { + + init(staticString: StaticString) { + guard let url = URL(string: "\(staticString)") else { + preconditionFailure("Invalid static URL string: \(staticString)") + } + self = url + } +} diff --git a/Tests/Jot/JotFileServiceDocumentsDirectoryContentsTests.swift b/Tests/Jot/JotFileServiceDocumentsDirectoryContentsTests.swift new file mode 100644 index 0000000..83d75a1 --- /dev/null +++ b/Tests/Jot/JotFileServiceDocumentsDirectoryContentsTests.swift @@ -0,0 +1,182 @@ +/* + Jottre: Minimalistic jotting for iPhone, iPad and Mac. + Copyright (C) 2021-2026 Anton Lorani + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +import Foundation +import XCTest + +@testable import Jottre + +final class JotFileServiceDocumentsDirectoryContentsTests: XCTestCase { + + private var localDocumentsDirectory: URL! + private var ubiquitousDocumentsDirectory: URL! + + override func setUpWithError() throws { + try super.setUpWithError() + localDocumentsDirectory = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + ubiquitousDocumentsDirectory = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory( + at: localDocumentsDirectory, + withIntermediateDirectories: true + ) + try FileManager.default.createDirectory( + at: ubiquitousDocumentsDirectory, + withIntermediateDirectories: true + ) + } + + override func tearDownWithError() throws { + try? FileManager.default.removeItem(at: localDocumentsDirectory) + try? FileManager.default.removeItem(at: ubiquitousDocumentsDirectory) + localDocumentsDirectory = nil + ubiquitousDocumentsDirectory = nil + try super.tearDownWithError() + } + + func test_documentsDirectoryContents_givenLocalAndUbiquitousJotFiles_yieldsCombinedContents() async throws { + // Given + let localFileURL = localDocumentsDirectory.appendingPathComponent("local.jot") + let ubiquitousFileURL = ubiquitousDocumentsDirectory.appendingPathComponent("cloud.jot") + try Data().write(to: localFileURL) + try Data().write(to: ubiquitousFileURL) + + let jotFileService = makeJotFileService() + + // When + var iterator = jotFileService.documentsDirectoryContents().makeAsyncIterator() + let infos = try await XCTUnwrapAsync(try await iterator.next()) + + // Then + let names = Set(infos.map(\.name)) + XCTAssertEqual(names, ["local", "cloud"]) + let cloudInfo = try XCTUnwrap(infos.first(where: { $0.name == "cloud" })) + XCTAssertNotNil(cloudInfo.ubiquitousInfo) + let localInfo = try XCTUnwrap(infos.first(where: { $0.name == "local" })) + XCTAssertNil(localInfo.ubiquitousInfo) + } + + func test_documentsDirectoryContents_givenNonJotFileInDirectory_filtersItOut() async throws { + // Given + let jotFileURL = localDocumentsDirectory.appendingPathComponent("real.jot") + let textFileURL = localDocumentsDirectory.appendingPathComponent("ignored.txt") + try Data().write(to: jotFileURL) + try Data().write(to: textFileURL) + + let jotFileService = makeJotFileService(includeUbiquitous: false) + + // When + var iterator = jotFileService.documentsDirectoryContents().makeAsyncIterator() + let infos = try await XCTUnwrapAsync(try await iterator.next()) + + // Then + XCTAssertEqual(infos.map(\.name), ["real"]) + } + + func test_documentsDirectoryContents_givenLocalDirectoryUnavailable_yieldsOnlyUbiquitousContents() async throws { + // Given + let ubiquitousFileURL = ubiquitousDocumentsDirectory.appendingPathComponent("cloud.jot") + try Data().write(to: ubiquitousFileURL) + + let localFileServiceMock = FileServiceMock( + documentsDirectoryProvider: { nil }, + listContentsProvider: { _, _ in [] } + ) + let ubiquitousFileServiceMock = FileServiceMock( + documentsDirectoryProvider: { [ubiquitousDocumentsDirectory] in ubiquitousDocumentsDirectory }, + listContentsProvider: { directory, _ in + try FileManager.default.contentsOfDirectory( + at: directory, + includingPropertiesForKeys: nil + ) + }, + directoryChangesProvider: { _ in + AsyncStream { continuation in + continuation.yield(()) + continuation.finish() + } + } + ) + let jotFileService = JotFileService( + localFileService: localFileServiceMock, + ubiquitousFileService: ubiquitousFileServiceMock + ) + + // When + var iterator = jotFileService.documentsDirectoryContents().makeAsyncIterator() + let infos = try await XCTUnwrapAsync(try await iterator.next()) + + // Then + XCTAssertEqual(infos.map(\.name), ["cloud"]) + } + + private func makeJotFileService(includeUbiquitous: Bool = true) -> JotFileService { + let localFileServiceMock = FileServiceMock( + documentsDirectoryProvider: { [localDocumentsDirectory] in localDocumentsDirectory }, + listContentsProvider: { directory, _ in + try FileManager.default.contentsOfDirectory( + at: directory, + includingPropertiesForKeys: nil + ) + }, + directoryChangesProvider: { _ in + AsyncStream { continuation in + continuation.yield(()) + continuation.finish() + } + } + ) + let ubiquitousFileServiceMock: FileServiceMock + if includeUbiquitous { + ubiquitousFileServiceMock = FileServiceMock( + documentsDirectoryProvider: { [ubiquitousDocumentsDirectory] in ubiquitousDocumentsDirectory }, + listContentsProvider: { directory, _ in + try FileManager.default.contentsOfDirectory( + at: directory, + includingPropertiesForKeys: nil + ) + }, + directoryChangesProvider: { _ in + AsyncStream { continuation in + continuation.yield(()) + continuation.finish() + } + } + ) + } else { + ubiquitousFileServiceMock = FileServiceMock( + documentsDirectoryProvider: { nil }, + listContentsProvider: { _, _ in [] } + ) + } + return JotFileService( + localFileService: localFileServiceMock, + ubiquitousFileService: ubiquitousFileServiceMock + ) + } +} + +private func XCTUnwrapAsync( + _ expression: @autoclosure () async throws -> T?, + file: StaticString = #filePath, + line: UInt = #line +) async throws -> T { + let value = try await expression() + return try XCTUnwrap(value, file: file, line: line) +} diff --git a/Tests/Jot/JotFileServiceTests.swift b/Tests/Jot/JotFileServiceTests.swift new file mode 100644 index 0000000..d51fe41 --- /dev/null +++ b/Tests/Jot/JotFileServiceTests.swift @@ -0,0 +1,451 @@ +/* + Jottre: Minimalistic jotting for iPhone, iPad and Mac. + Copyright (C) 2021-2026 Anton Lorani + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +import XCTest + +@testable import Jottre + +final class JotFileServiceTests: XCTestCase { + + func test_write_givenLocalJotFile_writesEncodedDataToLocalFileService() async throws { + // Given + let writeFileProviderExpectation = XCTestExpectation( + description: "FileServiceMock.writeFileProvider is called." + ) + let expectedFileURL = URL(staticString: "file:///tmp/note.jot") + let jot = Jot.makeEmpty() + let jotFile = JotFile( + info: JotFile.Info( + url: expectedFileURL, + name: "note", + modificationDate: nil, + ubiquitousInfo: nil + ), + jot: jot + ) + let expectedData = try PropertyListEncoder().encode(jot) + let localFileServiceMock = FileServiceMock( + writeFileProvider: { receivedFileURL, receivedData in + // Then + XCTAssertEqual(receivedFileURL, expectedFileURL) + XCTAssertEqual(receivedData, expectedData) + writeFileProviderExpectation.fulfill() + } + ) + let ubiquitousFileServiceMock = FileServiceMock( + writeFileProvider: { _, _ in + XCTFail("Ubiquitous file service should not be used for local jot files.") + } + ) + let jotFileService = JotFileService( + localFileService: localFileServiceMock, + ubiquitousFileService: ubiquitousFileServiceMock + ) + + // When + try jotFileService.write(jotFile: jotFile) + + // Then + await fulfillment(of: [writeFileProviderExpectation], timeout: 0.2) + } + + func test_write_givenUbiquitousJotFile_writesEncodedDataToUbiquitousFileService() async throws { + // Given + let writeFileProviderExpectation = XCTestExpectation(description: "Ubiquitous writeFileProvider is called.") + let expectedFileURL = URL(staticString: "file:///cloud/note.jot") + let jot = Jot.makeEmpty() + let jotFile = JotFile( + info: JotFile.Info( + url: expectedFileURL, + name: "note", + modificationDate: nil, + ubiquitousInfo: UbiquitousInfo( + downloadStatus: .current, + isDownloading: false + ) + ), + jot: jot + ) + let localFileServiceMock = FileServiceMock( + writeFileProvider: { _, _ in + XCTFail("Local file service should not be used for ubiquitous jot files.") + } + ) + let ubiquitousFileServiceMock = FileServiceMock( + writeFileProvider: { receivedFileURL, _ in + // Then + XCTAssertEqual(receivedFileURL, expectedFileURL) + writeFileProviderExpectation.fulfill() + } + ) + let jotFileService = JotFileService( + localFileService: localFileServiceMock, + ubiquitousFileService: ubiquitousFileServiceMock + ) + + // When + try jotFileService.write(jotFile: jotFile) + + // Then + await fulfillment(of: [writeFileProviderExpectation], timeout: 0.2) + } + + func test_readJotFile_givenLocalJotFileInfo_decodesDataFromLocalFileService() async throws { + // Given + let readFileProviderExpectation = XCTestExpectation(description: "Local readFileProvider is called.") + let expectedFileURL = URL(staticString: "file:///tmp/note.jot") + let expectedJot = Jot.makeEmpty() + let encodedData = try PropertyListEncoder().encode(expectedJot) + let localFileServiceMock = FileServiceMock( + readFileProvider: { receivedFileURL in + // Then + XCTAssertEqual(receivedFileURL, expectedFileURL) + readFileProviderExpectation.fulfill() + return encodedData + } + ) + let jotFileService = JotFileService( + localFileService: localFileServiceMock, + ubiquitousFileService: FileServiceMock() + ) + + // When + let jotFile = try jotFileService.readJotFile( + jotFileInfo: JotFile.Info( + url: expectedFileURL, + name: "note", + modificationDate: nil, + ubiquitousInfo: nil + ) + ) + + // Then + XCTAssertEqual(jotFile.jot.drawing, expectedJot.drawing) + XCTAssertEqual(jotFile.jot.width, expectedJot.width) + XCTAssertEqual(jotFile.jot.version, expectedJot.version) + await fulfillment(of: [readFileProviderExpectation], timeout: 0.2) + } + + func test_remove_givenLocalJotFileInfo_callsRemoveOnLocalFileService() async throws { + // Given + let removeFileProviderExpectation = XCTestExpectation(description: "Local removeFileProvider is called.") + let expectedFileURL = URL(staticString: "file:///tmp/note.jot") + let localFileServiceMock = FileServiceMock( + removeFileProvider: { receivedFileURL in + // Then + XCTAssertEqual(receivedFileURL, expectedFileURL) + removeFileProviderExpectation.fulfill() + } + ) + let jotFileService = JotFileService( + localFileService: localFileServiceMock, + ubiquitousFileService: FileServiceMock() + ) + + // When + try jotFileService.remove( + jotFileInfo: JotFile.Info( + url: expectedFileURL, + name: "note", + modificationDate: nil, + ubiquitousInfo: nil + ) + ) + + // Then + await fulfillment(of: [removeFileProviderExpectation], timeout: 0.2) + } + + func test_rename_givenLocalJotFileInfo_movesToNewURLAndReturnsUpdatedInfo() async throws { + // Given + let moveFileProviderExpectation = XCTestExpectation(description: "Local moveFileProvider is called.") + let originalFileURL = URL(staticString: "file:///tmp/old.jot") + let expectedNewFileURL = URL(staticString: "file:///tmp/new.jot") + let localFileServiceMock = FileServiceMock( + moveFileProvider: { receivedFileURL, receivedNewFileURL in + // Then + XCTAssertEqual(receivedFileURL, originalFileURL) + XCTAssertEqual(receivedNewFileURL, expectedNewFileURL) + moveFileProviderExpectation.fulfill() + } + ) + let jotFileService = JotFileService( + localFileService: localFileServiceMock, + ubiquitousFileService: FileServiceMock() + ) + + // When + let renamed = try jotFileService.rename( + jotFileInfo: JotFile.Info( + url: originalFileURL, + name: "old", + modificationDate: nil, + ubiquitousInfo: nil + ), + newName: "new" + ) + + // Then + XCTAssertEqual(renamed.url, expectedNewFileURL) + XCTAssertEqual(renamed.name, "new") + await fulfillment(of: [moveFileProviderExpectation], timeout: 0.2) + } + + func test_duplicate_givenLocalJotFileInfo_returnsDuplicatedInfo() async throws { + // Given + let duplicateFileProviderExpectation = XCTestExpectation(description: "Local duplicateFileProvider is called.") + let originalFileURL = URL(staticString: "file:///tmp/note.jot") + let duplicatedFileURL = URL(staticString: "file:///tmp/note-1.jot") + let localFileServiceMock = FileServiceMock( + duplicateFileProvider: { receivedFileURL in + // Then + XCTAssertEqual(receivedFileURL, originalFileURL) + duplicateFileProviderExpectation.fulfill() + return duplicatedFileURL + } + ) + let jotFileService = JotFileService( + localFileService: localFileServiceMock, + ubiquitousFileService: FileServiceMock() + ) + + // When + let duplicated = try jotFileService.duplicate( + jotFileInfo: JotFile.Info( + url: originalFileURL, + name: "note", + modificationDate: nil, + ubiquitousInfo: nil + ) + ) + + // Then + XCTAssertEqual(duplicated.url, duplicatedFileURL) + XCTAssertEqual(duplicated.name, "note-1") + await fulfillment(of: [duplicateFileProviderExpectation], timeout: 0.2) + } + + func test_move_givenShouldBecomeUbiquitous_movesIntoUbiquitousDocumentsDirectory() async throws { + // Given + let moveFileProviderExpectation = XCTestExpectation(description: "Ubiquitous moveFileProvider is called.") + let originalFileURL = URL(staticString: "file:///tmp/note.jot") + let ubiquitousDocumentsDirectory = URL(staticString: "file:///cloud/Documents/") + let expectedNewFileURL = ubiquitousDocumentsDirectory.appendingPathComponent("note.jot", isDirectory: false) + let ubiquitousFileServiceMock = FileServiceMock( + documentsDirectoryProvider: { ubiquitousDocumentsDirectory }, + moveFileProvider: { receivedFileURL, receivedNewFileURL in + // Then + XCTAssertEqual(receivedFileURL, originalFileURL) + XCTAssertEqual(receivedNewFileURL, expectedNewFileURL) + moveFileProviderExpectation.fulfill() + } + ) + let jotFileService = JotFileService( + localFileService: FileServiceMock(), + ubiquitousFileService: ubiquitousFileServiceMock + ) + + // When + try await jotFileService.move( + jotFileInfo: JotFile.Info( + url: originalFileURL, + name: "note", + modificationDate: nil, + ubiquitousInfo: nil + ), + shouldBecomeUbiquitous: true + ) + + // Then + await fulfillment(of: [moveFileProviderExpectation], timeout: 0.2) + } + + func test_readJotFile_givenUbiquitousJotFileInfo_decodesDataFromUbiquitousFileService() async throws { + // Given + let readFileProviderExpectation = XCTestExpectation(description: "Ubiquitous readFileProvider is called.") + let expectedFileURL = URL(staticString: "file:///cloud/note.jot") + let expectedJot = Jot.makeEmpty() + let encodedData = try PropertyListEncoder().encode(expectedJot) + let ubiquitousFileServiceMock = FileServiceMock( + readFileProvider: { receivedFileURL in + // Then + XCTAssertEqual(receivedFileURL, expectedFileURL) + readFileProviderExpectation.fulfill() + return encodedData + } + ) + let jotFileService = JotFileService( + localFileService: FileServiceMock( + readFileProvider: { _ in + XCTFail("Local file service should not be used for ubiquitous jot files.") + return Data() + } + ), + ubiquitousFileService: ubiquitousFileServiceMock + ) + + // When + _ = try jotFileService.readJotFile( + jotFileInfo: JotFile.Info( + url: expectedFileURL, + name: "note", + modificationDate: nil, + ubiquitousInfo: UbiquitousInfo(downloadStatus: .current, isDownloading: false) + ) + ) + + // Then + await fulfillment(of: [readFileProviderExpectation], timeout: 0.2) + } + + func test_remove_givenUbiquitousJotFileInfo_callsRemoveOnUbiquitousFileService() async throws { + // Given + let removeFileProviderExpectation = XCTestExpectation(description: "Ubiquitous removeFileProvider is called.") + let expectedFileURL = URL(staticString: "file:///cloud/note.jot") + let ubiquitousFileServiceMock = FileServiceMock( + removeFileProvider: { receivedFileURL in + // Then + XCTAssertEqual(receivedFileURL, expectedFileURL) + removeFileProviderExpectation.fulfill() + } + ) + let jotFileService = JotFileService( + localFileService: FileServiceMock( + removeFileProvider: { _ in + XCTFail("Local file service should not be used for ubiquitous jot files.") + } + ), + ubiquitousFileService: ubiquitousFileServiceMock + ) + + // When + try jotFileService.remove( + jotFileInfo: JotFile.Info( + url: expectedFileURL, + name: "note", + modificationDate: nil, + ubiquitousInfo: UbiquitousInfo(downloadStatus: .current, isDownloading: false) + ) + ) + + // Then + await fulfillment(of: [removeFileProviderExpectation], timeout: 0.2) + } + + func test_rename_givenUbiquitousJotFileInfo_movesViaUbiquitousFileService() async throws { + // Given + let moveFileProviderExpectation = XCTestExpectation(description: "Ubiquitous moveFileProvider is called.") + let originalFileURL = URL(staticString: "file:///cloud/old.jot") + let expectedNewFileURL = URL(staticString: "file:///cloud/new.jot") + let ubiquitousFileServiceMock = FileServiceMock( + moveFileProvider: { receivedFileURL, receivedNewFileURL in + // Then + XCTAssertEqual(receivedFileURL, originalFileURL) + XCTAssertEqual(receivedNewFileURL, expectedNewFileURL) + moveFileProviderExpectation.fulfill() + } + ) + let jotFileService = JotFileService( + localFileService: FileServiceMock(), + ubiquitousFileService: ubiquitousFileServiceMock + ) + + // When + let renamed = try jotFileService.rename( + jotFileInfo: JotFile.Info( + url: originalFileURL, + name: "old", + modificationDate: nil, + ubiquitousInfo: UbiquitousInfo(downloadStatus: .current, isDownloading: false) + ), + newName: "new" + ) + + // Then + XCTAssertEqual(renamed.url, expectedNewFileURL) + XCTAssertEqual(renamed.name, "new") + await fulfillment(of: [moveFileProviderExpectation], timeout: 0.2) + } + + func test_duplicate_givenUbiquitousJotFileInfo_returnsDuplicatedInfoFromUbiquitousFileService() async throws { + // Given + let duplicateFileProviderExpectation = XCTestExpectation( + description: "Ubiquitous duplicateFileProvider is called." + ) + let originalFileURL = URL(staticString: "file:///cloud/note.jot") + let duplicatedFileURL = URL(staticString: "file:///cloud/note-1.jot") + let originalUbiquitousInfo = UbiquitousInfo(downloadStatus: .current, isDownloading: false) + let ubiquitousFileServiceMock = FileServiceMock( + duplicateFileProvider: { receivedFileURL in + // Then + XCTAssertEqual(receivedFileURL, originalFileURL) + duplicateFileProviderExpectation.fulfill() + return duplicatedFileURL + } + ) + let jotFileService = JotFileService( + localFileService: FileServiceMock(), + ubiquitousFileService: ubiquitousFileServiceMock + ) + + // When + let duplicated = try jotFileService.duplicate( + jotFileInfo: JotFile.Info( + url: originalFileURL, + name: "note", + modificationDate: nil, + ubiquitousInfo: originalUbiquitousInfo + ) + ) + + // Then + XCTAssertEqual(duplicated.url, duplicatedFileURL) + XCTAssertEqual(duplicated.name, "note-1") + XCTAssertEqual(duplicated.ubiquitousInfo, originalUbiquitousInfo) + await fulfillment(of: [duplicateFileProviderExpectation], timeout: 0.2) + } + + func test_move_givenDocumentsDirectoryUnresolved_throwsCouldNotResolveFailure() async { + // Given + let localFileServiceMock = FileServiceMock( + documentsDirectoryProvider: { nil } + ) + let jotFileService = JotFileService( + localFileService: localFileServiceMock, + ubiquitousFileService: FileServiceMock() + ) + + // When / Then + do { + try await jotFileService.move( + jotFileInfo: JotFile.Info( + url: URL(staticString: "file:///tmp/note.jot"), + name: "note", + modificationDate: nil, + ubiquitousInfo: nil + ), + shouldBecomeUbiquitous: false + ) + XCTFail("Expected JotFileService.Failure.couldNotResolveDocumentsDirectory.") + } catch JotFileService.Failure.couldNotResolveDocumentsDirectory { + // Expected + } catch { + XCTFail("Unexpected error: \(error)") + } + } +} diff --git a/Tests/Jot/JotFileTests.swift b/Tests/Jot/JotFileTests.swift new file mode 100644 index 0000000..64e27d8 --- /dev/null +++ b/Tests/Jot/JotFileTests.swift @@ -0,0 +1,67 @@ +/* + Jottre: Minimalistic jotting for iPhone, iPad and Mac. + Copyright (C) 2021-2026 Anton Lorani + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +import XCTest + +@testable import Jottre + +final class JotFileTests: XCTestCase { + + func test_infoInit_givenURLWithJotExtension_succeedsAndDerivesNameFromURL() throws { + // Given + let url = URL(staticString: "file:///tmp/note.jot") + + // When + let info = try XCTUnwrap( + JotFile.Info( + url: url, + modificationDate: nil, + ubiquitousInfo: nil + ) + ) + + // Then + XCTAssertEqual(info.name, "note") + XCTAssertEqual(info.url, url) + } + + func test_infoInit_givenURLWithNonJotExtension_returnsNil() { + // Given + let url = URL(staticString: "file:///tmp/note.txt") + + // When + let info = JotFile.Info( + url: url, + modificationDate: nil, + ubiquitousInfo: nil + ) + + // Then + XCTAssertNil(info) + } + + func test_makeEmptyJot_returnsVersionThreeWithDefaultWidth() { + // When + let jot = Jot.makeEmpty() + + // Then + XCTAssertEqual(jot.version, 3) + XCTAssertEqual(jot.width, 1200) + XCTAssertFalse(jot.drawing.isEmpty) + } +} diff --git a/Tests/JotConflictPage/JotConflictBusinessModelTests.swift b/Tests/JotConflictPage/JotConflictBusinessModelTests.swift new file mode 100644 index 0000000..385a6a9 --- /dev/null +++ b/Tests/JotConflictPage/JotConflictBusinessModelTests.swift @@ -0,0 +1,95 @@ +/* + Jottre: Minimalistic jotting for iPhone, iPad and Mac. + Copyright (C) 2021-2026 Anton Lorani + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +import XCTest + +@testable import Jottre + +final class JotConflictBusinessModelTests: XCTestCase { + + func test_init_givenSavingComputerName_usesAsLastEditedDateString() { + // Given + let info = JotFile.Info( + url: URL(staticString: "file:///tmp/note.jot"), + name: "note", + modificationDate: nil, + ubiquitousInfo: nil + ) + let version = JotFileVersion( + localizedNameOfSavingComputer: "Anton's Mac", + info: info + ) + + // When + let model = JotConflictBusinessModel( + name: "label", + jotFileInfo: info, + jotFileVersion: version + ) + + // Then + XCTAssertEqual(model.name, "label") + XCTAssertEqual(model.lastEditedDateString, "Anton's Mac") + XCTAssertEqual(model.jotFileInfo, info) + } + + func test_init_givenNilSavingComputerName_returnsNotApplicableString() { + // Given + let info = JotFile.Info( + url: URL(staticString: "file:///tmp/note.jot"), + name: "note", + modificationDate: nil, + ubiquitousInfo: nil + ) + let version = JotFileVersion( + localizedNameOfSavingComputer: nil, + info: info + ) + + // When + let model = JotConflictBusinessModel( + name: "label", + jotFileInfo: info, + jotFileVersion: version + ) + + // Then + XCTAssertEqual(model.lastEditedDateString, "n/a") + } + + func test_toJotFileVersion_returnsOriginalVersion() { + // Given + let info = JotFile.Info( + url: URL(staticString: "file:///tmp/note.jot"), + name: "note", + modificationDate: nil, + ubiquitousInfo: nil + ) + let version = JotFileVersion(localizedNameOfSavingComputer: nil, info: info) + + // When + let model = JotConflictBusinessModel( + name: "label", + jotFileInfo: info, + jotFileVersion: version + ) + + // Then + XCTAssertEqual(model.toJotFileVersion(), version) + } +} diff --git a/Tests/JotConflictPage/JotConflictCellViewModelTests.swift b/Tests/JotConflictPage/JotConflictCellViewModelTests.swift new file mode 100644 index 0000000..994bec6 --- /dev/null +++ b/Tests/JotConflictPage/JotConflictCellViewModelTests.swift @@ -0,0 +1,141 @@ +/* + Jottre: Minimalistic jotting for iPhone, iPad and Mac. + Copyright (C) 2021-2026 Anton Lorani + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +import UIKit +import XCTest + +@testable import Jottre + +@MainActor +final class JotConflictCellViewModelTests: XCTestCase { + + func test_init_storesNameAndInfoTextFromBusinessModel() { + // Given + let jotFileInfo = JotFile.Info( + url: URL(staticString: "file:///tmp/note.jot"), + name: "note", + modificationDate: nil, + ubiquitousInfo: nil + ) + let businessModel = JotConflictBusinessModel( + name: "note", + jotFileInfo: jotFileInfo, + jotFileVersion: JotFileVersion(localizedNameOfSavingComputer: "Mac", info: jotFileInfo) + ) + + // When + let viewModel = JotConflictCellViewModel( + jotConflict: businessModel, + repository: JotConflictRepositoryMock() + ) + + // Then + XCTAssertEqual(viewModel.name, "note") + XCTAssertEqual(viewModel.infoText, "Mac") + } + + func test_init_givenJotFileVersionWithoutSavingComputer_setsInfoTextToNa() { + // Given + let jotFileInfo = JotFile.Info( + url: URL(staticString: "file:///tmp/note.jot"), + name: "note", + modificationDate: nil, + ubiquitousInfo: nil + ) + let businessModel = JotConflictBusinessModel( + name: "note", + jotFileInfo: jotFileInfo, + jotFileVersion: JotFileVersion(localizedNameOfSavingComputer: nil, info: jotFileInfo) + ) + + // When + let viewModel = JotConflictCellViewModel( + jotConflict: businessModel, + repository: JotConflictRepositoryMock() + ) + + // Then + XCTAssertEqual(viewModel.infoText, "n/a") + } + + func test_getPreviewImage_forwardsJotFileInfoAndVersionToRepository() async throws { + // Given + let getPreviewImageExpectation = XCTestExpectation( + description: "JotConflictRepositoryMock.getPreviewImageProvider is called." + ) + let expectedImage = UIImage() + let jotFileInfo = JotFile.Info( + url: URL(staticString: "file:///tmp/note.jot"), + name: "note", + modificationDate: nil, + ubiquitousInfo: nil + ) + let jotFileVersion = JotFileVersion(localizedNameOfSavingComputer: "Mac", info: jotFileInfo) + let businessModel = JotConflictBusinessModel( + name: "note", + jotFileInfo: jotFileInfo, + jotFileVersion: jotFileVersion + ) + let repositoryMock = JotConflictRepositoryMock( + getPreviewImageProvider: { receivedInfo, receivedVersion, receivedStyle, receivedScale in + // Then + XCTAssertEqual(receivedInfo, jotFileInfo) + XCTAssertEqual(receivedVersion, jotFileVersion) + XCTAssertEqual(receivedStyle, .dark) + XCTAssertEqual(receivedScale, 3.0) + getPreviewImageExpectation.fulfill() + return expectedImage + } + ) + let viewModel = JotConflictCellViewModel( + jotConflict: businessModel, + repository: repositoryMock + ) + + // When + let image = await viewModel.getPreviewImage(userInterfaceStyle: .dark, displayScale: 3.0) + + // Then + XCTAssertIdentical(image, expectedImage) + await fulfillment(of: [getPreviewImageExpectation], timeout: 0.2) + } + + func test_handleAction_givenTap_doesNothing() { + // Given + let jotFileInfo = JotFile.Info( + url: URL(staticString: "file:///tmp/note.jot"), + name: "note", + modificationDate: nil, + ubiquitousInfo: nil + ) + let viewModel = JotConflictCellViewModel( + jotConflict: JotConflictBusinessModel( + name: "note", + jotFileInfo: jotFileInfo, + jotFileVersion: JotFileVersion(localizedNameOfSavingComputer: nil, info: jotFileInfo) + ), + repository: JotConflictRepositoryMock() + ) + + // When + viewModel.handle(action: .tap) + + // Then + XCTAssertEqual(viewModel.name, "note") + } +} diff --git a/Tests/JotConflictPage/JotConflictCoordinatorTests.swift b/Tests/JotConflictPage/JotConflictCoordinatorTests.swift new file mode 100644 index 0000000..7febd14 --- /dev/null +++ b/Tests/JotConflictPage/JotConflictCoordinatorTests.swift @@ -0,0 +1,130 @@ +/* + Jottre: Minimalistic jotting for iPhone, iPad and Mac. + Copyright (C) 2021-2026 Anton Lorani + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +import UIKit +import XCTest + +@testable import Jottre + +@MainActor +final class JotConflictCoordinatorTests: XCTestCase { + + func test_start_givenInvoked_presentsModalNavigationController() { + // Given + let presentExpectation = XCTestExpectation(description: "Navigation.present is called.") + let madeViewController = UIViewController() + let info = JotFile.Info( + url: URL(staticString: "file:///tmp/note.jot"), + name: "note", + modificationDate: nil, + ubiquitousInfo: nil + ) + let navigation = Navigation.test( + presentViewControllerProvider: { viewController, _ in + MainActor.assumeIsolated { + let navigationController = viewController as? UINavigationController + XCTAssertEqual(navigationController?.viewControllers.first, madeViewController) + XCTAssertTrue(madeViewController.isModalInPresentation) + XCTAssertFalse(navigationController?.navigationBar.prefersLargeTitles ?? true) + presentExpectation.fulfill() + } + } + ) + let coordinator = JotConflictCoordinator( + jotFileInfo: info, + jotFileVersions: [JotFileVersion(localizedNameOfSavingComputer: "Mac", info: info)], + repository: JotConflictRepositoryMock(), + navigation: navigation, + jotConflictViewControllerFactory: JotConflictViewControllerFactoryMock( + makeProvider: { _ in madeViewController } + ), + onResult: { _ in } + ) + + // When + coordinator.start() + + // Then + wait(for: [presentExpectation], timeout: 1) + } + + func test_dismiss_givenCompletion_invokesNavigationDismissAndOnEnd() async { + // Given + let completionExpectation = XCTestExpectation(description: "Completion is invoked.") + let onEndExpectation = XCTestExpectation(description: "JotConflictCoordinator.onEnd is invoked.") + let info = JotFile.Info( + url: URL(staticString: "file:///tmp/note.jot"), + name: "note", + modificationDate: nil, + ubiquitousInfo: nil + ) + let navigation = Navigation.test( + dismissViewControllerProvider: { _, completion in + completion?() + } + ) + let coordinator = JotConflictCoordinator( + jotFileInfo: info, + jotFileVersions: [], + repository: JotConflictRepositoryMock(), + navigation: navigation, + jotConflictViewControllerFactory: JotConflictViewControllerFactoryMock(), + onResult: { _ in } + ) + coordinator.onEnd = { onEndExpectation.fulfill() } + + // When + coordinator.dismiss(completion: { completionExpectation.fulfill() }) + + // Then + await fulfillment(of: [completionExpectation, onEndExpectation], timeout: 1) + } + + func test_showInfoAlert_givenInvoked_presentsAlertController() { + // Given + let presentExpectation = XCTestExpectation(description: "Navigation.present is called.") + let info = JotFile.Info( + url: URL(staticString: "file:///tmp/note.jot"), + name: "note", + modificationDate: nil, + ubiquitousInfo: nil + ) + let navigation = Navigation.test( + presentViewControllerProvider: { viewController, _ in + MainActor.assumeIsolated { + XCTAssertTrue(viewController is UIAlertController) + presentExpectation.fulfill() + } + } + ) + let coordinator = JotConflictCoordinator( + jotFileInfo: info, + jotFileVersions: [], + repository: JotConflictRepositoryMock(), + navigation: navigation, + jotConflictViewControllerFactory: JotConflictViewControllerFactoryMock(), + onResult: { _ in } + ) + + // When + coordinator.showInfoAlert(title: "title", message: "message") + + // Then + wait(for: [presentExpectation], timeout: 1) + } +} diff --git a/Tests/JotConflictPage/JotConflictRepositoryTests.swift b/Tests/JotConflictPage/JotConflictRepositoryTests.swift new file mode 100644 index 0000000..102b244 --- /dev/null +++ b/Tests/JotConflictPage/JotConflictRepositoryTests.swift @@ -0,0 +1,184 @@ +/* + Jottre: Minimalistic jotting for iPhone, iPad and Mac. + Copyright (C) 2021-2026 Anton Lorani + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +import UIKit +import XCTest + +@testable import Jottre + +final class JotConflictRepositoryTests: XCTestCase { + + func test_resolveVersionConflicts_forwardsToJotFileConflictService() async throws { + // Given + let resolveVersionConflictsExpectation = XCTestExpectation( + description: "JotFileConflictServiceMock.resolveVersionConflictsProvider is called." + ) + let inputInfo = JotFile.Info( + url: URL(staticString: "file:///tmp/note.jot"), + name: "note", + modificationDate: nil, + ubiquitousInfo: nil + ) + let inputVersions = [ + JotFileVersion(localizedNameOfSavingComputer: "Mac", info: inputInfo), + JotFileVersion(localizedNameOfSavingComputer: "iPad", info: inputInfo), + ] + let jotFileConflictServiceMock = JotFileConflictServiceMock( + resolveVersionConflictsProvider: { receivedInfo, receivedVersions in + // Then + XCTAssertEqual(receivedInfo, inputInfo) + XCTAssertEqual(receivedVersions, inputVersions) + resolveVersionConflictsExpectation.fulfill() + } + ) + let repository = JotConflictRepository( + jotFileConflictService: jotFileConflictServiceMock, + jotFilePreviewImageService: JotFilePreviewImageServiceMock() + ) + + // When + try repository.resolveVersionConflicts(jotFileInfo: inputInfo, resolvedVersions: inputVersions) + + // Then + await fulfillment(of: [resolveVersionConflictsExpectation], timeout: 0.2) + } + + func test_getPreviewImage_givenCopyVersionToTemporaryReturnsNil_usesOriginalInfo() async throws { + // Given + let getPreviewImageDataExpectation = XCTestExpectation( + description: "JotFilePreviewImageServiceMock.getPreviewImageDataProvider is called with original info." + ) + let originalInfo = JotFile.Info( + url: URL(staticString: "file:///tmp/note.jot"), + name: "note", + modificationDate: nil, + ubiquitousInfo: nil + ) + let imageData = try XCTUnwrap(UIImage(systemName: "doc")?.pngData()) + let jotFileConflictServiceMock = JotFileConflictServiceMock( + copyVersionToTemporaryProvider: { _, _ in nil } + ) + let jotFilePreviewImageServiceMock = JotFilePreviewImageServiceMock( + getPreviewImageDataProvider: { receivedInfo, receivedStyle, receivedScale in + // Then + XCTAssertEqual(receivedInfo, originalInfo) + XCTAssertEqual(receivedStyle, .light) + XCTAssertEqual(receivedScale, 2.0) + getPreviewImageDataExpectation.fulfill() + return imageData + } + ) + let repository = JotConflictRepository( + jotFileConflictService: jotFileConflictServiceMock, + jotFilePreviewImageService: jotFilePreviewImageServiceMock + ) + + // When + let image = await repository.getPreviewImage( + jotFileInfo: originalInfo, + jotFileVersion: JotFileVersion(localizedNameOfSavingComputer: "Mac", info: originalInfo), + userInterfaceStyle: .light, + displayScale: 2.0 + ) + + // Then + XCTAssertNotNil(image) + await fulfillment(of: [getPreviewImageDataExpectation], timeout: 0.2) + } + + func test_getPreviewImage_givenCopyVersionToTemporaryReturnsInfo_usesTemporaryInfoAndCleansUp() async throws { + // Given + let getPreviewImageDataExpectation = XCTestExpectation( + description: "JotFilePreviewImageServiceMock.getPreviewImageDataProvider is called with temporary info." + ) + let originalInfo = JotFile.Info( + url: URL(staticString: "file:///tmp/note.jot"), + name: "note", + modificationDate: nil, + ubiquitousInfo: nil + ) + let temporaryURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString) + .appendingPathExtension("jot") + try Data().write(to: temporaryURL) + let temporaryInfo = JotFile.Info( + url: temporaryURL, + name: "tmp", + modificationDate: nil, + ubiquitousInfo: nil + ) + let imageData = try XCTUnwrap(UIImage(systemName: "doc")?.pngData()) + let jotFileConflictServiceMock = JotFileConflictServiceMock( + copyVersionToTemporaryProvider: { _, _ in temporaryInfo } + ) + let jotFilePreviewImageServiceMock = JotFilePreviewImageServiceMock( + getPreviewImageDataProvider: { receivedInfo, _, _ in + // Then + XCTAssertEqual(receivedInfo, temporaryInfo) + getPreviewImageDataExpectation.fulfill() + return imageData + } + ) + let repository = JotConflictRepository( + jotFileConflictService: jotFileConflictServiceMock, + jotFilePreviewImageService: jotFilePreviewImageServiceMock + ) + + // When + let image = await repository.getPreviewImage( + jotFileInfo: originalInfo, + jotFileVersion: JotFileVersion(localizedNameOfSavingComputer: "Mac", info: originalInfo), + userInterfaceStyle: .light, + displayScale: 2.0 + ) + + // Then + XCTAssertNotNil(image) + XCTAssertFalse(FileManager.default.fileExists(atPath: temporaryURL.path)) + await fulfillment(of: [getPreviewImageDataExpectation], timeout: 0.2) + } + + func test_getPreviewImage_givenPreviewImageServiceThrows_returnsNil() async throws { + // Given + let originalInfo = JotFile.Info( + url: URL(staticString: "file:///tmp/note.jot"), + name: "note", + modificationDate: nil, + ubiquitousInfo: nil + ) + let repository = JotConflictRepository( + jotFileConflictService: JotFileConflictServiceMock(), + jotFilePreviewImageService: JotFilePreviewImageServiceMock( + getPreviewImageDataProvider: { _, _, _ in + throw NSError(domain: "test", code: 0) + } + ) + ) + + // When + let image = await repository.getPreviewImage( + jotFileInfo: originalInfo, + jotFileVersion: JotFileVersion(localizedNameOfSavingComputer: "Mac", info: originalInfo), + userInterfaceStyle: .light, + displayScale: 2.0 + ) + + // Then + XCTAssertNil(image) + } +} diff --git a/Tests/JotConflictPage/JotConflictViewModelTests.swift b/Tests/JotConflictPage/JotConflictViewModelTests.swift new file mode 100644 index 0000000..60688dd --- /dev/null +++ b/Tests/JotConflictPage/JotConflictViewModelTests.swift @@ -0,0 +1,159 @@ +/* + Jottre: Minimalistic jotting for iPhone, iPad and Mac. + Copyright (C) 2021-2026 Anton Lorani + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +import XCTest + +@testable import Jottre + +@MainActor +final class JotConflictViewModelTests: XCTestCase { + + func test_items_givenOneVersion_yieldsHeaderAndDeviceVersionAndProvidedVersion() async throws { + // Given + let info = JotFile.Info( + url: URL(staticString: "file:///tmp/note.jot"), + name: "note", + modificationDate: nil, + ubiquitousInfo: nil + ) + let version = JotFileVersion(localizedNameOfSavingComputer: "Mac", info: info) + let viewModel = JotConflictViewModel( + jotFileInfo: info, + jotFileVersions: [version], + repository: JotConflictRepositoryMock(), + coordinator: JotConflictCoordinatorMock(), + onResult: { _ in } + ) + + // When + let items = try await firstValue(of: viewModel.items) + + // Then + XCTAssertEqual(items.count, 3) + } + + func test_actions_givenTwoVersions_yieldsThreeActions() { + // Given + let info = JotFile.Info( + url: URL(staticString: "file:///tmp/note.jot"), + name: "note", + modificationDate: nil, + ubiquitousInfo: nil + ) + let viewModel = JotConflictViewModel( + jotFileInfo: info, + jotFileVersions: [ + JotFileVersion(localizedNameOfSavingComputer: "Mac", info: info), + JotFileVersion(localizedNameOfSavingComputer: "iPad", info: info), + ], + repository: JotConflictRepositoryMock(), + coordinator: JotConflictCoordinatorMock(), + onResult: { _ in } + ) + + // Then: 1 device version + 2 provided versions + keep-all = 4 + XCTAssertEqual(viewModel.actions.count, 4) + } + + func test_actions_givenKeepAllTap_resolvesAllAndDismissesWithKeepAllResult() { + // Given + let resolveExpectation = XCTestExpectation(description: "Repository.resolveVersionConflicts is called.") + let dismissExpectation = XCTestExpectation(description: "Coordinator.dismiss is called.") + let onResultExpectation = XCTestExpectation(description: "onResult is called with .keepAll.") + let info = JotFile.Info( + url: URL(staticString: "file:///tmp/note.jot"), + name: "note", + modificationDate: nil, + ubiquitousInfo: nil + ) + let coordinator = JotConflictCoordinatorMock( + dismissProvider: { completion in + dismissExpectation.fulfill() + completion() + } + ) + let viewModel = JotConflictViewModel( + jotFileInfo: info, + jotFileVersions: [ + JotFileVersion(localizedNameOfSavingComputer: "Mac", info: info) + ], + repository: JotConflictRepositoryMock( + resolveVersionConflictsProvider: { _, resolved in + XCTAssertEqual(resolved.count, 2) + resolveExpectation.fulfill() + } + ), + coordinator: coordinator, + onResult: { result in + if case .keepAll = result { + onResultExpectation.fulfill() + } + } + ) + + // When (last action is keepAll) + viewModel.actions.last?.action() + + // Then + wait(for: [resolveExpectation, dismissExpectation, onResultExpectation], timeout: 1) + } + + func test_actions_givenKeepVersionThrows_invokesShowInfoAlert() async { + // Given + let alertExpectation = XCTestExpectation(description: "Coordinator.showInfoAlert is called.") + let info = JotFile.Info( + url: URL(staticString: "file:///tmp/note.jot"), + name: "note", + modificationDate: nil, + ubiquitousInfo: nil + ) + let coordinator = JotConflictCoordinatorMock( + showInfoAlertProvider: { _, _ in alertExpectation.fulfill() } + ) + let viewModel = JotConflictViewModel( + jotFileInfo: info, + jotFileVersions: [ + JotFileVersion(localizedNameOfSavingComputer: "Mac", info: info) + ], + repository: JotConflictRepositoryMock( + resolveVersionConflictsProvider: { _, _ in + throw NSError(domain: "test", code: 0) + } + ), + coordinator: coordinator, + onResult: { _ in } + ) + + // When (first action is keep version) + viewModel.actions.first?.action() + + // Then + await fulfillment(of: [alertExpectation], timeout: 1) + } +} + +@MainActor +private func firstValue( + of sequence: S +) async throws -> S.Element where S.Element: Sendable { + var iterator = sequence.makeAsyncIterator() + guard let value = try await iterator.next() else { + throw NSError(domain: "JotConflictViewModelTests", code: 0) + } + return value +} diff --git a/Tests/JotConflictPage/JotFileConflictServiceTests.swift b/Tests/JotConflictPage/JotFileConflictServiceTests.swift new file mode 100644 index 0000000..4faa46c --- /dev/null +++ b/Tests/JotConflictPage/JotFileConflictServiceTests.swift @@ -0,0 +1,148 @@ +/* + Jottre: Minimalistic jotting for iPhone, iPad and Mac. + Copyright (C) 2021-2026 Anton Lorani + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +import XCTest + +@testable import Jottre + +final class JotFileConflictServiceTests: XCTestCase { + + func test_getConfictingVersions_givenNilFromUnderlyingService_returnsNil() { + // Given + let service = JotFileConflictService( + fileConflictService: FileConflictServiceMock( + getConflictingVersionsProvider: { _ in nil } + ) + ) + + // When + let result = service.getConfictingVersions(jotFileInfo: makeJotFileInfo()) + + // Then + XCTAssertNil(result) + } + + func test_getConfictingVersions_givenEmptyArrayFromUnderlyingService_returnsNil() { + // Given + let service = JotFileConflictService( + fileConflictService: FileConflictServiceMock( + getConflictingVersionsProvider: { _ in [] } + ) + ) + + // When + let result = service.getConfictingVersions(jotFileInfo: makeJotFileInfo()) + + // Then + XCTAssertNil(result) + } + + func test_resolveVersionConflicts_forwardsURLsToUnderlyingService() async throws { + // Given + let resolveVersionConflictsExpectation = XCTestExpectation( + description: "FileConflictServiceMock.resolveVersionConflictsProvider is called." + ) + let inputInfo = makeJotFileInfo() + let versionURLA = URL(staticString: "file:///tmp/version-a.jot") + let versionURLB = URL(staticString: "file:///tmp/version-b.jot") + let versionInfos = [ + JotFileVersion( + localizedNameOfSavingComputer: "A", + info: JotFile.Info(url: versionURLA, name: "a", modificationDate: nil, ubiquitousInfo: nil) + ), + JotFileVersion( + localizedNameOfSavingComputer: "B", + info: JotFile.Info(url: versionURLB, name: "b", modificationDate: nil, ubiquitousInfo: nil) + ), + ] + let fileConflictServiceMock = FileConflictServiceMock( + resolveVersionConflictsProvider: { receivedFileURL, receivedResolvedVersions in + // Then + XCTAssertEqual(receivedFileURL, inputInfo.url) + XCTAssertEqual(receivedResolvedVersions, [versionURLA, versionURLB]) + resolveVersionConflictsExpectation.fulfill() + } + ) + let service = JotFileConflictService(fileConflictService: fileConflictServiceMock) + + // When + try service.resolveVersionConflicts(jotFileInfo: inputInfo, resolvedVersions: versionInfos) + + // Then + await fulfillment(of: [resolveVersionConflictsExpectation], timeout: 0.2) + } + + func test_copyVersionToTemporary_givenUnderlyingReturnsURL_returnsInfoWithTemporaryURLAndOriginalMetadata() throws { + // Given + let temporaryURL = URL(staticString: "file:///tmp/copy.jot") + let fileConflictServiceMock = FileConflictServiceMock( + copyVersionToTemporaryProvider: { _, _ in temporaryURL } + ) + let service = JotFileConflictService(fileConflictService: fileConflictServiceMock) + let modificationDate = Date(timeIntervalSince1970: 1_000) + let versionInfo = JotFile.Info( + url: URL(staticString: "file:///cloud/version.jot"), + name: "version", + modificationDate: modificationDate, + ubiquitousInfo: UbiquitousInfo(downloadStatus: .current, isDownloading: false) + ) + let jotFileVersion = JotFileVersion(localizedNameOfSavingComputer: "Mac", info: versionInfo) + + // When + let result = try service.copyVersionToTemporary( + jotFileInfo: makeJotFileInfo(), + jotFileVersion: jotFileVersion + ) + + // Then + let unwrapped = try XCTUnwrap(result) + XCTAssertEqual(unwrapped.url, temporaryURL) + XCTAssertEqual(unwrapped.name, versionInfo.name) + XCTAssertEqual(unwrapped.modificationDate, modificationDate) + XCTAssertEqual(unwrapped.ubiquitousInfo, versionInfo.ubiquitousInfo) + } + + func test_copyVersionToTemporary_givenUnderlyingReturnsNil_returnsNil() throws { + // Given + let service = JotFileConflictService( + fileConflictService: FileConflictServiceMock(copyVersionToTemporaryProvider: { _, _ in nil }) + ) + let jotFileVersion = JotFileVersion( + localizedNameOfSavingComputer: nil, + info: makeJotFileInfo() + ) + + // When + let result = try service.copyVersionToTemporary( + jotFileInfo: makeJotFileInfo(), + jotFileVersion: jotFileVersion + ) + + // Then + XCTAssertNil(result) + } + + private func makeJotFileInfo() -> JotFile.Info { + JotFile.Info( + url: URL(staticString: "file:///cloud/note.jot"), + name: "note", + modificationDate: nil, + ubiquitousInfo: UbiquitousInfo(downloadStatus: .current, isDownloading: false) + ) + } +} diff --git a/Tests/JotFilePreview/CachedJotFilePreviewImageServiceTests.swift b/Tests/JotFilePreview/CachedJotFilePreviewImageServiceTests.swift new file mode 100644 index 0000000..a6ae4f0 --- /dev/null +++ b/Tests/JotFilePreview/CachedJotFilePreviewImageServiceTests.swift @@ -0,0 +1,206 @@ +/* + Jottre: Minimalistic jotting for iPhone, iPad and Mac. + Copyright (C) 2021-2026 Anton Lorani + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +import XCTest + +@testable import Jottre + +final class CachedJotFilePreviewImageServiceTests: XCTestCase { + + private let info = JotFile.Info( + url: URL(staticString: "file:///tmp/note.jot"), + name: "note", + modificationDate: Date(timeIntervalSince1970: 1_000), + ubiquitousInfo: nil + ) + + func test_getPreviewImageData_givenColdCache_callsUnderlyingServiceAndReturnsItsData() async throws { + // Given + let underlyingExpectation = XCTestExpectation(description: "Underlying service is called.") + let expectedData = Data([1, 2, 3]) + let underlying = JotFilePreviewImageServiceMock( + getPreviewImageDataProvider: { _, _, _ in + underlyingExpectation.fulfill() + return expectedData + } + ) + let service = CachedJotFilePreviewImageService( + localFileService: FileServiceMock( + readFileProvider: { _ in throw NSError(domain: "noDiskCache", code: 0) } + ), + jotFilePreviewImageService: underlying + ) + + // When + let data = try await service.getPreviewImageData( + jotFileInfo: info, + userInterfaceStyle: .light, + displayScale: 2.0 + ) + + // Then + XCTAssertEqual(data, expectedData) + await fulfillment(of: [underlyingExpectation], timeout: 0.5) + } + + func test_getPreviewImageData_givenSecondCallWithSameKey_servesFromMemoryCache() async throws { + // Given + let underlyingCallCount = LockIsolated(0) + let expectedData = Data([9, 8, 7]) + let underlying = JotFilePreviewImageServiceMock( + getPreviewImageDataProvider: { _, _, _ in + underlyingCallCount.withValue { $0 += 1 } + return expectedData + } + ) + let service = CachedJotFilePreviewImageService( + localFileService: FileServiceMock( + readFileProvider: { _ in throw NSError(domain: "noDiskCache", code: 0) } + ), + jotFilePreviewImageService: underlying + ) + + // When + _ = try await service.getPreviewImageData(jotFileInfo: info, userInterfaceStyle: .light, displayScale: 2.0) + let second = try await service.getPreviewImageData( + jotFileInfo: info, + userInterfaceStyle: .light, + displayScale: 2.0 + ) + + // Then + XCTAssertEqual(second, expectedData) + XCTAssertEqual(underlyingCallCount.value, 1) + } + + func test_getPreviewImageData_givenColdCache_writesThroughToDiskCache() async throws { + // Given + let writeFileExpectation = XCTestExpectation(description: "FileServiceMock.writeFileProvider is called.") + let producedData = Data([4, 5, 6]) + let writtenURL = LockIsolated(nil) + let writtenData = LockIsolated(nil) + let service = CachedJotFilePreviewImageService( + localFileService: FileServiceMock( + temporaryDirectoryProvider: { + FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + }, + readFileProvider: { _ in throw NSError(domain: "noDiskCache", code: 0) }, + writeFileProvider: { receivedURL, receivedData in + writtenURL.withValue { $0 = receivedURL } + writtenData.withValue { $0 = receivedData } + writeFileExpectation.fulfill() + } + ), + jotFilePreviewImageService: JotFilePreviewImageServiceMock( + getPreviewImageDataProvider: { _, _, _ in producedData } + ) + ) + + // When + let data = try await service.getPreviewImageData( + jotFileInfo: info, + userInterfaceStyle: .light, + displayScale: 2.0 + ) + + // Then + XCTAssertEqual(data, producedData) + await fulfillment(of: [writeFileExpectation], timeout: 0.5) + XCTAssertEqual(writtenData.value, producedData) + XCTAssertNotNil(writtenURL.value) + } + + func test_getPreviewImageData_givenJotFileInfoWithoutModificationDate_skipsDiskCache() async throws { + // Given + let readFileExpectation = XCTestExpectation(description: "FileServiceMock.readFileProvider is not called.") + readFileExpectation.isInverted = true + let writeFileExpectation = XCTestExpectation(description: "FileServiceMock.writeFileProvider is not called.") + writeFileExpectation.isInverted = true + let undatedInfo = JotFile.Info( + url: URL(staticString: "file:///tmp/note.jot"), + name: "note", + modificationDate: nil, + ubiquitousInfo: nil + ) + let service = CachedJotFilePreviewImageService( + localFileService: FileServiceMock( + readFileProvider: { _ in + readFileExpectation.fulfill() + return Data() + }, + writeFileProvider: { _, _ in writeFileExpectation.fulfill() } + ), + jotFilePreviewImageService: JotFilePreviewImageServiceMock( + getPreviewImageDataProvider: { _, _, _ in Data([1]) } + ) + ) + + // When + _ = try await service.getPreviewImageData( + jotFileInfo: undatedInfo, + userInterfaceStyle: .light, + displayScale: 2.0 + ) + + // Then + await fulfillment(of: [readFileExpectation, writeFileExpectation], timeout: 0.2) + } + + func test_getPreviewImageData_givenDiskCacheHit_skipsUnderlyingService() async throws { + // Given + let cachedData = Data([42]) + let underlying = JotFilePreviewImageServiceMock( + getPreviewImageDataProvider: { _, _, _ in + XCTFail("Underlying service should not be called when the disk cache is warm.") + return Data() + } + ) + let service = CachedJotFilePreviewImageService( + localFileService: FileServiceMock( + readFileProvider: { _ in cachedData } + ), + jotFilePreviewImageService: underlying + ) + + // When + let data = try await service.getPreviewImageData( + jotFileInfo: info, + userInterfaceStyle: .dark, + displayScale: 3.0 + ) + + // Then + XCTAssertEqual(data, cachedData) + } +} + +final class LockIsolated: @unchecked Sendable { + private let lock = NSLock() + private var _value: Value + + init(_ value: Value) { _value = value } + + var value: Value { + lock.withLock { _value } + } + + func withValue(_ work: (inout Value) -> Void) { + lock.withLock { work(&_value) } + } +} diff --git a/Tests/JotsPage/CreateJotCoordinatorTests.swift b/Tests/JotsPage/CreateJotCoordinatorTests.swift new file mode 100644 index 0000000..9633804 --- /dev/null +++ b/Tests/JotsPage/CreateJotCoordinatorTests.swift @@ -0,0 +1,78 @@ +/* + Jottre: Minimalistic jotting for iPhone, iPad and Mac. + Copyright (C) 2021-2026 Anton Lorani + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +import UIKit +import XCTest + +@testable import Jottre + +@MainActor +final class CreateJotCoordinatorTests: XCTestCase { + + func test_start_givenInvoked_presentsAlertWithTitleAndCreateAndCancelActions() { + // Given + let presentExpectation = XCTestExpectation(description: "Navigation.present is called.") + let navigation = Navigation.test( + presentViewControllerProvider: { viewController, _ in + MainActor.assumeIsolated { + let alert = viewController as? UIAlertController + XCTAssertNotNil(alert) + XCTAssertEqual(alert?.actions.count, 2) + XCTAssertEqual(alert?.actions.last?.style, .cancel) + presentExpectation.fulfill() + } + } + ) + let coordinator = CreateJotCoordinator( + navigation: navigation, + repository: CreateJotRepositoryMock() + ) + + // When + coordinator.start() + + // Then + wait(for: [presentExpectation], timeout: 1) + } + + func test_start_givenCancelTapped_invokesOnEnd() { + // Given + let onEndExpectation = XCTestExpectation(description: "CreateJotCoordinator.onEnd is invoked.") + var alertController: UIAlertController? + let navigation = Navigation.test( + presentViewControllerProvider: { viewController, _ in + MainActor.assumeIsolated { + alertController = viewController as? UIAlertController + } + } + ) + let coordinator = CreateJotCoordinator( + navigation: navigation, + repository: CreateJotRepositoryMock() + ) + coordinator.onEnd = { onEndExpectation.fulfill() } + + // When + coordinator.start() + let cancelAction = alertController?.actions.first { $0.style == .cancel } + cancelAction?.invokeHandler() + + // Then + wait(for: [onEndExpectation], timeout: 1) + } +} diff --git a/Tests/JotsPage/CreateJotRepositoryTests.swift b/Tests/JotsPage/CreateJotRepositoryTests.swift new file mode 100644 index 0000000..9923920 --- /dev/null +++ b/Tests/JotsPage/CreateJotRepositoryTests.swift @@ -0,0 +1,142 @@ +/* + Jottre: Minimalistic jotting for iPhone, iPad and Mac. + Copyright (C) 2021-2026 Anton Lorani + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +import XCTest + +@testable import Jottre + +final class CreateJotRepositoryTests: XCTestCase { + + func test_createJot_givenUbiquitousDocumentsDirectoryAvailable_writesToUbiquitousService() async throws { + // Given + let writeProviderExpectation = XCTestExpectation(description: "JotFileServiceMock.writeProvider is called.") + let ubiquitousDocumentsDirectory = URL(staticString: "file:///cloud/Documents/") + let jotFileServiceMock = JotFileServiceMock( + writeProvider: { jotFile in + // Then + XCTAssertEqual(jotFile.info.name, "note") + XCTAssertNotNil(jotFile.info.ubiquitousInfo) + XCTAssertEqual( + jotFile.info.url, + ubiquitousDocumentsDirectory.appendingPathComponent("note", isDirectory: false) + .appendingPathExtension("jot") + ) + writeProviderExpectation.fulfill() + } + ) + let repository = CreateJotRepository( + localFileService: FileServiceMock( + documentsDirectoryProvider: { + XCTFail("Local file service should not be used when ubiquitous is available.") + return nil + } + ), + ubiquitousFileService: FileServiceMock( + documentsDirectoryProvider: { ubiquitousDocumentsDirectory } + ), + jotFileService: jotFileServiceMock + ) + + // When + let info = try await repository.createJot(name: "note") + + // Then + XCTAssertNotNil(info.ubiquitousInfo) + await fulfillment(of: [writeProviderExpectation], timeout: 0.2) + } + + func test_createJot_givenOnlyLocalDocumentsDirectoryAvailable_writesToLocalService() async throws { + // Given + let writeProviderExpectation = XCTestExpectation(description: "JotFileServiceMock.writeProvider is called.") + let localDocumentsDirectory = URL(staticString: "file:///local/Documents/") + let jotFileServiceMock = JotFileServiceMock( + writeProvider: { jotFile in + // Then + XCTAssertNil(jotFile.info.ubiquitousInfo) + XCTAssertEqual( + jotFile.info.url, + localDocumentsDirectory.appendingPathComponent("note", isDirectory: false).appendingPathExtension( + "jot" + ) + ) + writeProviderExpectation.fulfill() + } + ) + let repository = CreateJotRepository( + localFileService: FileServiceMock( + documentsDirectoryProvider: { localDocumentsDirectory } + ), + ubiquitousFileService: FileServiceMock( + documentsDirectoryProvider: { nil } + ), + jotFileService: jotFileServiceMock + ) + + // When + let info = try await repository.createJot(name: "note") + + // Then + XCTAssertNil(info.ubiquitousInfo) + await fulfillment(of: [writeProviderExpectation], timeout: 0.2) + } + + func test_createJot_givenNoDocumentsDirectoryAvailable_throwsCouldNotCreateFile() async { + // Given + let repository = CreateJotRepository( + localFileService: FileServiceMock(documentsDirectoryProvider: { nil }), + ubiquitousFileService: FileServiceMock(documentsDirectoryProvider: { nil }), + jotFileService: JotFileServiceMock() + ) + + // When / Then + do { + _ = try await repository.createJot(name: "note") + XCTFail("Expected CreateJotRepository.Failure.couldNotCreateFile.") + } catch CreateJotRepository.Failure.couldNotCreateFile { + // Expected + } catch { + XCTFail("Unexpected error: \(error)") + } + } + + func test_createJot_givenFileAlreadyExists_throwsFileExists() async { + // Given + let repository = CreateJotRepository( + localFileService: FileServiceMock(documentsDirectoryProvider: { nil }), + ubiquitousFileService: FileServiceMock( + documentsDirectoryProvider: { URL(staticString: "file:///cloud/Documents/") }, + fileExistsProvider: { _ in true } + ), + jotFileService: JotFileServiceMock( + writeProvider: { _ in + XCTFail("Should not write when file already exists.") + } + ) + ) + + // When / Then + do { + _ = try await repository.createJot(name: "note") + XCTFail("Expected CreateJotRepository.Failure.fileExists.") + } catch CreateJotRepository.Failure.fileExists { + // Expected + } catch { + XCTFail("Unexpected error: \(error)") + } + } +} diff --git a/Tests/JotsPage/DeleteJotCoordinatorTests.swift b/Tests/JotsPage/DeleteJotCoordinatorTests.swift new file mode 100644 index 0000000..d5a3bd6 --- /dev/null +++ b/Tests/JotsPage/DeleteJotCoordinatorTests.swift @@ -0,0 +1,97 @@ +/* + Jottre: Minimalistic jotting for iPhone, iPad and Mac. + Copyright (C) 2021-2026 Anton Lorani + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +import UIKit +import XCTest + +@testable import Jottre + +@MainActor +final class DeleteJotCoordinatorTests: XCTestCase { + + func test_start_givenInvoked_presentsAlertWithDestructiveDeleteAction() { + // Given + let presentExpectation = XCTestExpectation(description: "Navigation.present is called.") + let info = JotFile.Info( + url: URL(staticString: "file:///tmp/note.jot"), + name: "note", + modificationDate: nil, + ubiquitousInfo: nil + ) + let navigation = Navigation.test( + presentViewControllerProvider: { viewController, _ in + MainActor.assumeIsolated { + let alert = viewController as? UIAlertController + XCTAssertNotNil(alert) + XCTAssertEqual(alert?.actions.count, 2) + XCTAssertEqual(alert?.actions.last?.style, .destructive) + presentExpectation.fulfill() + } + } + ) + let coordinator = DeleteJotCoordinator( + jotFileInfo: info, + navigation: navigation, + repository: DeleteJotRepositoryMock() + ) + + // When + coordinator.start() + + // Then + wait(for: [presentExpectation], timeout: 1) + } + + func test_start_givenDeleteActionTapped_invokesRepositoryDeleteAndDismisses() { + // Given + let deleteExpectation = XCTestExpectation(description: "Repository.deleteJot is called.") + let dismissExpectation = XCTestExpectation(description: "Navigation.dismiss is called.") + let info = JotFile.Info( + url: URL(staticString: "file:///tmp/note.jot"), + name: "note", + modificationDate: nil, + ubiquitousInfo: nil + ) + var alertController: UIAlertController? + let navigation = Navigation.test( + presentViewControllerProvider: { viewController, _ in + MainActor.assumeIsolated { + alertController = viewController as? UIAlertController + } + }, + dismissViewControllerProvider: { _, _ in + dismissExpectation.fulfill() + } + ) + let coordinator = DeleteJotCoordinator( + jotFileInfo: info, + navigation: navigation, + repository: DeleteJotRepositoryMock( + deleteJotProvider: { _ in deleteExpectation.fulfill() } + ) + ) + + // When + coordinator.start() + let destructive = alertController?.actions.first { $0.style == .destructive } + destructive?.invokeHandler() + + // Then + wait(for: [deleteExpectation, dismissExpectation], timeout: 1) + } +} diff --git a/Tests/JotsPage/DeleteJotRepositoryTests.swift b/Tests/JotsPage/DeleteJotRepositoryTests.swift new file mode 100644 index 0000000..d9e220d --- /dev/null +++ b/Tests/JotsPage/DeleteJotRepositoryTests.swift @@ -0,0 +1,49 @@ +/* + Jottre: Minimalistic jotting for iPhone, iPad and Mac. + Copyright (C) 2021-2026 Anton Lorani + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +import XCTest + +@testable import Jottre + +final class DeleteJotRepositoryTests: XCTestCase { + + func test_deleteJot_forwardsToJotFileServiceRemove() async throws { + // Given + let removeProviderExpectation = XCTestExpectation(description: "JotFileServiceMock.removeProvider is called.") + let info = JotFile.Info( + url: URL(staticString: "file:///tmp/note.jot"), + name: "note", + modificationDate: nil, + ubiquitousInfo: nil + ) + let jotFileServiceMock = JotFileServiceMock( + removeProvider: { receivedInfo in + // Then + XCTAssertEqual(receivedInfo, info) + removeProviderExpectation.fulfill() + } + ) + let repository = DeleteJotRepository(jotFileService: jotFileServiceMock) + + // When + try repository.deleteJot(jotFileInfo: info) + + // Then + await fulfillment(of: [removeProviderExpectation], timeout: 0.2) + } +} diff --git a/Tests/JotsPage/EmptyStateCellViewModelTests.swift b/Tests/JotsPage/EmptyStateCellViewModelTests.swift new file mode 100644 index 0000000..8f7f0ec --- /dev/null +++ b/Tests/JotsPage/EmptyStateCellViewModelTests.swift @@ -0,0 +1,47 @@ +/* + Jottre: Minimalistic jotting for iPhone, iPad and Mac. + Copyright (C) 2021-2026 Anton Lorani + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +import XCTest + +@testable import Jottre + +@MainActor +final class EmptyStateCellViewModelTests: XCTestCase { + + func test_init_storesTitle() { + // When + let viewModel = EmptyStateCellViewModel(title: "Nothing here yet") + + // Then + XCTAssertEqual(viewModel.title, "Nothing here yet") + } + + func test_handleContextMenuConfiguration_returnsNilByDefault() { + // Given + let viewModel = EmptyStateCellViewModel(title: "irrelevant") + + // When + let configuration = viewModel.handleContextMenuConfiguration( + point: .zero, + sourceView: UIView() + ) + + // Then + XCTAssertNil(configuration) + } +} diff --git a/Tests/JotsPage/JotBusinessModelTests.swift b/Tests/JotsPage/JotBusinessModelTests.swift new file mode 100644 index 0000000..bd788d3 --- /dev/null +++ b/Tests/JotsPage/JotBusinessModelTests.swift @@ -0,0 +1,93 @@ +/* + Jottre: Minimalistic jotting for iPhone, iPad and Mac. + Copyright (C) 2021-2026 Anton Lorani + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +import XCTest + +@testable import Jottre + +final class JotBusinessModelTests: XCTestCase { + + func test_init_givenLocalJotFileInfo_isDownloadedTrueAndIsDownloadingFalse() { + // Given + let jotFileInfo = JotFile.Info( + url: URL(staticString: "file:///tmp/note.jot"), + name: "note", + modificationDate: nil, + ubiquitousInfo: nil + ) + + // When + let model = JotBusinessModel(jotFileInfo: jotFileInfo) + + // Then + XCTAssertEqual(model.name, "note") + XCTAssertTrue(model.isDownloaded) + XCTAssertFalse(model.isDownloading) + } + + func test_init_givenUbiquitousInfoWithNotDownloaded_isDownloadedFalse() { + // Given + let jotFileInfo = JotFile.Info( + url: URL(staticString: "file:///cloud/note.jot"), + name: "note", + modificationDate: nil, + ubiquitousInfo: UbiquitousInfo(downloadStatus: .notDownloaded, isDownloading: true) + ) + + // When + let model = JotBusinessModel(jotFileInfo: jotFileInfo) + + // Then + XCTAssertFalse(model.isDownloaded) + XCTAssertTrue(model.isDownloading) + } + + func test_init_givenUbiquitousInfoWithDownloaded_isDownloadedTrue() { + // Given + let jotFileInfo = JotFile.Info( + url: URL(staticString: "file:///cloud/note.jot"), + name: "note", + modificationDate: nil, + ubiquitousInfo: UbiquitousInfo(downloadStatus: .downloaded, isDownloading: false) + ) + + // When + let model = JotBusinessModel(jotFileInfo: jotFileInfo) + + // Then + XCTAssertTrue(model.isDownloaded) + XCTAssertFalse(model.isDownloading) + } + + func test_toJotFileInfo_returnsOriginalInfo() { + // Given + let jotFileInfo = JotFile.Info( + url: URL(staticString: "file:///tmp/note.jot"), + name: "note", + modificationDate: nil, + ubiquitousInfo: nil + ) + let model = JotBusinessModel(jotFileInfo: jotFileInfo) + + // When + let result = model.toJotFileInfo() + + // Then + XCTAssertEqual(result, jotFileInfo) + } +} diff --git a/Tests/JotsPage/JotCellViewModelTests.swift b/Tests/JotsPage/JotCellViewModelTests.swift new file mode 100644 index 0000000..32a7589 --- /dev/null +++ b/Tests/JotsPage/JotCellViewModelTests.swift @@ -0,0 +1,156 @@ +/* + Jottre: Minimalistic jotting for iPhone, iPad and Mac. + Copyright (C) 2021-2026 Anton Lorani + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +import UIKit +import XCTest + +@testable import Jottre + +@MainActor +final class JotCellViewModelTests: XCTestCase { + + func test_init_givenIsDownloadingTrue_setsPreviewToLoadingIndicator() { + // Given + let jotFileInfo = JotFile.Info( + url: URL(staticString: "file:///cloud/note.jot"), + name: "note", + modificationDate: nil, + ubiquitousInfo: UbiquitousInfo(downloadStatus: .notDownloaded, isDownloading: true) + ) + let businessModel = JotBusinessModel(jotFileInfo: jotFileInfo) + + // When + let viewModel = JotCellViewModel( + jot: businessModel, + jotMenuConfigurations: makeEmptyJotMenuConfigurations(), + repository: JotsRepositoryMock(), + onAction: {} + ) + + // Then + XCTAssertEqual(viewModel.preview, .loadingIndicator) + XCTAssertEqual(viewModel.name, "note") + } + + func test_init_givenDownloadedAndNotDownloading_setsPreviewToThumbnail() { + // Given + let jotFileInfo = JotFile.Info( + url: URL(staticString: "file:///cloud/note.jot"), + name: "note", + modificationDate: nil, + ubiquitousInfo: UbiquitousInfo(downloadStatus: .current, isDownloading: false) + ) + + // When + let viewModel = JotCellViewModel( + jot: JotBusinessModel(jotFileInfo: jotFileInfo), + jotMenuConfigurations: makeEmptyJotMenuConfigurations(), + repository: JotsRepositoryMock(), + onAction: {} + ) + + // Then + XCTAssertEqual(viewModel.preview, .thumbnail) + } + + func test_init_givenNotDownloadedAndNotDownloading_setsPreviewToCloudImage() { + // Given + let jotFileInfo = JotFile.Info( + url: URL(staticString: "file:///cloud/note.jot"), + name: "note", + modificationDate: nil, + ubiquitousInfo: UbiquitousInfo(downloadStatus: .notDownloaded, isDownloading: false) + ) + + // When + let viewModel = JotCellViewModel( + jot: JotBusinessModel(jotFileInfo: jotFileInfo), + jotMenuConfigurations: makeEmptyJotMenuConfigurations(), + repository: JotsRepositoryMock(), + onAction: {} + ) + + // Then + XCTAssertEqual(viewModel.preview, .cloudImage) + } + + func test_handleAction_givenTap_invokesOnAction() async { + // Given + let onActionExpectation = XCTestExpectation(description: "onAction is called.") + let jotFileInfo = JotFile.Info( + url: URL(staticString: "file:///tmp/note.jot"), + name: "note", + modificationDate: nil, + ubiquitousInfo: nil + ) + let viewModel = JotCellViewModel( + jot: JotBusinessModel(jotFileInfo: jotFileInfo), + jotMenuConfigurations: makeEmptyJotMenuConfigurations(), + repository: JotsRepositoryMock(), + onAction: { onActionExpectation.fulfill() } + ) + + // When + viewModel.handle(action: .tap) + + // Then + await fulfillment(of: [onActionExpectation], timeout: 0.2) + } + + func test_getPreviewImage_forwardsToRepositoryWithJotFileInfo() async throws { + // Given + let getPreviewImageProviderExpectation = XCTestExpectation( + description: "JotsRepositoryMock.getPreviewImageProvider is called." + ) + let expectedImage = UIImage() + let expectedFileURL = URL(staticString: "file:///tmp/note.jot") + let jotFileInfo = JotFile.Info( + url: expectedFileURL, + name: "note", + modificationDate: nil, + ubiquitousInfo: nil + ) + let repositoryMock = JotsRepositoryMock( + getPreviewImageProvider: { receivedInfo, receivedStyle, receivedScale in + // Then + XCTAssertEqual(receivedInfo.url, expectedFileURL) + XCTAssertEqual(receivedStyle, .dark) + XCTAssertEqual(receivedScale, 3.0) + getPreviewImageProviderExpectation.fulfill() + return expectedImage + } + ) + let viewModel = JotCellViewModel( + jot: JotBusinessModel(jotFileInfo: jotFileInfo), + jotMenuConfigurations: makeEmptyJotMenuConfigurations(), + repository: repositoryMock, + onAction: {} + ) + + // When + let image = await viewModel.getPreviewImage(userInterfaceStyle: .dark, displayScale: 3.0) + + // Then + XCTAssertIdentical(image, expectedImage) + await fulfillment(of: [getPreviewImageProviderExpectation], timeout: 0.2) + } + + private func makeEmptyJotMenuConfigurations() -> JotMenuConfigurations { + JotMenuConfigurations { _ in [] } + } +} diff --git a/Tests/JotsPage/JotMenuConfigurationFactoryTests.swift b/Tests/JotsPage/JotMenuConfigurationFactoryTests.swift new file mode 100644 index 0000000..fde30cc --- /dev/null +++ b/Tests/JotsPage/JotMenuConfigurationFactoryTests.swift @@ -0,0 +1,151 @@ +/* + Jottre: Minimalistic jotting for iPhone, iPad and Mac. + Copyright (C) 2021-2026 Anton Lorani + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +import XCTest + +@testable import Jottre + +@MainActor +final class JotMenuConfigurationFactoryTests: XCTestCase { + + func test_make_givenNoOpenInNewWindow_producesRenameDuplicateDeleteRevealAndShareGroup() { + // Given + let factory = JotMenuConfigurationFactory() + + // When + let configurations = factory.make( + onShare: { _, _ in }, + onRename: {}, + onDuplicate: {}, + onDelete: {}, + onShowInFiles: {} + ) + let resolved = configurations.make(popoverAnchorProvider: { nil }) + + // Then + XCTAssertEqual(resolved.count, 5) + XCTAssertActionAt(resolved, index: 0, expectedSystemImage: "pencil") + XCTAssertActionAt(resolved, index: 1, expectedSystemImage: "plus.square.on.square") + XCTAssertActionAt(resolved, index: 2, expectedSystemImage: "trash", expectedDestructive: true) + XCTAssertActionAt(resolved, index: 3, expectedSystemImage: "folder") + XCTAssertGroupAt(resolved, index: 4, expectedActionCount: 3) + } + + func test_make_givenOpenInNewWindow_prependsOpenInNewWindowAction() { + // Given + let factory = JotMenuConfigurationFactory() + + // When + let configurations = factory.make( + onShare: { _, _ in }, + onRename: {}, + onDuplicate: {}, + onDelete: {}, + onShowInFiles: {}, + onOpenInNewWindow: {} + ) + let resolved = configurations.make(popoverAnchorProvider: { nil }) + + // Then + XCTAssertEqual(resolved.count, 6) + XCTAssertActionAt(resolved, index: 0, expectedSystemImage: "plus.app") + XCTAssertActionAt(resolved, index: 1, expectedSystemImage: "pencil") + } + + func test_make_actionHandlers_invokeCorrespondingClosures() async { + // Given + let onRenameExpectation = XCTestExpectation(description: "onRename is called.") + let onDuplicateExpectation = XCTestExpectation(description: "onDuplicate is called.") + let onDeleteExpectation = XCTestExpectation(description: "onDelete is called.") + let onShowInFilesExpectation = XCTestExpectation(description: "onShowInFiles is called.") + let onShareExpectation = XCTestExpectation(description: "onShare is called for each format.") + onShareExpectation.expectedFulfillmentCount = 3 + + let factory = JotMenuConfigurationFactory() + + // When + let configurations = factory.make( + onShare: { _, _ in onShareExpectation.fulfill() }, + onRename: { onRenameExpectation.fulfill() }, + onDuplicate: { onDuplicateExpectation.fulfill() }, + onDelete: { onDeleteExpectation.fulfill() }, + onShowInFiles: { onShowInFilesExpectation.fulfill() } + ) + let resolved = configurations.make(popoverAnchorProvider: { nil }) + + invokeAction(resolved[0]) + invokeAction(resolved[1]) + invokeAction(resolved[2]) + invokeAction(resolved[3]) + if case let .group(group) = resolved[4] { + for action in group.actions { + action.handler() + } + } + + // Then + await fulfillment( + of: [ + onRenameExpectation, + onDuplicateExpectation, + onDeleteExpectation, + onShowInFilesExpectation, + onShareExpectation, + ], + timeout: 0.2 + ) + } + + private func invokeAction(_ configuration: JotMenuConfiguration) { + if case let .action(action) = configuration { + action.handler() + } else { + XCTFail("Expected .action, got \(configuration).") + } + } + + private func XCTAssertActionAt( + _ configurations: [JotMenuConfiguration], + index: Int, + expectedSystemImage: String, + expectedDestructive: Bool = false, + file: StaticString = #filePath, + line: UInt = #line + ) { + guard case let .action(action) = configurations[index] else { + XCTFail("Expected .action at index \(index)", file: file, line: line) + return + } + XCTAssertEqual(action.systemImageName, expectedSystemImage, file: file, line: line) + XCTAssertEqual(action.isDestructive, expectedDestructive, file: file, line: line) + } + + private func XCTAssertGroupAt( + _ configurations: [JotMenuConfiguration], + index: Int, + expectedActionCount: Int, + file: StaticString = #filePath, + line: UInt = #line + ) { + guard case let .group(group) = configurations[index] else { + XCTFail("Expected .group at index \(index)", file: file, line: line) + return + } + XCTAssertEqual(group.actions.count, expectedActionCount, file: file, line: line) + } +} diff --git a/Tests/JotsPage/JotsCoordinatorTests.swift b/Tests/JotsPage/JotsCoordinatorTests.swift new file mode 100644 index 0000000..27af9f1 --- /dev/null +++ b/Tests/JotsPage/JotsCoordinatorTests.swift @@ -0,0 +1,331 @@ +/* + Jottre: Minimalistic jotting for iPhone, iPad and Mac. + Copyright (C) 2021-2026 Anton Lorani + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +import UIKit +import XCTest + +@testable import Jottre + +@MainActor +final class JotsCoordinatorTests: XCTestCase { + + func test_shouldHandle_givenAnyURL_returnsTrue() { + // Given + let coordinator = makeCoordinator() + + // Then + XCTAssertTrue(coordinator.shouldHandle(url: URL(staticString: "https://example.com"))) + } + + func test_handle_givenNonEditJotURL_returnsCachedJotsViewController() { + // Given + let madeViewController = UIViewController() + let coordinator = makeCoordinator( + jotsViewControllerFactory: JotsViewControllerFactoryMock( + makeProvider: { _ in madeViewController } + ) + ) + + // When + let viewControllers = coordinator.handle(url: URL(staticString: "https://example.com")) + + // Then + XCTAssertEqual(viewControllers, [madeViewController]) + } + + func test_handle_givenEditJotURL_returnsJotsAndChildViewControllers() { + // Given + let madeViewController = UIViewController() + let info = JotFile.Info( + url: URL(staticString: "file:///tmp/note.jot"), + name: "note", + modificationDate: nil, + ubiquitousInfo: nil + ) + let editJotURL = EditJotURL(jotFileInfo: info).toURL() + let editJotViewController = UIViewController() + let coordinator = makeCoordinator( + jotsViewControllerFactory: JotsViewControllerFactoryMock( + makeProvider: { _ in madeViewController } + ), + editJotCoordinatorFactory: EditJotCoordinatorFactoryMock( + makeProvider: { _ in + NavigationCoordinatorMock( + shouldHandleProvider: { _ in true }, + handleProvider: { _ in [editJotViewController] } + ) + } + ) + ) + + // When + let viewControllers = coordinator.handle(url: editJotURL) + + // Then + XCTAssertEqual(viewControllers, [madeViewController, editJotViewController]) + } + + func test_openSettings_givenInvoked_startsSettingsCoordinator() { + // Given + let startExpectation = XCTestExpectation(description: "SettingsCoordinator.start is called.") + let coordinator = makeCoordinator( + settingsCoordinatorFactory: SettingsCoordinatorFactoryMock( + makeProvider: { _ in + CoordinatorMock(startProvider: { startExpectation.fulfill() }) + } + ) + ) + + // When + coordinator.openSettings() + + // Then + wait(for: [startExpectation], timeout: 1) + } + + func test_openCreateJot_givenInvoked_startsCreateJotCoordinator() { + // Given + let startExpectation = XCTestExpectation(description: "CreateJotCoordinator.start is called.") + let coordinator = makeCoordinator( + createJotCoordinatorFactory: CreateJotCoordinatorFactoryMock( + makeProvider: { _ in + CoordinatorMock(startProvider: { startExpectation.fulfill() }) + } + ) + ) + + // When + coordinator.openCreateJot() + + // Then + wait(for: [startExpectation], timeout: 1) + } + + func test_openEnableCloudPage_givenInvoked_startsEnableCloudCoordinator() { + // Given + let startExpectation = XCTestExpectation(description: "EnableCloudCoordinator.start is called.") + let coordinator = makeCoordinator( + enableCloudCoordinatorFactory: EnableCloudCoordinatorFactoryMock( + makeProvider: { _ in + CoordinatorMock(startProvider: { startExpectation.fulfill() }) + } + ) + ) + + // When + coordinator.openEnableCloudPage() + + // Then + wait(for: [startExpectation], timeout: 1) + } + + #if !targetEnvironment(macCatalyst) + func test_openJot_givenPrefersNewWindow_invokesNavigationOpenScene() { + // Given + let openSceneExpectation = XCTestExpectation(description: "Navigation.openScene is called.") + let info = JotFile.Info( + url: URL(staticString: "file:///tmp/note.jot"), + name: "note", + modificationDate: nil, + ubiquitousInfo: nil + ) + let navigation = Navigation.test( + openSceneProvider: { receivedURL in + XCTAssertEqual(receivedURL, EditJotURL(jotFileInfo: info).toURL()) + openSceneExpectation.fulfill() + } + ) + let coordinator = makeCoordinator(navigation: navigation) + + // When + coordinator.openJot(jotFileInfo: info, prefersNewWindow: true) + + // Then + wait(for: [openSceneExpectation], timeout: 1) + } + + func test_openJot_givenPrefersSameWindow_invokesNavigationOpen() { + // Given + let openExpectation = XCTestExpectation(description: "Navigation.open is called.") + let info = JotFile.Info( + url: URL(staticString: "file:///tmp/note.jot"), + name: "note", + modificationDate: nil, + ubiquitousInfo: nil + ) + let navigation = Navigation.test( + openURLProvider: { receivedURL in + XCTAssertEqual(receivedURL, EditJotURL(jotFileInfo: info).toURL()) + openExpectation.fulfill() + } + ) + let coordinator = makeCoordinator(navigation: navigation) + + // When + coordinator.openJot(jotFileInfo: info, prefersNewWindow: false) + + // Then + wait(for: [openExpectation], timeout: 1) + } + #endif + + func test_openDeleteJot_givenInvoked_startsDeleteJotCoordinator() { + // Given + let startExpectation = XCTestExpectation(description: "DeleteJotCoordinator.start is called.") + let info = JotFile.Info( + url: URL(staticString: "file:///tmp/note.jot"), + name: "note", + modificationDate: nil, + ubiquitousInfo: nil + ) + let coordinator = makeCoordinator( + deleteJotCoordinatorFactory: DeleteJotCoordinatorFactoryMock( + makeProvider: { _, _ in + CoordinatorMock(startProvider: { startExpectation.fulfill() }) + } + ) + ) + + // When + coordinator.openDeleteJot(jotFileInfo: info) + + // Then + wait(for: [startExpectation], timeout: 1) + } + + func test_showRenameAlert_givenInvoked_startsRenameJotCoordinator() { + // Given + let startExpectation = XCTestExpectation(description: "RenameJotCoordinator.start is called.") + let info = JotFile.Info( + url: URL(staticString: "file:///tmp/note.jot"), + name: "note", + modificationDate: nil, + ubiquitousInfo: nil + ) + let coordinator = makeCoordinator( + renameJotCoordinatorFactory: RenameJotCoordinatorFactoryMock( + makeProvider: { _, _, _ in + CoordinatorMock(startProvider: { startExpectation.fulfill() }) + } + ) + ) + + // When + coordinator.showRenameAlert(jotFileInfo: info) + + // Then + wait(for: [startExpectation], timeout: 1) + } + + func test_showShareJot_givenInvoked_startsShareJotCoordinator() { + // Given + let startExpectation = XCTestExpectation(description: "ShareJotCoordinator.start is called.") + let info = JotFile.Info( + url: URL(staticString: "file:///tmp/note.jot"), + name: "note", + modificationDate: nil, + ubiquitousInfo: nil + ) + let coordinator = makeCoordinator( + shareJotCoordinatorFactory: ShareJotCoordinatorFactoryMock( + makeProvider: { _, _, _, _ in + CoordinatorMock(startProvider: { startExpectation.fulfill() }) + } + ) + ) + + // When + coordinator.showShareJot(jotFileInfo: info, format: .pdf, configurePopoverAnchor: nil) + + // Then + wait(for: [startExpectation], timeout: 1) + } + + func test_showInFiles_givenInvoked_startsRevealFileCoordinator() { + // Given + let startExpectation = XCTestExpectation(description: "RevealFileCoordinator.start is called.") + let info = JotFile.Info( + url: URL(staticString: "file:///tmp/note.jot"), + name: "note", + modificationDate: nil, + ubiquitousInfo: nil + ) + let coordinator = makeCoordinator( + revealFileCoordinatorFactory: RevealFileCoordinatorFactoryMock( + makeProvider: { _, _ in + CoordinatorMock(startProvider: { startExpectation.fulfill() }) + } + ) + ) + + // When + coordinator.showInFiles(jotFileInfo: info) + + // Then + wait(for: [startExpectation], timeout: 1) + } + + func test_showInfoAlert_givenInvoked_presentsAlertController() { + // Given + let presentExpectation = XCTestExpectation(description: "Navigation.present is called.") + let navigation = Navigation.test( + presentViewControllerProvider: { viewController, _ in + MainActor.assumeIsolated { + XCTAssertTrue(viewController is UIAlertController) + presentExpectation.fulfill() + } + } + ) + let coordinator = makeCoordinator(navigation: navigation) + + // When + coordinator.showInfoAlert(title: "title", message: "message") + + // Then + wait(for: [presentExpectation], timeout: 1) + } + + private func makeCoordinator( + navigation: Navigation = .test(), + jotsViewControllerFactory: JotsViewControllerFactoryProtocol = JotsViewControllerFactoryMock(), + settingsCoordinatorFactory: SettingsCoordinatorFactoryProtocol = SettingsCoordinatorFactoryMock(), + enableCloudCoordinatorFactory: EnableCloudCoordinatorFactoryProtocol = EnableCloudCoordinatorFactoryMock(), + editJotCoordinatorFactory: EditJotCoordinatorFactoryProtocol = EditJotCoordinatorFactoryMock(), + cloudMigrationCoordinatorFactory: CloudMigrationCoordinatorFactoryProtocol = + CloudMigrationCoordinatorFactoryMock(), + createJotCoordinatorFactory: CreateJotCoordinatorFactoryProtocol = CreateJotCoordinatorFactoryMock(), + deleteJotCoordinatorFactory: DeleteJotCoordinatorFactoryProtocol = DeleteJotCoordinatorFactoryMock(), + renameJotCoordinatorFactory: RenameJotCoordinatorFactoryProtocol = RenameJotCoordinatorFactoryMock(), + shareJotCoordinatorFactory: ShareJotCoordinatorFactoryProtocol = ShareJotCoordinatorFactoryMock(), + revealFileCoordinatorFactory: RevealFileCoordinatorFactoryProtocol = RevealFileCoordinatorFactoryMock() + ) -> JotsCoordinator { + JotsCoordinator( + navigation: navigation, + jotsViewControllerFactory: jotsViewControllerFactory, + settingsCoordinatorFactory: settingsCoordinatorFactory, + enableCloudCoordinatorFactory: enableCloudCoordinatorFactory, + editJotCoordinatorFactory: editJotCoordinatorFactory, + cloudMigrationCoordinatorFactory: cloudMigrationCoordinatorFactory, + createJotCoordinatorFactory: createJotCoordinatorFactory, + deleteJotCoordinatorFactory: deleteJotCoordinatorFactory, + renameJotCoordinatorFactory: renameJotCoordinatorFactory, + shareJotCoordinatorFactory: shareJotCoordinatorFactory, + revealFileCoordinatorFactory: revealFileCoordinatorFactory + ) + } +} diff --git a/Tests/JotsPage/JotsRepositoryTests.swift b/Tests/JotsPage/JotsRepositoryTests.swift new file mode 100644 index 0000000..014802d --- /dev/null +++ b/Tests/JotsPage/JotsRepositoryTests.swift @@ -0,0 +1,212 @@ +/* + Jottre: Minimalistic jotting for iPhone, iPad and Mac. + Copyright (C) 2021-2026 Anton Lorani + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +import XCTest + +@testable import Jottre + +final class JotsRepositoryTests: XCTestCase { + + func test_getJotFiles_givenJotFileInfos_emitsThemSortedByModificationDateDescending() async throws { + // Given + let older = JotFile.Info( + url: URL(staticString: "file:///tmp/older.jot"), + name: "older", + modificationDate: Date(timeIntervalSince1970: 1_000), + ubiquitousInfo: nil + ) + let newer = JotFile.Info( + url: URL(staticString: "file:///tmp/newer.jot"), + name: "newer", + modificationDate: Date(timeIntervalSince1970: 2_000), + ubiquitousInfo: nil + ) + let undated = JotFile.Info( + url: URL(staticString: "file:///tmp/undated.jot"), + name: "undated", + modificationDate: nil, + ubiquitousInfo: nil + ) + let jotFileServiceMock = JotFileServiceMock( + documentsDirectoryContentsProvider: { + AsyncThrowingStream { continuation in + continuation.yield([older, newer, undated]) + continuation.finish() + } + } + ) + let repository = JotsRepository( + ubiquitousFileService: FileServiceMock(), + applicationService: await ApplicationServiceMock(), + deviceService: await DeviceServiceMock(), + jotFileService: jotFileServiceMock, + jotFilePreviewImageService: JotFilePreviewImageServiceMock() + ) + + // When + var iterator = repository.getJotFiles().makeAsyncIterator() + let first = try await XCTUnwrapAsync(try await iterator.next()) + + // Then + XCTAssertEqual(first.map(\.name), ["newer", "older", "undated"]) + } + + func test_shouldShowEnableICloudButton_givenUbiquitousServiceDisabled_returnsTrue() async { + // Given + let repository = JotsRepository( + ubiquitousFileService: FileServiceMock(isEnabledProvider: { false }), + applicationService: await ApplicationServiceMock(), + deviceService: await DeviceServiceMock(), + jotFileService: JotFileServiceMock(), + jotFilePreviewImageService: JotFilePreviewImageServiceMock() + ) + + // Then + XCTAssertTrue(repository.shouldShowEnableICloudButton()) + } + + func test_shouldShowEnableICloudButton_givenUbiquitousServiceEnabled_returnsFalse() async { + // Given + let repository = JotsRepository( + ubiquitousFileService: FileServiceMock(isEnabledProvider: { true }), + applicationService: await ApplicationServiceMock(), + deviceService: await DeviceServiceMock(), + jotFileService: JotFileServiceMock(), + jotFilePreviewImageService: JotFilePreviewImageServiceMock() + ) + + // Then + XCTAssertFalse(repository.shouldShowEnableICloudButton()) + } + + func test_duplicate_forwardsToJotFileService() async throws { + // Given + let duplicateProviderExpectation = XCTestExpectation( + description: "JotFileServiceMock.duplicateProvider is called." + ) + let inputInfo = JotFile.Info( + url: URL(staticString: "file:///tmp/note.jot"), + name: "note", + modificationDate: nil, + ubiquitousInfo: nil + ) + let expectedDuplicatedInfo = JotFile.Info( + url: URL(staticString: "file:///tmp/note-1.jot"), + name: "note-1", + modificationDate: nil, + ubiquitousInfo: nil + ) + let jotFileServiceMock = JotFileServiceMock( + duplicateProvider: { receivedInfo in + // Then + XCTAssertEqual(receivedInfo, inputInfo) + duplicateProviderExpectation.fulfill() + return expectedDuplicatedInfo + } + ) + let repository = JotsRepository( + ubiquitousFileService: FileServiceMock(), + applicationService: await ApplicationServiceMock(), + deviceService: await DeviceServiceMock(), + jotFileService: jotFileServiceMock, + jotFilePreviewImageService: JotFilePreviewImageServiceMock() + ) + + // When + let result = try repository.duplicate(jotFileInfo: inputInfo) + + // Then + XCTAssertEqual(result, expectedDuplicatedInfo) + await fulfillment(of: [duplicateProviderExpectation], timeout: 0.2) + } + + func test_download_callsStartDownloadOnUbiquitousFileServiceWithJotFileURL() async throws { + // Given + let startDownloadProviderExpectation = XCTestExpectation( + description: "Ubiquitous startDownloadProvider is called." + ) + let expectedFileURL = URL(staticString: "file:///cloud/note.jot") + let ubiquitousFileServiceMock = FileServiceMock( + startDownloadProvider: { receivedFileURL in + // Then + XCTAssertEqual(receivedFileURL, expectedFileURL) + startDownloadProviderExpectation.fulfill() + } + ) + let repository = JotsRepository( + ubiquitousFileService: ubiquitousFileServiceMock, + applicationService: await ApplicationServiceMock(), + deviceService: await DeviceServiceMock(), + jotFileService: JotFileServiceMock(), + jotFilePreviewImageService: JotFilePreviewImageServiceMock() + ) + + // When + try repository.download( + jotFileInfo: JotFile.Info( + url: expectedFileURL, + name: "note", + modificationDate: nil, + ubiquitousInfo: UbiquitousInfo(downloadStatus: .notDownloaded, isDownloading: false) + ) + ) + + // Then + await fulfillment(of: [startDownloadProviderExpectation], timeout: 0.2) + } + + func test_getPreviewImage_givenServiceThrows_returnsNil() async throws { + // Given + let jotFilePreviewImageServiceMock = JotFilePreviewImageServiceMock( + getPreviewImageDataProvider: { _, _, _ in + throw NSError(domain: "test", code: 0) + } + ) + let repository = JotsRepository( + ubiquitousFileService: FileServiceMock(), + applicationService: await ApplicationServiceMock(), + deviceService: await DeviceServiceMock(), + jotFileService: JotFileServiceMock(), + jotFilePreviewImageService: jotFilePreviewImageServiceMock + ) + + // When + let image = await repository.getPreviewImage( + jotFileInfo: JotFile.Info( + url: URL(staticString: "file:///tmp/note.jot"), + name: "note", + modificationDate: nil, + ubiquitousInfo: nil + ), + userInterfaceStyle: .light, + displayScale: 2.0 + ) + + // Then + XCTAssertNil(image) + } +} + +private func XCTUnwrapAsync( + _ expression: @autoclosure () async throws -> T?, + file: StaticString = #filePath, + line: UInt = #line +) async throws -> T { + let value = try await expression() + return try XCTUnwrap(value, file: file, line: line) +} diff --git a/Tests/JotsPage/JotsViewModelTests.swift b/Tests/JotsPage/JotsViewModelTests.swift new file mode 100644 index 0000000..7c1f6f3 --- /dev/null +++ b/Tests/JotsPage/JotsViewModelTests.swift @@ -0,0 +1,200 @@ +/* + Jottre: Minimalistic jotting for iPhone, iPad and Mac. + Copyright (C) 2021-2026 Anton Lorani + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +import XCTest + +@testable import Jottre + +@MainActor +final class JotsViewModelTests: XCTestCase { + + func test_leftNavigationItems_givenShouldNotShowICloudButton_yieldsOnlySettings() async throws { + // Given + let viewModel = JotsViewModel( + coordinator: JotsCoordinatorMock(), + repository: JotsRepositoryMock(shouldShowEnableICloudButtonProvider: { false }), + menuConfigurationFactory: JotMenuConfigurationFactory() + ) + + // When + let items = try await firstValue(of: viewModel.leftNavigationItems) + + // Then + XCTAssertEqual(items.count, 1) + guard case let .symbol(systemImageName, _) = items[0] else { + XCTFail("Expected .symbol") + return + } + XCTAssertEqual(systemImageName, "gear") + } + + func test_leftNavigationItems_givenShouldShowICloudButton_yieldsSettingsAndICloudSlash() async throws { + // Given + let viewModel = JotsViewModel( + coordinator: JotsCoordinatorMock(), + repository: JotsRepositoryMock(shouldShowEnableICloudButtonProvider: { true }), + menuConfigurationFactory: JotMenuConfigurationFactory() + ) + + // When + let items = try await firstValue(of: viewModel.leftNavigationItems) + + // Then + XCTAssertEqual(items.count, 2) + guard case let .symbol(secondImage, _) = items[1] else { + XCTFail("Expected .symbol") + return + } + XCTAssertEqual(secondImage, "icloud.slash") + } + + func test_leftNavigationItem_givenSettingsTap_invokesOpenSettings() async throws { + // Given + let openSettingsExpectation = XCTestExpectation(description: "Coordinator.openSettings is called.") + let coordinator = JotsCoordinatorMock( + openSettingsProvider: { openSettingsExpectation.fulfill() } + ) + let viewModel = JotsViewModel( + coordinator: coordinator, + repository: JotsRepositoryMock(), + menuConfigurationFactory: JotMenuConfigurationFactory() + ) + + // When + let items = try await firstValue(of: viewModel.leftNavigationItems) + guard case let .symbol(_, onAction) = items[0] else { + XCTFail("Expected .symbol") + return + } + onAction() + await Task.yield() + + // Then + await fulfillment(of: [openSettingsExpectation], timeout: 1) + } + + func test_leftNavigationItem_givenICloudTap_invokesOpenEnableCloudPage() async throws { + // Given + let expectation = XCTestExpectation(description: "Coordinator.openEnableCloudPage is called.") + let coordinator = JotsCoordinatorMock( + openEnableCloudPageProvider: { expectation.fulfill() } + ) + let viewModel = JotsViewModel( + coordinator: coordinator, + repository: JotsRepositoryMock(shouldShowEnableICloudButtonProvider: { true }), + menuConfigurationFactory: JotMenuConfigurationFactory() + ) + + // When + let items = try await firstValue(of: viewModel.leftNavigationItems) + guard case let .symbol(_, onAction) = items[1] else { + XCTFail("Expected .symbol") + return + } + onAction() + await Task.yield() + + // Then + await fulfillment(of: [expectation], timeout: 1) + } + + #if !targetEnvironment(macCatalyst) + func test_rightNavigationItem_givenCreateTap_invokesOpenCreateJot() async throws { + // Given + let expectation = XCTestExpectation(description: "Coordinator.openCreateJot is called.") + let coordinator = JotsCoordinatorMock( + openCreateJotProvider: { expectation.fulfill() } + ) + let viewModel = JotsViewModel( + coordinator: coordinator, + repository: JotsRepositoryMock(), + menuConfigurationFactory: JotMenuConfigurationFactory() + ) + + // When + let items = try await firstValue(of: viewModel.rightNavigationItems) + XCTAssertEqual(items.count, 1) + guard case let .text(_, onAction) = items[0] else { + XCTFail("Expected .text") + return + } + onAction() + await Task.yield() + + // Then + await fulfillment(of: [expectation], timeout: 1) + } + #endif + + func test_didLoad_givenEmptyJots_yieldsEmptyStateItem() async throws { + // Given + let stream = AsyncThrowingStream<[JotFile.Info], Error> { continuation in + continuation.yield([]) + continuation.finish() + } + let viewModel = JotsViewModel( + coordinator: JotsCoordinatorMock(), + repository: JotsRepositoryMock(getJotFilesProvider: { stream }), + menuConfigurationFactory: JotMenuConfigurationFactory() + ) + + // When + viewModel.didLoad() + let items = try await firstValue(of: viewModel.items) + + // Then + XCTAssertEqual(items.count, 1) + } + + func test_didLoad_givenOneJot_yieldsOneJotItem() async throws { + // Given + let info = JotFile.Info( + url: URL(staticString: "file:///tmp/note.jot"), + name: "note", + modificationDate: nil, + ubiquitousInfo: nil + ) + let stream = AsyncThrowingStream<[JotFile.Info], Error> { continuation in + continuation.yield([info]) + continuation.finish() + } + let viewModel = JotsViewModel( + coordinator: JotsCoordinatorMock(), + repository: JotsRepositoryMock(getJotFilesProvider: { stream }), + menuConfigurationFactory: JotMenuConfigurationFactory() + ) + + // When + viewModel.didLoad() + let items = try await firstValue(of: viewModel.items) + + // Then + XCTAssertEqual(items.count, 1) + } +} + +@MainActor +private func firstValue( + of sequence: S +) async throws -> S.Element where S.Element: Sendable { + var iterator = sequence.makeAsyncIterator() + guard let value = try await iterator.next() else { + throw NSError(domain: "JotsViewModelTests", code: 0) + } + return value +} diff --git a/Tests/JotsPage/RenameJotCoordinatorTests.swift b/Tests/JotsPage/RenameJotCoordinatorTests.swift new file mode 100644 index 0000000..2c04621 --- /dev/null +++ b/Tests/JotsPage/RenameJotCoordinatorTests.swift @@ -0,0 +1,94 @@ +/* + Jottre: Minimalistic jotting for iPhone, iPad and Mac. + Copyright (C) 2021-2026 Anton Lorani + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +import UIKit +import XCTest + +@testable import Jottre + +@MainActor +final class RenameJotCoordinatorTests: XCTestCase { + + func test_start_givenInvoked_presentsAlertWithCancelAndRenameActions() { + // Given + let presentExpectation = XCTestExpectation(description: "Navigation.present is called.") + let info = JotFile.Info( + url: URL(staticString: "file:///tmp/note.jot"), + name: "note", + modificationDate: nil, + ubiquitousInfo: nil + ) + let navigation = Navigation.test( + presentViewControllerProvider: { viewController, _ in + MainActor.assumeIsolated { + let alert = viewController as? UIAlertController + XCTAssertNotNil(alert) + XCTAssertEqual(alert?.actions.count, 2) + XCTAssertEqual(alert?.actions.first?.style, .cancel) + presentExpectation.fulfill() + } + } + ) + let coordinator = RenameJotCoordinator( + jotFileInfo: info, + navigation: navigation, + repository: RenameJotRepositoryMock(), + onRename: { _ in } + ) + + // When + coordinator.start() + + // Then + wait(for: [presentExpectation], timeout: 1) + } + + func test_start_givenCancelTapped_invokesOnEnd() { + // Given + let onEndExpectation = XCTestExpectation(description: "RenameJotCoordinator.onEnd is invoked.") + let info = JotFile.Info( + url: URL(staticString: "file:///tmp/note.jot"), + name: "note", + modificationDate: nil, + ubiquitousInfo: nil + ) + var alertController: UIAlertController? + let navigation = Navigation.test( + presentViewControllerProvider: { viewController, _ in + MainActor.assumeIsolated { + alertController = viewController as? UIAlertController + } + } + ) + let coordinator = RenameJotCoordinator( + jotFileInfo: info, + navigation: navigation, + repository: RenameJotRepositoryMock(), + onRename: { _ in } + ) + coordinator.onEnd = { onEndExpectation.fulfill() } + + // When + coordinator.start() + let cancelAction = alertController?.actions.first { $0.style == .cancel } + cancelAction?.invokeHandler() + + // Then + wait(for: [onEndExpectation], timeout: 1) + } +} diff --git a/Tests/JotsPage/RenameJotRepositoryTests.swift b/Tests/JotsPage/RenameJotRepositoryTests.swift new file mode 100644 index 0000000..a0782ee --- /dev/null +++ b/Tests/JotsPage/RenameJotRepositoryTests.swift @@ -0,0 +1,58 @@ +/* + Jottre: Minimalistic jotting for iPhone, iPad and Mac. + Copyright (C) 2021-2026 Anton Lorani + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +import XCTest + +@testable import Jottre + +final class RenameJotRepositoryTests: XCTestCase { + + func test_rename_forwardsArgumentsToJotFileServiceAndReturnsResult() async throws { + // Given + let renameProviderExpectation = XCTestExpectation(description: "JotFileServiceMock.renameProvider is called.") + let inputInfo = JotFile.Info( + url: URL(staticString: "file:///tmp/old.jot"), + name: "old", + modificationDate: nil, + ubiquitousInfo: nil + ) + let expectedRenamedInfo = JotFile.Info( + url: URL(staticString: "file:///tmp/new.jot"), + name: "new", + modificationDate: nil, + ubiquitousInfo: nil + ) + let jotFileServiceMock = JotFileServiceMock( + renameProvider: { receivedInfo, receivedName in + // Then + XCTAssertEqual(receivedInfo, inputInfo) + XCTAssertEqual(receivedName, "new") + renameProviderExpectation.fulfill() + return expectedRenamedInfo + } + ) + let repository = RenameJotRepository(jotFileService: jotFileServiceMock) + + // When + let result = try repository.rename(jotFileInfo: inputInfo, newName: "new") + + // Then + XCTAssertEqual(result, expectedRenamedInfo) + await fulfillment(of: [renameProviderExpectation], timeout: 0.2) + } +} diff --git a/Tests/JotsPage/ShareJotCoordinatorTests.swift b/Tests/JotsPage/ShareJotCoordinatorTests.swift new file mode 100644 index 0000000..b6db5b1 --- /dev/null +++ b/Tests/JotsPage/ShareJotCoordinatorTests.swift @@ -0,0 +1,96 @@ +/* + Jottre: Minimalistic jotting for iPhone, iPad and Mac. + Copyright (C) 2021-2026 Anton Lorani + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +import UIKit +import XCTest + +@testable import Jottre + +@MainActor +final class ShareJotCoordinatorTests: XCTestCase { + + func test_start_givenSuccessfulExport_presentsActivityViewController() async { + // Given + let presentExpectation = XCTestExpectation(description: "Navigation.present(activity) is called.") + let info = JotFile.Info( + url: URL(staticString: "file:///tmp/note.jot"), + name: "note", + modificationDate: nil, + ubiquitousInfo: nil + ) + let navigation = Navigation.test( + presentViewControllerProvider: { viewController, _ in + MainActor.assumeIsolated { + if viewController is UIActivityViewController { + presentExpectation.fulfill() + } + } + } + ) + let coordinator = ShareJotCoordinator( + jotFileInfo: info, + format: .pdf, + navigation: navigation, + repository: ShareJotRepositoryMock( + exportJotProvider: { _, _ in URL(fileURLWithPath: "/tmp/share.pdf") } + ), + configurePopoverAnchor: { _ in } + ) + + // When + coordinator.start() + + // Then + await fulfillment(of: [presentExpectation], timeout: 5) + } + + func test_start_givenExportThrows_presentsAlertController() async { + // Given + let presentExpectation = XCTestExpectation(description: "Navigation.present(alert) is called.") + let info = JotFile.Info( + url: URL(staticString: "file:///tmp/note.jot"), + name: "note", + modificationDate: nil, + ubiquitousInfo: nil + ) + let navigation = Navigation.test( + presentViewControllerProvider: { viewController, _ in + MainActor.assumeIsolated { + if viewController is UIAlertController { + presentExpectation.fulfill() + } + } + } + ) + let coordinator = ShareJotCoordinator( + jotFileInfo: info, + format: .pdf, + navigation: navigation, + repository: ShareJotRepositoryMock( + exportJotProvider: { _, _ in throw NSError(domain: "test", code: 0) } + ), + configurePopoverAnchor: { _ in } + ) + + // When + coordinator.start() + + // Then + await fulfillment(of: [presentExpectation], timeout: 5) + } +} diff --git a/Tests/JotsPage/ShareJotRepositoryTests.swift b/Tests/JotsPage/ShareJotRepositoryTests.swift new file mode 100644 index 0000000..7c66c2b --- /dev/null +++ b/Tests/JotsPage/ShareJotRepositoryTests.swift @@ -0,0 +1,143 @@ +/* + Jottre: Minimalistic jotting for iPhone, iPad and Mac. + Copyright (C) 2021-2026 Anton Lorani + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +@preconcurrency import PencilKit +import UIKit +import XCTest + +@testable import Jottre + +final class ShareJotRepositoryTests: XCTestCase { + + private var temporaryDirectory: URL! + + override func setUpWithError() throws { + try super.setUpWithError() + temporaryDirectory = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory( + at: temporaryDirectory, + withIntermediateDirectories: true + ) + } + + override func tearDownWithError() throws { + try? FileManager.default.removeItem(at: temporaryDirectory) + temporaryDirectory = nil + try super.tearDownWithError() + } + + func test_exportJot_givenPDFFormat_writesPDFFileToTemporaryDirectoryAndReturnsItsURL() async throws { + // Given + let repository = try makeRepository() + + // When + let resultURL = try await repository.exportJot(jotFileInfo: makeJotFileInfo(), format: .pdf) + + // Then + XCTAssertEqual(resultURL.lastPathComponent, "note.pdf") + XCTAssertEqual(resultURL.deletingLastPathComponent(), temporaryDirectory) + XCTAssertTrue(FileManager.default.fileExists(atPath: resultURL.path)) + let header = try Data(contentsOf: resultURL).prefix(4) + XCTAssertEqual(Array(header), Array("%PDF".utf8)) + } + + func test_exportJot_givenJPGFormat_writesJPGFileToTemporaryDirectoryAndReturnsItsURL() async throws { + // Given + let repository = try makeRepository() + + // When + let resultURL = try await repository.exportJot(jotFileInfo: makeJotFileInfo(), format: .jpg) + + // Then + XCTAssertEqual(resultURL.lastPathComponent, "note.jpg") + XCTAssertTrue(FileManager.default.fileExists(atPath: resultURL.path)) + let bytes = try Data(contentsOf: resultURL).prefix(3) + XCTAssertEqual(Array(bytes), [0xFF, 0xD8, 0xFF]) + } + + func test_exportJot_givenPNGFormat_writesPNGFileToTemporaryDirectoryAndReturnsItsURL() async throws { + // Given + let repository = try makeRepository() + + // When + let resultURL = try await repository.exportJot(jotFileInfo: makeJotFileInfo(), format: .png) + + // Then + XCTAssertEqual(resultURL.lastPathComponent, "note.png") + XCTAssertTrue(FileManager.default.fileExists(atPath: resultURL.path)) + let bytes = try Data(contentsOf: resultURL).prefix(8) + XCTAssertEqual(Array(bytes), [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]) + } + + func test_exportJot_givenJotFileServiceThrows_propagatesError() async { + // Given + struct UnexpectedError: Error {} + let repository = ShareJotRepository( + jotFileService: JotFileServiceMock( + readJotFileProvider: { _ in throw UnexpectedError() } + ), + fileService: FileServiceMock( + temporaryDirectoryProvider: { [temporaryDirectory] in temporaryDirectory! } + ) + ) + + // When + Then + do { + _ = try await repository.exportJot(jotFileInfo: makeJotFileInfo(), format: .pdf) + XCTFail("Expected exportJot to throw") + } catch is UnexpectedError { + // Expected + } catch { + XCTFail("Unexpected error: \(error)") + } + } + + private func makeRepository() throws -> ShareJotRepository { + let fixtureJot = try loadFixtureJot() + return ShareJotRepository( + jotFileService: JotFileServiceMock( + readJotFileProvider: { jotFileInfo in + JotFile(info: jotFileInfo, jot: fixtureJot) + } + ), + fileService: FileServiceMock( + temporaryDirectoryProvider: { [temporaryDirectory] in temporaryDirectory! } + ) + ) + } + + private func loadFixtureJot() throws -> Jot { + let bundle = Bundle(for: ShareJotRepositoryTests.self) + let fixtureURL = try XCTUnwrap( + bundle.url(forResource: "Calculator Pro", withExtension: "jot"), + "Missing 'Calculator Pro.jot' fixture in test bundle resources." + ) + let data = try Data(contentsOf: fixtureURL) + return try PropertyListDecoder().decode(Jot.self, from: data) + } + + private func makeJotFileInfo() -> JotFile.Info { + JotFile.Info( + url: URL(staticString: "file:///tmp/note.jot"), + name: "note", + modificationDate: nil, + ubiquitousInfo: nil + ) + } +} diff --git a/Tests/Mocks/ApplicationServiceMock.swift b/Tests/Mocks/ApplicationServiceMock.swift new file mode 100644 index 0000000..99333f9 --- /dev/null +++ b/Tests/Mocks/ApplicationServiceMock.swift @@ -0,0 +1,43 @@ +/* + Jottre: Minimalistic jotting for iPhone, iPad and Mac. + Copyright (C) 2021-2026 Anton Lorani + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +import Foundation + +@testable import Jottre + +@MainActor +final class ApplicationServiceMock: ApplicationServiceProtocol { + + private let supportsMultipleScenesProvider: @MainActor () -> Bool + private let openProvider: @MainActor (_ url: URL) -> Void + private let canOpenProvider: @MainActor (_ url: URL) -> Bool + + init( + supportsMultipleScenesProvider: @MainActor @escaping () -> Bool = { false }, + openProvider: @MainActor @escaping (_ url: URL) -> Void = { _ in }, + canOpenProvider: @MainActor @escaping (_ url: URL) -> Bool = { _ in true } + ) { + self.supportsMultipleScenesProvider = supportsMultipleScenesProvider + self.openProvider = openProvider + self.canOpenProvider = canOpenProvider + } + + func supportsMultipleScenes() -> Bool { supportsMultipleScenesProvider() } + func open(url: URL) { openProvider(url) } + func canOpen(url: URL) -> Bool { canOpenProvider(url) } +} diff --git a/Tests/Mocks/BundleServiceMock.swift b/Tests/Mocks/BundleServiceMock.swift new file mode 100644 index 0000000..b52302f --- /dev/null +++ b/Tests/Mocks/BundleServiceMock.swift @@ -0,0 +1,30 @@ +/* + Jottre: Minimalistic jotting for iPhone, iPad and Mac. + Copyright (C) 2021-2026 Anton Lorani + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +@testable import Jottre + +final class BundleServiceMock: BundleServiceProtocol { + + private let shortVersionStringProvider: @Sendable () -> String? + + init(shortVersionStringProvider: @Sendable @escaping () -> String? = { nil }) { + self.shortVersionStringProvider = shortVersionStringProvider + } + + func shortVersionString() -> String? { shortVersionStringProvider() } +} diff --git a/Tests/Mocks/CloudMigrationCoordinatorMock.swift b/Tests/Mocks/CloudMigrationCoordinatorMock.swift new file mode 100644 index 0000000..1df724c --- /dev/null +++ b/Tests/Mocks/CloudMigrationCoordinatorMock.swift @@ -0,0 +1,58 @@ +/* + Jottre: Minimalistic jotting for iPhone, iPad and Mac. + Copyright (C) 2021-2026 Anton Lorani + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +@testable import Jottre + +@MainActor +final class CloudMigrationCoordinatorMock: CloudMigrationCoordinatorProtocol { + + var onEnd: (() -> Void)? + + private let shouldStartProvider: () -> Bool + private let startProvider: () -> Void + private let showInfoAlertProvider: (_ title: String, _ message: String) -> Void + private let dismissProvider: () -> Void + + init( + shouldStartProvider: @escaping () -> Bool = { false }, + startProvider: @escaping () -> Void = {}, + showInfoAlertProvider: @escaping (_ title: String, _ message: String) -> Void = { _, _ in }, + dismissProvider: @escaping () -> Void = {} + ) { + self.shouldStartProvider = shouldStartProvider + self.startProvider = startProvider + self.showInfoAlertProvider = showInfoAlertProvider + self.dismissProvider = dismissProvider + } + + func shouldStart() -> Bool { + shouldStartProvider() + } + + func start() { + startProvider() + } + + func showInfoAlert(title: String, message: String) { + showInfoAlertProvider(title, message) + } + + func dismiss() { + dismissProvider() + } +} diff --git a/Tests/Mocks/CloudMigrationRepositoryMock.swift b/Tests/Mocks/CloudMigrationRepositoryMock.swift new file mode 100644 index 0000000..b7f759c --- /dev/null +++ b/Tests/Mocks/CloudMigrationRepositoryMock.swift @@ -0,0 +1,87 @@ +/* + Jottre: Minimalistic jotting for iPhone, iPad and Mac. + Copyright (C) 2021-2026 Anton Lorani + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +import Foundation +import UIKit + +@testable import Jottre + +final class CloudMigrationRepositoryMock: CloudMigrationRepositoryProtocol { + + private let getJotFilesProvider: @Sendable () -> AsyncThrowingStream<[CloudMigrationJotBusinessModel], Error> + private let moveJotFileProvider: + @Sendable (_ jotFileInfo: JotFile.Info, _ shouldBecomeUbiquitous: Bool) async throws -> Void + private let getShouldShowCloudMigrationProvider: @Sendable () -> Bool + private let markCloudMigrationPageDoneProvider: @Sendable () -> Void + private let getPreviewImageProvider: + @Sendable ( + _ jotFileInfo: JotFile.Info, + _ userInterfaceStyle: UIUserInterfaceStyle, + _ displayScale: CGFloat + ) async -> UIImage? + + init( + getJotFilesProvider: + @Sendable @escaping () -> AsyncThrowingStream<[CloudMigrationJotBusinessModel], Error> = { + AsyncThrowingStream { $0.finish() } + }, + moveJotFileProvider: + @Sendable @escaping (_ jotFileInfo: JotFile.Info, _ shouldBecomeUbiquitous: Bool) async throws -> Void = { + _, + _ in + }, + getShouldShowCloudMigrationProvider: @Sendable @escaping () -> Bool = { false }, + markCloudMigrationPageDoneProvider: @Sendable @escaping () -> Void = {}, + getPreviewImageProvider: + @Sendable @escaping ( + _ jotFileInfo: JotFile.Info, + _ userInterfaceStyle: UIUserInterfaceStyle, + _ displayScale: CGFloat + ) async -> UIImage? = { _, _, _ in nil } + ) { + self.getJotFilesProvider = getJotFilesProvider + self.moveJotFileProvider = moveJotFileProvider + self.getShouldShowCloudMigrationProvider = getShouldShowCloudMigrationProvider + self.markCloudMigrationPageDoneProvider = markCloudMigrationPageDoneProvider + self.getPreviewImageProvider = getPreviewImageProvider + } + + func getJotFiles() -> AsyncThrowingStream<[CloudMigrationJotBusinessModel], Error> { + getJotFilesProvider() + } + + func moveJotFile(jotFileInfo: JotFile.Info, shouldBecomeUbiquitous: Bool) async throws { + try await moveJotFileProvider(jotFileInfo, shouldBecomeUbiquitous) + } + + func getShouldShowCloudMigration() -> Bool { + getShouldShowCloudMigrationProvider() + } + + func markCloudMigrationPageDone() { + markCloudMigrationPageDoneProvider() + } + + func getPreviewImage( + jotFileInfo: JotFile.Info, + userInterfaceStyle: UIUserInterfaceStyle, + displayScale: CGFloat + ) async -> UIImage? { + await getPreviewImageProvider(jotFileInfo, userInterfaceStyle, displayScale) + } +} diff --git a/Tests/Mocks/CloudMigrationViewControllerFactoryMock.swift b/Tests/Mocks/CloudMigrationViewControllerFactoryMock.swift new file mode 100644 index 0000000..dcf082f --- /dev/null +++ b/Tests/Mocks/CloudMigrationViewControllerFactoryMock.swift @@ -0,0 +1,39 @@ +/* + Jottre: Minimalistic jotting for iPhone, iPad and Mac. + Copyright (C) 2021-2026 Anton Lorani + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +import UIKit + +@testable import Jottre + +@MainActor +final class CloudMigrationViewControllerFactoryMock: CloudMigrationViewControllerFactoryProtocol { + + private let makeProvider: @MainActor (_ viewModel: CloudMigrationViewModel) -> UIViewController + + init( + makeProvider: @MainActor @escaping (_ viewModel: CloudMigrationViewModel) -> UIViewController = { _ in + UIViewController() + } + ) { + self.makeProvider = makeProvider + } + + func make(viewModel: CloudMigrationViewModel) -> UIViewController { + makeProvider(viewModel) + } +} diff --git a/Tests/Mocks/CoordinatorFactoryMocks.swift b/Tests/Mocks/CoordinatorFactoryMocks.swift new file mode 100644 index 0000000..8dcc111 --- /dev/null +++ b/Tests/Mocks/CoordinatorFactoryMocks.swift @@ -0,0 +1,171 @@ +/* + Jottre: Minimalistic jotting for iPhone, iPad and Mac. + Copyright (C) 2021-2026 Anton Lorani + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +import Foundation + +@testable import Jottre + +@MainActor +final class CreateJotCoordinatorFactoryMock: CreateJotCoordinatorFactoryProtocol { + + private let makeProvider: @MainActor (_ navigation: Navigation) -> Coordinator + + init( + makeProvider: @MainActor @escaping (_ navigation: Navigation) -> Coordinator = { _ in CoordinatorMock() } + ) { + self.makeProvider = makeProvider + } + + func make(navigation: Navigation) -> Coordinator { + makeProvider(navigation) + } +} + +@MainActor +final class DeleteJotCoordinatorFactoryMock: DeleteJotCoordinatorFactoryProtocol { + + private let makeProvider: @MainActor (_ jotFileInfo: JotFile.Info, _ navigation: Navigation) -> Coordinator + + init( + makeProvider: + @MainActor @escaping (_ jotFileInfo: JotFile.Info, _ navigation: Navigation) -> Coordinator = { _, _ in + CoordinatorMock() + } + ) { + self.makeProvider = makeProvider + } + + func make(jotFileInfo: JotFile.Info, navigation: Navigation) -> Coordinator { + makeProvider(jotFileInfo, navigation) + } +} + +@MainActor +final class RenameJotCoordinatorFactoryMock: RenameJotCoordinatorFactoryProtocol { + + private let makeProvider: + @MainActor ( + _ jotFileInfo: JotFile.Info, + _ navigation: Navigation, + _ onRename: @Sendable (_ renameJotFileInfo: JotFile.Info) -> Void + ) -> Coordinator + + init( + makeProvider: + @MainActor @escaping ( + _ jotFileInfo: JotFile.Info, + _ navigation: Navigation, + _ onRename: @Sendable (_ renameJotFileInfo: JotFile.Info) -> Void + ) -> Coordinator = { _, _, _ in CoordinatorMock() } + ) { + self.makeProvider = makeProvider + } + + func make( + jotFileInfo: JotFile.Info, + navigation: Navigation, + onRename: @Sendable @escaping (_ renameJotFileInfo: JotFile.Info) -> Void + ) -> Coordinator { + makeProvider(jotFileInfo, navigation, onRename) + } +} + +@MainActor +final class ShareJotCoordinatorFactoryMock: ShareJotCoordinatorFactoryProtocol { + + private let makeProvider: + @MainActor ( + _ jotFileInfo: JotFile.Info, + _ format: ShareFormat, + _ navigation: Navigation, + _ configurePopoverAnchor: PopoverAnchor? + ) -> Coordinator + + init( + makeProvider: + @MainActor @escaping ( + _ jotFileInfo: JotFile.Info, + _ format: ShareFormat, + _ navigation: Navigation, + _ configurePopoverAnchor: PopoverAnchor? + ) -> Coordinator = { _, _, _, _ in CoordinatorMock() } + ) { + self.makeProvider = makeProvider + } + + func make( + jotFileInfo: JotFile.Info, + format: ShareFormat, + navigation: Navigation, + configurePopoverAnchor: PopoverAnchor? + ) -> Coordinator { + makeProvider(jotFileInfo, format, navigation, configurePopoverAnchor) + } +} + +@MainActor +final class RevealFileCoordinatorFactoryMock: RevealFileCoordinatorFactoryProtocol { + + private let makeProvider: @MainActor (_ jotFileInfo: JotFile.Info, _ navigation: Navigation) -> Coordinator + + init( + makeProvider: + @MainActor @escaping (_ jotFileInfo: JotFile.Info, _ navigation: Navigation) -> Coordinator = { _, _ in + CoordinatorMock() + } + ) { + self.makeProvider = makeProvider + } + + func make(jotFileInfo: JotFile.Info, navigation: Navigation) -> Coordinator { + makeProvider(jotFileInfo, navigation) + } +} + +@MainActor +final class JotConflictCoordinatorFactoryMock: JotConflictCoordinatorFactoryProtocol { + + private let makeProvider: + @MainActor ( + _ jotFileInfo: JotFile.Info, + _ jotFileVersions: [JotFileVersion], + _ navigation: Navigation, + _ onResult: @Sendable (_ result: JotConflictResult) -> Void + ) -> Coordinator + + init( + makeProvider: + @MainActor @escaping ( + _ jotFileInfo: JotFile.Info, + _ jotFileVersions: [JotFileVersion], + _ navigation: Navigation, + _ onResult: @Sendable (_ result: JotConflictResult) -> Void + ) -> Coordinator = { _, _, _, _ in CoordinatorMock() } + ) { + self.makeProvider = makeProvider + } + + func make( + jotFileInfo: JotFile.Info, + jotFileVersions: [JotFileVersion], + navigation: Navigation, + onResult: @Sendable @escaping (_ result: JotConflictResult) -> Void + ) -> Coordinator { + makeProvider(jotFileInfo, jotFileVersions, navigation, onResult) + } +} diff --git a/Tests/Mocks/CoordinatorMock.swift b/Tests/Mocks/CoordinatorMock.swift new file mode 100644 index 0000000..f17bbb8 --- /dev/null +++ b/Tests/Mocks/CoordinatorMock.swift @@ -0,0 +1,37 @@ +/* + Jottre: Minimalistic jotting for iPhone, iPad and Mac. + Copyright (C) 2021-2026 Anton Lorani + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +@testable import Jottre + +@MainActor +final class CoordinatorMock: Coordinator { + + var onEnd: (() -> Void)? + + private let startProvider: () -> Void + + init( + startProvider: @escaping () -> Void = {} + ) { + self.startProvider = startProvider + } + + func start() { + startProvider() + } +} diff --git a/Tests/Mocks/CreateJotRepositoryMock.swift b/Tests/Mocks/CreateJotRepositoryMock.swift new file mode 100644 index 0000000..7d5f151 --- /dev/null +++ b/Tests/Mocks/CreateJotRepositoryMock.swift @@ -0,0 +1,44 @@ +/* + Jottre: Minimalistic jotting for iPhone, iPad and Mac. + Copyright (C) 2021-2026 Anton Lorani + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +import Foundation + +@testable import Jottre + +final class CreateJotRepositoryMock: CreateJotRepositoryProtocol { + + private let createJotProvider: @Sendable (_ name: String) async throws -> JotFile.Info + + init( + createJotProvider: + @Sendable @escaping (_ name: String) async throws -> JotFile.Info = { name in + JotFile.Info( + url: URL(fileURLWithPath: "/tmp/\(name).jot"), + name: name, + modificationDate: nil, + ubiquitousInfo: nil + ) + } + ) { + self.createJotProvider = createJotProvider + } + + func createJot(name: String) async throws -> JotFile.Info { + try await createJotProvider(name) + } +} diff --git a/Tests/Mocks/DefaultsServiceMock.swift b/Tests/Mocks/DefaultsServiceMock.swift new file mode 100644 index 0000000..773bd3e --- /dev/null +++ b/Tests/Mocks/DefaultsServiceMock.swift @@ -0,0 +1,66 @@ +/* + Jottre: Minimalistic jotting for iPhone, iPad and Mac. + Copyright (C) 2021-2026 Anton Lorani + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +import Foundation + +@testable import Jottre + +final class DefaultsServiceMock: DefaultsServiceProtocol, @unchecked Sendable { + + private let lock = NSLock() + private var storage: [String: Any] = [:] + private var continuations: [String: [Any]] = [:] + + init(initialValues: [String: any LosslessStringConvertible & Sendable] = [:]) { + storage = initialValues + } + + func getValue(_ defaultsKey: DefaultsKey) -> T? { + lock.withLock { + storage[defaultsKey.description] as? T + } + } + + func set(_ defaultsKey: DefaultsKey, value: T?) { + let listeners: [AsyncStream.Continuation] = lock.withLock { + if let value { + storage[defaultsKey.description] = value + } else { + storage.removeValue(forKey: defaultsKey.description) + } + return (continuations[defaultsKey.description] ?? []) + .compactMap { $0 as? AsyncStream.Continuation } + } + for continuation in listeners { + continuation.yield(value) + } + } + + func getValueStream(_ defaultsKey: DefaultsKey) -> AsyncStream { + AsyncStream { [weak self] continuation in + guard let self else { + continuation.finish() + return + } + continuation.yield(self.getValue(defaultsKey)) + self.lock.withLock { + self.continuations[defaultsKey.description, default: []].append(continuation) + } + } + } +} diff --git a/Tests/Mocks/DeleteJotRepositoryMock.swift b/Tests/Mocks/DeleteJotRepositoryMock.swift new file mode 100644 index 0000000..af087d2 --- /dev/null +++ b/Tests/Mocks/DeleteJotRepositoryMock.swift @@ -0,0 +1,36 @@ +/* + Jottre: Minimalistic jotting for iPhone, iPad and Mac. + Copyright (C) 2021-2026 Anton Lorani + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +import Foundation + +@testable import Jottre + +final class DeleteJotRepositoryMock: DeleteJotRepositoryProtocol { + + private let deleteJotProvider: @Sendable (_ jotFileInfo: JotFile.Info) throws -> Void + + init( + deleteJotProvider: @Sendable @escaping (_ jotFileInfo: JotFile.Info) throws -> Void = { _ in } + ) { + self.deleteJotProvider = deleteJotProvider + } + + func deleteJot(jotFileInfo: JotFile.Info) throws { + try deleteJotProvider(jotFileInfo) + } +} diff --git a/Tests/Mocks/DeviceServiceMock.swift b/Tests/Mocks/DeviceServiceMock.swift new file mode 100644 index 0000000..ee083e3 --- /dev/null +++ b/Tests/Mocks/DeviceServiceMock.swift @@ -0,0 +1,31 @@ +/* + Jottre: Minimalistic jotting for iPhone, iPad and Mac. + Copyright (C) 2021-2026 Anton Lorani + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +@testable import Jottre + +@MainActor +final class DeviceServiceMock: DeviceServiceProtocol { + + private let isIPadOSProvider: @MainActor () -> Bool + + init(isIPadOSProvider: @MainActor @escaping () -> Bool = { false }) { + self.isIPadOSProvider = isIPadOSProvider + } + + func isIPadOS() -> Bool { isIPadOSProvider() } +} diff --git a/Tests/Mocks/EditJotCoordinatorMock.swift b/Tests/Mocks/EditJotCoordinatorMock.swift new file mode 100644 index 0000000..d51af04 --- /dev/null +++ b/Tests/Mocks/EditJotCoordinatorMock.swift @@ -0,0 +1,128 @@ +/* + Jottre: Minimalistic jotting for iPhone, iPad and Mac. + Copyright (C) 2021-2026 Anton Lorani + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +import UIKit + +@testable import Jottre + +@MainActor +final class EditJotCoordinatorMock: EditJotCoordinatorProtocol { + + private let shouldHandleProvider: (_ url: URL) -> Bool + private let handleProvider: (_ url: URL) -> [UIViewController] + private let showShareJotProvider: + (_ jotFileInfo: JotFile.Info, _ format: ShareFormat, _ configurePopoverAnchor: PopoverAnchor?) -> Void + private let showRenameAlertProvider: (_ jotFileInfo: JotFile.Info) -> Void + private let openDeleteJotProvider: (_ jotFileInfo: JotFile.Info) -> Void + private let openJotProvider: (_ jotFileInfo: JotFile.Info) -> Void + private let showInFilesProvider: (_ jotFileInfo: JotFile.Info) -> Void + private let showJotConflictPageProvider: + ( + _ jotFileInfo: JotFile.Info, + _ jotFileVersions: [JotFileVersion], + _ onResult: @Sendable (_ result: JotConflictResult) -> Void + ) -> Void + private let canGoBackProvider: () -> Bool + private let goBackProvider: () -> Void + private let showInfoAlertProvider: (_ title: String, _ message: String) -> Void + + init( + shouldHandleProvider: @escaping (_ url: URL) -> Bool = { _ in false }, + handleProvider: @escaping (_ url: URL) -> [UIViewController] = { _ in [] }, + showShareJotProvider: + @escaping (_ jotFileInfo: JotFile.Info, _ format: ShareFormat, _ configurePopoverAnchor: PopoverAnchor?) -> + Void = { _, _, _ in }, + showRenameAlertProvider: @escaping (_ jotFileInfo: JotFile.Info) -> Void = { _ in }, + openDeleteJotProvider: @escaping (_ jotFileInfo: JotFile.Info) -> Void = { _ in }, + openJotProvider: @escaping (_ jotFileInfo: JotFile.Info) -> Void = { _ in }, + showInFilesProvider: @escaping (_ jotFileInfo: JotFile.Info) -> Void = { _ in }, + showJotConflictPageProvider: + @escaping ( + _ jotFileInfo: JotFile.Info, + _ jotFileVersions: [JotFileVersion], + _ onResult: @Sendable (_ result: JotConflictResult) -> Void + ) -> Void = { _, _, _ in }, + canGoBackProvider: @escaping () -> Bool = { false }, + goBackProvider: @escaping () -> Void = {}, + showInfoAlertProvider: @escaping (_ title: String, _ message: String) -> Void = { _, _ in } + ) { + self.shouldHandleProvider = shouldHandleProvider + self.handleProvider = handleProvider + self.showShareJotProvider = showShareJotProvider + self.showRenameAlertProvider = showRenameAlertProvider + self.openDeleteJotProvider = openDeleteJotProvider + self.openJotProvider = openJotProvider + self.showInFilesProvider = showInFilesProvider + self.showJotConflictPageProvider = showJotConflictPageProvider + self.canGoBackProvider = canGoBackProvider + self.goBackProvider = goBackProvider + self.showInfoAlertProvider = showInfoAlertProvider + } + + func shouldHandle(url: URL) -> Bool { + shouldHandleProvider(url) + } + + func handle(url: URL) -> [UIViewController] { + handleProvider(url) + } + + func showShareJot( + jotFileInfo: JotFile.Info, + format: ShareFormat, + configurePopoverAnchor: PopoverAnchor? + ) { + showShareJotProvider(jotFileInfo, format, configurePopoverAnchor) + } + + func showRenameAlert(jotFileInfo: JotFile.Info) { + showRenameAlertProvider(jotFileInfo) + } + + func openDeleteJot(jotFileInfo: JotFile.Info) { + openDeleteJotProvider(jotFileInfo) + } + + func openJot(jotFileInfo: JotFile.Info) { + openJotProvider(jotFileInfo) + } + + func showInFiles(jotFileInfo: JotFile.Info) { + showInFilesProvider(jotFileInfo) + } + + func showJotConflictPage( + jotFileInfo: JotFile.Info, + jotFileVersions: [JotFileVersion], + onResult: @Sendable @escaping (_ result: JotConflictResult) -> Void + ) { + showJotConflictPageProvider(jotFileInfo, jotFileVersions, onResult) + } + + func canGoBack() -> Bool { + canGoBackProvider() + } + + func goBack() { + goBackProvider() + } + + func showInfoAlert(title: String, message: String) { + showInfoAlertProvider(title, message) + } +} diff --git a/Tests/Mocks/EditJotRepositoryMock.swift b/Tests/Mocks/EditJotRepositoryMock.swift new file mode 100644 index 0000000..5023d9c --- /dev/null +++ b/Tests/Mocks/EditJotRepositoryMock.swift @@ -0,0 +1,75 @@ +/* + Jottre: Minimalistic jotting for iPhone, iPad and Mac. + Copyright (C) 2021-2026 Anton Lorani + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +import CoreGraphics +import Foundation +@preconcurrency import PencilKit + +@testable import Jottre + +final class EditJotRepositoryMock: EditJotRepositoryProtocol { + + private let ubiquitousInfoProvider: @Sendable (_ url: URL) -> UbiquitousInfo? + private let readDrawingProvider: + @Sendable (_ jotFileInfo: JotFile.Info) async throws -> (drawing: PKDrawing, width: CGFloat) + private let writeDrawingProvider: @Sendable (_ jotFileInfo: JotFile.Info, _ drawing: PKDrawing) async throws -> Void + private let getConflictingVersionsProvider: @Sendable (_ jotFileInfo: JotFile.Info) -> [JotFileVersion]? + private let duplicateProvider: @Sendable (_ jotFileInfo: JotFile.Info) throws -> JotFile.Info + + init( + ubiquitousInfoProvider: @Sendable @escaping (_ url: URL) -> UbiquitousInfo? = { _ in nil }, + readDrawingProvider: + @Sendable @escaping (_ jotFileInfo: JotFile.Info) async throws -> (drawing: PKDrawing, width: CGFloat) = { + _ in (PKDrawing(), 800) + }, + writeDrawingProvider: + @Sendable @escaping (_ jotFileInfo: JotFile.Info, _ drawing: PKDrawing) async throws -> Void = { _, _ in }, + getConflictingVersionsProvider: @Sendable @escaping (_ jotFileInfo: JotFile.Info) -> [JotFileVersion]? = { _ in + nil + }, + duplicateProvider: @Sendable @escaping (_ jotFileInfo: JotFile.Info) throws -> JotFile.Info = { jotFileInfo in + jotFileInfo + } + ) { + self.ubiquitousInfoProvider = ubiquitousInfoProvider + self.readDrawingProvider = readDrawingProvider + self.writeDrawingProvider = writeDrawingProvider + self.getConflictingVersionsProvider = getConflictingVersionsProvider + self.duplicateProvider = duplicateProvider + } + + func ubiquitousInfo(url: URL) -> UbiquitousInfo? { + ubiquitousInfoProvider(url) + } + + func readDrawing(jotFileInfo: JotFile.Info) async throws -> (drawing: PKDrawing, width: CGFloat) { + try await readDrawingProvider(jotFileInfo) + } + + func writeDrawing(jotFileInfo: JotFile.Info, drawing: PKDrawing) async throws { + try await writeDrawingProvider(jotFileInfo, drawing) + } + + func getConflictingVersions(jotFileInfo: JotFile.Info) -> [JotFileVersion]? { + getConflictingVersionsProvider(jotFileInfo) + } + + func duplicate(jotFileInfo: JotFile.Info) throws -> JotFile.Info { + try duplicateProvider(jotFileInfo) + } +} diff --git a/Tests/Mocks/EditJotViewControllerFactoryMock.swift b/Tests/Mocks/EditJotViewControllerFactoryMock.swift new file mode 100644 index 0000000..03f2027 --- /dev/null +++ b/Tests/Mocks/EditJotViewControllerFactoryMock.swift @@ -0,0 +1,40 @@ +/* + Jottre: Minimalistic jotting for iPhone, iPad and Mac. + Copyright (C) 2021-2026 Anton Lorani + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +import UIKit + +@testable import Jottre + +@MainActor +final class EditJotViewControllerFactoryMock: EditJotViewControllerFactoryProtocol { + + private let makeProvider: + @MainActor (_ jotFileInfo: JotFile.Info, _ coordinator: EditJotCoordinatorProtocol) -> UIViewController + + init( + makeProvider: + @MainActor @escaping (_ jotFileInfo: JotFile.Info, _ coordinator: EditJotCoordinatorProtocol) -> + UIViewController = { _, _ in UIViewController() } + ) { + self.makeProvider = makeProvider + } + + func make(jotFileInfo: JotFile.Info, coordinator: EditJotCoordinatorProtocol) -> UIViewController { + makeProvider(jotFileInfo, coordinator) + } +} diff --git a/Tests/Mocks/EnableCloudCoordinatorMock.swift b/Tests/Mocks/EnableCloudCoordinatorMock.swift new file mode 100644 index 0000000..a69011c --- /dev/null +++ b/Tests/Mocks/EnableCloudCoordinatorMock.swift @@ -0,0 +1,51 @@ +/* + Jottre: Minimalistic jotting for iPhone, iPad and Mac. + Copyright (C) 2021-2026 Anton Lorani + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +@testable import Jottre + +@MainActor +final class EnableCloudCoordinatorMock: EnableCloudCoordinatorProtocol { + + var onEnd: (() -> Void)? + + private let startProvider: () -> Void + private let openLearnHowToEnableProvider: () -> Void + private let dismissProvider: () -> Void + + init( + startProvider: @escaping () -> Void = {}, + openLearnHowToEnableProvider: @escaping () -> Void = {}, + dismissProvider: @escaping () -> Void = {} + ) { + self.startProvider = startProvider + self.openLearnHowToEnableProvider = openLearnHowToEnableProvider + self.dismissProvider = dismissProvider + } + + func start() { + startProvider() + } + + func openLearnHowToEnable() { + openLearnHowToEnableProvider() + } + + func dismiss() { + dismissProvider() + } +} diff --git a/Tests/Mocks/EnableCloudViewControllerFactoryMock.swift b/Tests/Mocks/EnableCloudViewControllerFactoryMock.swift new file mode 100644 index 0000000..220f083 --- /dev/null +++ b/Tests/Mocks/EnableCloudViewControllerFactoryMock.swift @@ -0,0 +1,39 @@ +/* + Jottre: Minimalistic jotting for iPhone, iPad and Mac. + Copyright (C) 2021-2026 Anton Lorani + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +import UIKit + +@testable import Jottre + +@MainActor +final class EnableCloudViewControllerFactoryMock: EnableCloudViewControllerFactoryProtocol { + + private let makeProvider: @MainActor (_ coordinator: EnableCloudCoordinatorProtocol) -> UIViewController + + init( + makeProvider: @MainActor @escaping (_ coordinator: EnableCloudCoordinatorProtocol) -> UIViewController = { _ in + UIViewController() + } + ) { + self.makeProvider = makeProvider + } + + func make(coordinator: EnableCloudCoordinatorProtocol) -> UIViewController { + makeProvider(coordinator) + } +} diff --git a/Tests/Mocks/FileConflictServiceMock.swift b/Tests/Mocks/FileConflictServiceMock.swift new file mode 100644 index 0000000..9787476 --- /dev/null +++ b/Tests/Mocks/FileConflictServiceMock.swift @@ -0,0 +1,52 @@ +/* + Jottre: Minimalistic jotting for iPhone, iPad and Mac. + Copyright (C) 2021-2026 Anton Lorani + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +import Foundation + +@testable import Jottre + +final class FileConflictServiceMock: FileConflictServiceProtocol { + + private let getConflictingVersionsProvider: @Sendable (_ fileURL: URL) -> [NSFileVersion]? + private let resolveVersionConflictsProvider: @Sendable (_ fileURL: URL, _ resolvedVersions: [URL]) throws -> Void + private let copyVersionToTemporaryProvider: @Sendable (_ fileURL: URL, _ versionURL: URL) throws -> URL? + + init( + getConflictingVersionsProvider: @Sendable @escaping (_ fileURL: URL) -> [NSFileVersion]? = { _ in nil }, + resolveVersionConflictsProvider: + @Sendable @escaping (_ fileURL: URL, _ resolvedVersions: [URL]) throws -> Void = { _, _ in }, + copyVersionToTemporaryProvider: + @Sendable @escaping (_ fileURL: URL, _ versionURL: URL) throws -> URL? = { _, _ in nil } + ) { + self.getConflictingVersionsProvider = getConflictingVersionsProvider + self.resolveVersionConflictsProvider = resolveVersionConflictsProvider + self.copyVersionToTemporaryProvider = copyVersionToTemporaryProvider + } + + func getConflictingVersions(fileURL: URL) -> [NSFileVersion]? { + getConflictingVersionsProvider(fileURL) + } + + func resolveVersionConflicts(fileURL: URL, resolvedVersions: [URL]) throws { + try resolveVersionConflictsProvider(fileURL, resolvedVersions) + } + + func copyVersionToTemporary(fileURL: URL, versionURL: URL) throws -> URL? { + try copyVersionToTemporaryProvider(fileURL, versionURL) + } +} diff --git a/Tests/Mocks/FileServiceMock.swift b/Tests/Mocks/FileServiceMock.swift new file mode 100644 index 0000000..118297b --- /dev/null +++ b/Tests/Mocks/FileServiceMock.swift @@ -0,0 +1,93 @@ +/* + Jottre: Minimalistic jotting for iPhone, iPad and Mac. + Copyright (C) 2021-2026 Anton Lorani + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +import Foundation + +@testable import Jottre + +final class FileServiceMock: FileServiceProtocol { + + private let isEnabledProvider: @Sendable () -> Bool + private let initializeDocumentsDirectoryProvider: @Sendable () async throws -> Void + private let documentsDirectoryProvider: @Sendable () async throws -> URL? + private let temporaryDirectoryProvider: @Sendable () -> URL + private let listContentsProvider: @Sendable (_ directory: URL, _ properties: [URLResourceKey]) throws -> [URL] + private let ubiquitousInfoProvider: @Sendable (_ url: URL) -> UbiquitousInfo? + private let startDownloadProvider: @Sendable (_ fileURL: URL) throws -> Void + private let directoryChangesProvider: @Sendable (_ directory: URL) -> AsyncStream + private let readFileProvider: @Sendable (_ fileURL: URL) throws -> Data + private let writeFileProvider: @Sendable (_ fileURL: URL, _ data: Data) throws -> Void + private let fileExistsProvider: @Sendable (_ fileURL: URL) -> Bool + private let removeFileProvider: @Sendable (_ fileURL: URL) throws -> Void + private let moveFileProvider: @Sendable (_ fileURL: URL, _ newFileURL: URL) throws -> Void + private let duplicateFileProvider: @Sendable (_ fileURL: URL) throws -> URL + + init( + isEnabledProvider: @Sendable @escaping () -> Bool = { true }, + initializeDocumentsDirectoryProvider: @Sendable @escaping () async throws -> Void = {}, + documentsDirectoryProvider: @Sendable @escaping () async throws -> URL? = { nil }, + temporaryDirectoryProvider: @Sendable @escaping () -> URL = { URL(fileURLWithPath: NSTemporaryDirectory()) }, + listContentsProvider: @Sendable @escaping (_ directory: URL, _ properties: [URLResourceKey]) throws -> [URL] = { + _, + _ in [] + }, + ubiquitousInfoProvider: @Sendable @escaping (_ url: URL) -> UbiquitousInfo? = { _ in nil }, + startDownloadProvider: @Sendable @escaping (_ fileURL: URL) throws -> Void = { _ in }, + directoryChangesProvider: @Sendable @escaping (_ directory: URL) -> AsyncStream = { _ in + AsyncStream { $0.finish() } + }, + readFileProvider: @Sendable @escaping (_ fileURL: URL) throws -> Data = { _ in Data() }, + writeFileProvider: @Sendable @escaping (_ fileURL: URL, _ data: Data) throws -> Void = { _, _ in }, + fileExistsProvider: @Sendable @escaping (_ fileURL: URL) -> Bool = { _ in false }, + removeFileProvider: @Sendable @escaping (_ fileURL: URL) throws -> Void = { _ in }, + moveFileProvider: @Sendable @escaping (_ fileURL: URL, _ newFileURL: URL) throws -> Void = { _, _ in }, + duplicateFileProvider: @Sendable @escaping (_ fileURL: URL) throws -> URL = { $0 } + ) { + self.isEnabledProvider = isEnabledProvider + self.initializeDocumentsDirectoryProvider = initializeDocumentsDirectoryProvider + self.documentsDirectoryProvider = documentsDirectoryProvider + self.temporaryDirectoryProvider = temporaryDirectoryProvider + self.listContentsProvider = listContentsProvider + self.ubiquitousInfoProvider = ubiquitousInfoProvider + self.startDownloadProvider = startDownloadProvider + self.directoryChangesProvider = directoryChangesProvider + self.readFileProvider = readFileProvider + self.writeFileProvider = writeFileProvider + self.fileExistsProvider = fileExistsProvider + self.removeFileProvider = removeFileProvider + self.moveFileProvider = moveFileProvider + self.duplicateFileProvider = duplicateFileProvider + } + + func isEnabled() -> Bool { isEnabledProvider() } + func initializeDocumentsDirectory() async throws { try await initializeDocumentsDirectoryProvider() } + func documentsDirectory() async throws -> URL? { try await documentsDirectoryProvider() } + func temporaryDirectory() -> URL { temporaryDirectoryProvider() } + func listContents(directory: URL, properties: [URLResourceKey]) throws -> [URL] { + try listContentsProvider(directory, properties) + } + func ubiquitousInfo(url: URL) -> UbiquitousInfo? { ubiquitousInfoProvider(url) } + func startDownload(fileURL: URL) throws { try startDownloadProvider(fileURL) } + func directoryChanges(directory: URL) -> AsyncStream { directoryChangesProvider(directory) } + func readFile(fileURL: URL) throws -> Data { try readFileProvider(fileURL) } + func writeFile(fileURL: URL, data: Data) throws { try writeFileProvider(fileURL, data) } + func fileExists(fileURL: URL) -> Bool { fileExistsProvider(fileURL) } + func removeFile(fileURL: URL) throws { try removeFileProvider(fileURL) } + func moveFile(fileURL: URL, newFileURL: URL) throws { try moveFileProvider(fileURL, newFileURL) } + func duplicateFile(fileURL: URL) throws -> URL { try duplicateFileProvider(fileURL) } +} diff --git a/Tests/Mocks/JotConflictCoordinatorMock.swift b/Tests/Mocks/JotConflictCoordinatorMock.swift new file mode 100644 index 0000000..03b68a1 --- /dev/null +++ b/Tests/Mocks/JotConflictCoordinatorMock.swift @@ -0,0 +1,51 @@ +/* + Jottre: Minimalistic jotting for iPhone, iPad and Mac. + Copyright (C) 2021-2026 Anton Lorani + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +@testable import Jottre + +@MainActor +final class JotConflictCoordinatorMock: JotConflictCoordinatorProtocol { + + var onEnd: (() -> Void)? + + private let startProvider: () -> Void + private let showInfoAlertProvider: (_ title: String, _ message: String) -> Void + private let dismissProvider: (_ completion: @Sendable () -> Void) -> Void + + init( + startProvider: @escaping () -> Void = {}, + showInfoAlertProvider: @escaping (_ title: String, _ message: String) -> Void = { _, _ in }, + dismissProvider: @escaping (_ completion: @Sendable () -> Void) -> Void = { _ in } + ) { + self.startProvider = startProvider + self.showInfoAlertProvider = showInfoAlertProvider + self.dismissProvider = dismissProvider + } + + func start() { + startProvider() + } + + func showInfoAlert(title: String, message: String) { + showInfoAlertProvider(title, message) + } + + func dismiss(completion: @Sendable @escaping () -> Void) { + dismissProvider(completion) + } +} diff --git a/Tests/Mocks/JotConflictRepositoryMock.swift b/Tests/Mocks/JotConflictRepositoryMock.swift new file mode 100644 index 0000000..4cdf2c0 --- /dev/null +++ b/Tests/Mocks/JotConflictRepositoryMock.swift @@ -0,0 +1,69 @@ +/* + Jottre: Minimalistic jotting for iPhone, iPad and Mac. + Copyright (C) 2021-2026 Anton Lorani + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +import Foundation +import UIKit + +@testable import Jottre + +final class JotConflictRepositoryMock: JotConflictRepositoryProtocol { + + private let resolveVersionConflictsProvider: + @Sendable (_ jotFileInfo: JotFile.Info, _ resolvedVersions: [JotFileVersion]) throws -> Void + private let getPreviewImageProvider: + @Sendable ( + _ jotFileInfo: JotFile.Info, + _ jotFileVersion: JotFileVersion, + _ userInterfaceStyle: UIUserInterfaceStyle, + _ displayScale: CGFloat + ) async -> UIImage? + + init( + resolveVersionConflictsProvider: + @Sendable @escaping (_ jotFileInfo: JotFile.Info, _ resolvedVersions: [JotFileVersion]) throws -> Void = { + _, + _ in + }, + getPreviewImageProvider: + @Sendable @escaping ( + _ jotFileInfo: JotFile.Info, + _ jotFileVersion: JotFileVersion, + _ userInterfaceStyle: UIUserInterfaceStyle, + _ displayScale: CGFloat + ) async -> UIImage? = { _, _, _, _ in nil } + ) { + self.resolveVersionConflictsProvider = resolveVersionConflictsProvider + self.getPreviewImageProvider = getPreviewImageProvider + } + + func resolveVersionConflicts( + jotFileInfo: JotFile.Info, + resolvedVersions: [JotFileVersion] + ) throws { + try resolveVersionConflictsProvider(jotFileInfo, resolvedVersions) + } + + func getPreviewImage( + jotFileInfo: JotFile.Info, + jotFileVersion: JotFileVersion, + userInterfaceStyle: UIUserInterfaceStyle, + displayScale: CGFloat + ) async -> UIImage? { + await getPreviewImageProvider(jotFileInfo, jotFileVersion, userInterfaceStyle, displayScale) + } +} diff --git a/Tests/Mocks/JotConflictViewControllerFactoryMock.swift b/Tests/Mocks/JotConflictViewControllerFactoryMock.swift new file mode 100644 index 0000000..4788cba --- /dev/null +++ b/Tests/Mocks/JotConflictViewControllerFactoryMock.swift @@ -0,0 +1,39 @@ +/* + Jottre: Minimalistic jotting for iPhone, iPad and Mac. + Copyright (C) 2021-2026 Anton Lorani + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +import UIKit + +@testable import Jottre + +@MainActor +final class JotConflictViewControllerFactoryMock: JotConflictViewControllerFactoryProtocol { + + private let makeProvider: @MainActor (_ viewModel: JotConflictViewModel) -> UIViewController + + init( + makeProvider: @MainActor @escaping (_ viewModel: JotConflictViewModel) -> UIViewController = { _ in + UIViewController() + } + ) { + self.makeProvider = makeProvider + } + + func make(viewModel: JotConflictViewModel) -> UIViewController { + makeProvider(viewModel) + } +} diff --git a/Tests/Mocks/JotFileConflictServiceMock.swift b/Tests/Mocks/JotFileConflictServiceMock.swift new file mode 100644 index 0000000..43e0ff4 --- /dev/null +++ b/Tests/Mocks/JotFileConflictServiceMock.swift @@ -0,0 +1,56 @@ +/* + Jottre: Minimalistic jotting for iPhone, iPad and Mac. + Copyright (C) 2021-2026 Anton Lorani + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +@testable import Jottre + +final class JotFileConflictServiceMock: JotFileConflictServiceProtocol { + + private let getConfictingVersionsProvider: @Sendable (_ jotFileInfo: JotFile.Info) -> [JotFileVersion]? + private let resolveVersionConflictsProvider: + @Sendable (_ jotFileInfo: JotFile.Info, _ resolvedVersions: [JotFileVersion]) throws -> Void + private let copyVersionToTemporaryProvider: + @Sendable (_ jotFileInfo: JotFile.Info, _ jotFileVersion: JotFileVersion) throws -> JotFile.Info? + + init( + getConfictingVersionsProvider: @Sendable @escaping (_ jotFileInfo: JotFile.Info) -> [JotFileVersion]? = { _ in + nil + }, + resolveVersionConflictsProvider: + @Sendable @escaping (_ jotFileInfo: JotFile.Info, _ resolvedVersions: [JotFileVersion]) throws -> Void = { + _, + _ in + }, + copyVersionToTemporaryProvider: + @Sendable @escaping (_ jotFileInfo: JotFile.Info, _ jotFileVersion: JotFileVersion) throws -> JotFile.Info? = + { _, _ in nil } + ) { + self.getConfictingVersionsProvider = getConfictingVersionsProvider + self.resolveVersionConflictsProvider = resolveVersionConflictsProvider + self.copyVersionToTemporaryProvider = copyVersionToTemporaryProvider + } + + func getConfictingVersions(jotFileInfo: JotFile.Info) -> [JotFileVersion]? { + getConfictingVersionsProvider(jotFileInfo) + } + func resolveVersionConflicts(jotFileInfo: JotFile.Info, resolvedVersions: [JotFileVersion]) throws { + try resolveVersionConflictsProvider(jotFileInfo, resolvedVersions) + } + func copyVersionToTemporary(jotFileInfo: JotFile.Info, jotFileVersion: JotFileVersion) throws -> JotFile.Info? { + try copyVersionToTemporaryProvider(jotFileInfo, jotFileVersion) + } +} diff --git a/Tests/Mocks/JotFilePreviewImageServiceMock.swift b/Tests/Mocks/JotFilePreviewImageServiceMock.swift new file mode 100644 index 0000000..8c64a0d --- /dev/null +++ b/Tests/Mocks/JotFilePreviewImageServiceMock.swift @@ -0,0 +1,51 @@ +/* + Jottre: Minimalistic jotting for iPhone, iPad and Mac. + Copyright (C) 2021-2026 Anton Lorani + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +import Foundation +import UIKit + +@testable import Jottre + +final class JotFilePreviewImageServiceMock: JotFilePreviewImageServiceProtocol { + + private let getPreviewImageDataProvider: + @Sendable ( + _ jotFileInfo: JotFile.Info, + _ userInterfaceStyle: UIUserInterfaceStyle, + _ displayScale: CGFloat + ) async throws -> Data + + init( + getPreviewImageDataProvider: + @Sendable @escaping ( + _ jotFileInfo: JotFile.Info, + _ userInterfaceStyle: UIUserInterfaceStyle, + _ displayScale: CGFloat + ) async throws -> Data = { _, _, _ in Data() } + ) { + self.getPreviewImageDataProvider = getPreviewImageDataProvider + } + + func getPreviewImageData( + jotFileInfo: JotFile.Info, + userInterfaceStyle: UIUserInterfaceStyle, + displayScale: CGFloat + ) async throws -> Data { + try await getPreviewImageDataProvider(jotFileInfo, userInterfaceStyle, displayScale) + } +} diff --git a/Tests/Mocks/JotFileServiceMock.swift b/Tests/Mocks/JotFileServiceMock.swift new file mode 100644 index 0000000..d085d7e --- /dev/null +++ b/Tests/Mocks/JotFileServiceMock.swift @@ -0,0 +1,84 @@ +/* + Jottre: Minimalistic jotting for iPhone, iPad and Mac. + Copyright (C) 2021-2026 Anton Lorani + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +import Foundation + +@testable import Jottre + +final class JotFileServiceMock: JotFileServiceProtocol { + + private let documentsDirectoryContentsProvider: @Sendable () -> AsyncThrowingStream<[JotFile.Info], Error> + private let readJotFileProvider: @Sendable (_ jotFileInfo: JotFile.Info) throws -> JotFile + private let writeProvider: @Sendable (_ jotFile: JotFile) throws -> Void + private let duplicateProvider: @Sendable (_ jotFileInfo: JotFile.Info) throws -> JotFile.Info + private let renameProvider: @Sendable (_ jotFileInfo: JotFile.Info, _ newName: String) throws -> JotFile.Info + private let removeProvider: @Sendable (_ jotFileInfo: JotFile.Info) throws -> Void + private let moveProvider: + @Sendable (_ jotFileInfo: JotFile.Info, _ shouldBecomeUbiquitous: Bool) async throws -> Void + + init( + documentsDirectoryContentsProvider: @Sendable @escaping () -> AsyncThrowingStream<[JotFile.Info], Error> = { + AsyncThrowingStream { $0.finish() } + }, + readJotFileProvider: @Sendable @escaping (_ jotFileInfo: JotFile.Info) throws -> JotFile = { info in + JotFile(info: info, jot: Jot.makeEmpty()) + }, + writeProvider: @Sendable @escaping (_ jotFile: JotFile) throws -> Void = { _ in }, + duplicateProvider: @Sendable @escaping (_ jotFileInfo: JotFile.Info) throws -> JotFile.Info = { $0 }, + renameProvider: @Sendable @escaping (_ jotFileInfo: JotFile.Info, _ newName: String) throws -> JotFile.Info = { + info, + _ in info + }, + removeProvider: @Sendable @escaping (_ jotFileInfo: JotFile.Info) throws -> Void = { _ in }, + moveProvider: + @Sendable @escaping (_ jotFileInfo: JotFile.Info, _ shouldBecomeUbiquitous: Bool) async throws -> Void = { + _, + _ in + } + ) { + self.documentsDirectoryContentsProvider = documentsDirectoryContentsProvider + self.readJotFileProvider = readJotFileProvider + self.writeProvider = writeProvider + self.duplicateProvider = duplicateProvider + self.renameProvider = renameProvider + self.removeProvider = removeProvider + self.moveProvider = moveProvider + } + + func documentsDirectoryContents() -> AsyncThrowingStream<[JotFile.Info], Error> { + documentsDirectoryContentsProvider() + } + func readJotFile(jotFileInfo: JotFile.Info) throws -> JotFile { + try readJotFileProvider(jotFileInfo) + } + func write(jotFile: JotFile) throws { + try writeProvider(jotFile) + } + func duplicate(jotFileInfo: JotFile.Info) throws -> JotFile.Info { + try duplicateProvider(jotFileInfo) + } + func rename(jotFileInfo: JotFile.Info, newName: String) throws -> JotFile.Info { + try renameProvider(jotFileInfo, newName) + } + func remove(jotFileInfo: JotFile.Info) throws { + try removeProvider(jotFileInfo) + } + func move(jotFileInfo: JotFile.Info, shouldBecomeUbiquitous: Bool) async throws { + try await moveProvider(jotFileInfo, shouldBecomeUbiquitous) + } +} diff --git a/Tests/Mocks/JotsCoordinatorMock.swift b/Tests/Mocks/JotsCoordinatorMock.swift new file mode 100644 index 0000000..a93a392 --- /dev/null +++ b/Tests/Mocks/JotsCoordinatorMock.swift @@ -0,0 +1,100 @@ +/* + Jottre: Minimalistic jotting for iPhone, iPad and Mac. + Copyright (C) 2021-2026 Anton Lorani + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +import UIKit + +@testable import Jottre + +@MainActor +final class JotsCoordinatorMock: JotsCoordinatorProtocol { + + private let shouldHandleProvider: (_ url: URL) -> Bool + private let handleProvider: (_ url: URL) -> [UIViewController] + private let openSettingsProvider: () -> Void + private let openCreateJotProvider: () -> Void + private let openJotProvider: (_ jotFileInfo: JotFile.Info, _ prefersNewWindow: Bool) -> Void + private let openEnableCloudPageProvider: () -> Void + private let showShareJotProvider: + (_ jotFileInfo: JotFile.Info, _ format: ShareFormat, _ configurePopoverAnchor: PopoverAnchor?) -> Void + private let showRenameAlertProvider: (_ jotFileInfo: JotFile.Info) -> Void + private let openDeleteJotProvider: (_ jotFileInfo: JotFile.Info) -> Void + private let showInfoAlertProvider: (_ title: String, _ message: String) -> Void + private let showInFilesProvider: (_ jotFileInfo: JotFile.Info) -> Void + + init( + shouldHandleProvider: @escaping (_ url: URL) -> Bool = { _ in false }, + handleProvider: @escaping (_ url: URL) -> [UIViewController] = { _ in [] }, + openSettingsProvider: @escaping () -> Void = {}, + openCreateJotProvider: @escaping () -> Void = {}, + openJotProvider: @escaping (_ jotFileInfo: JotFile.Info, _ prefersNewWindow: Bool) -> Void = { _, _ in }, + openEnableCloudPageProvider: @escaping () -> Void = {}, + showShareJotProvider: + @escaping (_ jotFileInfo: JotFile.Info, _ format: ShareFormat, _ configurePopoverAnchor: PopoverAnchor?) -> + Void = { _, _, _ in }, + showRenameAlertProvider: @escaping (_ jotFileInfo: JotFile.Info) -> Void = { _ in }, + openDeleteJotProvider: @escaping (_ jotFileInfo: JotFile.Info) -> Void = { _ in }, + showInfoAlertProvider: @escaping (_ title: String, _ message: String) -> Void = { _, _ in }, + showInFilesProvider: @escaping (_ jotFileInfo: JotFile.Info) -> Void = { _ in } + ) { + self.shouldHandleProvider = shouldHandleProvider + self.handleProvider = handleProvider + self.openSettingsProvider = openSettingsProvider + self.openCreateJotProvider = openCreateJotProvider + self.openJotProvider = openJotProvider + self.openEnableCloudPageProvider = openEnableCloudPageProvider + self.showShareJotProvider = showShareJotProvider + self.showRenameAlertProvider = showRenameAlertProvider + self.openDeleteJotProvider = openDeleteJotProvider + self.showInfoAlertProvider = showInfoAlertProvider + self.showInFilesProvider = showInFilesProvider + } + + func shouldHandle(url: URL) -> Bool { + shouldHandleProvider(url) + } + + func handle(url: URL) -> [UIViewController] { + handleProvider(url) + } + + func openSettings() { openSettingsProvider() } + func openCreateJot() { openCreateJotProvider() } + func openJot(jotFileInfo: JotFile.Info, prefersNewWindow: Bool) { + openJotProvider(jotFileInfo, prefersNewWindow) + } + func openEnableCloudPage() { openEnableCloudPageProvider() } + func showShareJot( + jotFileInfo: JotFile.Info, + format: ShareFormat, + configurePopoverAnchor: PopoverAnchor? + ) { + showShareJotProvider(jotFileInfo, format, configurePopoverAnchor) + } + func showRenameAlert(jotFileInfo: JotFile.Info) { + showRenameAlertProvider(jotFileInfo) + } + func openDeleteJot(jotFileInfo: JotFile.Info) { + openDeleteJotProvider(jotFileInfo) + } + func showInfoAlert(title: String, message: String) { + showInfoAlertProvider(title, message) + } + func showInFiles(jotFileInfo: JotFile.Info) { + showInFilesProvider(jotFileInfo) + } +} diff --git a/Tests/Mocks/JotsRepositoryMock.swift b/Tests/Mocks/JotsRepositoryMock.swift new file mode 100644 index 0000000..96af65d --- /dev/null +++ b/Tests/Mocks/JotsRepositoryMock.swift @@ -0,0 +1,97 @@ +/* + Jottre: Minimalistic jotting for iPhone, iPad and Mac. + Copyright (C) 2021-2026 Anton Lorani + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +import Foundation +import UIKit + +@testable import Jottre + +final class JotsRepositoryMock: JotsRepositoryProtocol { + + private let getJotFilesProvider: @Sendable () -> AsyncThrowingStream<[JotFile.Info], Error> + private let shouldShowEnableICloudButtonProvider: @Sendable () -> Bool + private let duplicateProvider: @Sendable (_ jotFileInfo: JotFile.Info) throws -> JotFile.Info + private let downloadProvider: @Sendable (_ jotFileInfo: JotFile.Info) throws -> Void + private let getPreviewImageProvider: + @Sendable ( + _ jotFileInfo: JotFile.Info, + _ userInterfaceStyle: UIUserInterfaceStyle, + _ displayScale: CGFloat + ) async -> UIImage? + private let supportsMultipleScenesProvider: @MainActor @Sendable () -> Bool + private let isIPadOSProvider: @MainActor @Sendable () -> Bool + + init( + getJotFilesProvider: @Sendable @escaping () -> AsyncThrowingStream<[JotFile.Info], Error> = { + AsyncThrowingStream { $0.finish() } + }, + shouldShowEnableICloudButtonProvider: @Sendable @escaping () -> Bool = { false }, + duplicateProvider: @Sendable @escaping (_ jotFileInfo: JotFile.Info) throws -> JotFile.Info = { $0 }, + downloadProvider: @Sendable @escaping (_ jotFileInfo: JotFile.Info) throws -> Void = { _ in }, + getPreviewImageProvider: + @Sendable @escaping ( + _ jotFileInfo: JotFile.Info, + _ userInterfaceStyle: UIUserInterfaceStyle, + _ displayScale: CGFloat + ) async -> UIImage? = { _, _, _ in nil }, + supportsMultipleScenesProvider: @MainActor @Sendable @escaping () -> Bool = { false }, + isIPadOSProvider: @MainActor @Sendable @escaping () -> Bool = { false } + ) { + self.getJotFilesProvider = getJotFilesProvider + self.shouldShowEnableICloudButtonProvider = shouldShowEnableICloudButtonProvider + self.duplicateProvider = duplicateProvider + self.downloadProvider = downloadProvider + self.getPreviewImageProvider = getPreviewImageProvider + self.supportsMultipleScenesProvider = supportsMultipleScenesProvider + self.isIPadOSProvider = isIPadOSProvider + } + + func getJotFiles() -> AsyncThrowingStream<[JotFile.Info], Error> { + getJotFilesProvider() + } + + func shouldShowEnableICloudButton() -> Bool { + shouldShowEnableICloudButtonProvider() + } + + func duplicate(jotFileInfo: JotFile.Info) throws -> JotFile.Info { + try duplicateProvider(jotFileInfo) + } + + func download(jotFileInfo: JotFile.Info) throws { + try downloadProvider(jotFileInfo) + } + + func getPreviewImage( + jotFileInfo: JotFile.Info, + userInterfaceStyle: UIUserInterfaceStyle, + displayScale: CGFloat + ) async -> UIImage? { + await getPreviewImageProvider(jotFileInfo, userInterfaceStyle, displayScale) + } + + @MainActor + func supportsMultipleScenes() -> Bool { + supportsMultipleScenesProvider() + } + + @MainActor + func isIPadOS() -> Bool { + isIPadOSProvider() + } +} diff --git a/Tests/Mocks/JotsViewControllerFactoryMock.swift b/Tests/Mocks/JotsViewControllerFactoryMock.swift new file mode 100644 index 0000000..27c545f --- /dev/null +++ b/Tests/Mocks/JotsViewControllerFactoryMock.swift @@ -0,0 +1,39 @@ +/* + Jottre: Minimalistic jotting for iPhone, iPad and Mac. + Copyright (C) 2021-2026 Anton Lorani + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +import UIKit + +@testable import Jottre + +@MainActor +final class JotsViewControllerFactoryMock: JotsViewControllerFactoryProtocol { + + private let makeProvider: @MainActor (_ coordinator: JotsCoordinatorProtocol) -> UIViewController + + init( + makeProvider: @MainActor @escaping (_ coordinator: JotsCoordinatorProtocol) -> UIViewController = { _ in + UIViewController() + } + ) { + self.makeProvider = makeProvider + } + + func make(coordinator: JotsCoordinatorProtocol) -> UIViewController { + makeProvider(coordinator) + } +} diff --git a/Tests/Mocks/PageCoordinatorFactoryMocks.swift b/Tests/Mocks/PageCoordinatorFactoryMocks.swift new file mode 100644 index 0000000..b9edb0e --- /dev/null +++ b/Tests/Mocks/PageCoordinatorFactoryMocks.swift @@ -0,0 +1,113 @@ +/* + Jottre: Minimalistic jotting for iPhone, iPad and Mac. + Copyright (C) 2021-2026 Anton Lorani + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +import UIKit + +@testable import Jottre + +@MainActor +final class SettingsCoordinatorFactoryMock: SettingsCoordinatorFactoryProtocol { + + private let makeProvider: @MainActor (_ navigation: Navigation) -> Coordinator + + init( + makeProvider: @MainActor @escaping (_ navigation: Navigation) -> Coordinator = { _ in CoordinatorMock() } + ) { + self.makeProvider = makeProvider + } + + func make(navigation: Navigation) -> Coordinator { + makeProvider(navigation) + } +} + +@MainActor +final class EnableCloudCoordinatorFactoryMock: EnableCloudCoordinatorFactoryProtocol { + + private let makeProvider: @MainActor (_ navigation: Navigation) -> Coordinator + + init( + makeProvider: @MainActor @escaping (_ navigation: Navigation) -> Coordinator = { _ in CoordinatorMock() } + ) { + self.makeProvider = makeProvider + } + + func make(navigation: Navigation) -> Coordinator { + makeProvider(navigation) + } +} + +@MainActor +final class EditJotCoordinatorFactoryMock: EditJotCoordinatorFactoryProtocol { + + private let makeProvider: @MainActor (_ navigation: Navigation) -> NavigationCoordinator + + init( + makeProvider: + @MainActor @escaping (_ navigation: Navigation) -> NavigationCoordinator = { _ in + NavigationCoordinatorMock() + } + ) { + self.makeProvider = makeProvider + } + + func make(navigation: Navigation) -> NavigationCoordinator { + makeProvider(navigation) + } +} + +@MainActor +final class CloudMigrationCoordinatorFactoryMock: CloudMigrationCoordinatorFactoryProtocol { + + private let makeProvider: @MainActor (_ navigation: Navigation) -> CloudMigrationCoordinatorProtocol + + init( + makeProvider: @MainActor @escaping (_ navigation: Navigation) -> CloudMigrationCoordinatorProtocol = { _ in + CloudMigrationCoordinatorMock() + } + ) { + self.makeProvider = makeProvider + } + + func make(navigation: Navigation) -> CloudMigrationCoordinatorProtocol { + makeProvider(navigation) + } +} + +@MainActor +final class NavigationCoordinatorMock: NavigationCoordinator { + + private let shouldHandleProvider: (_ url: URL) -> Bool + private let handleProvider: (_ url: URL) -> [UIViewController] + + init( + shouldHandleProvider: @escaping (_ url: URL) -> Bool = { _ in false }, + handleProvider: @escaping (_ url: URL) -> [UIViewController] = { _ in [] } + ) { + self.shouldHandleProvider = shouldHandleProvider + self.handleProvider = handleProvider + } + + func shouldHandle(url: URL) -> Bool { + shouldHandleProvider(url) + } + + func handle(url: URL) -> [UIViewController] { + handleProvider(url) + } +} diff --git a/Tests/Mocks/RenameJotRepositoryMock.swift b/Tests/Mocks/RenameJotRepositoryMock.swift new file mode 100644 index 0000000..a03a520 --- /dev/null +++ b/Tests/Mocks/RenameJotRepositoryMock.swift @@ -0,0 +1,44 @@ +/* + Jottre: Minimalistic jotting for iPhone, iPad and Mac. + Copyright (C) 2021-2026 Anton Lorani + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +import Foundation + +@testable import Jottre + +final class RenameJotRepositoryMock: RenameJotRepositoryProtocol { + + private let renameProvider: (_ jotFileInfo: JotFile.Info, _ newName: String) throws -> JotFile.Info + + init( + renameProvider: + @escaping (_ jotFileInfo: JotFile.Info, _ newName: String) throws -> JotFile.Info = { jotFileInfo, name in + JotFile.Info( + url: jotFileInfo.url.deletingLastPathComponent().appendingPathComponent("\(name).jot"), + name: name, + modificationDate: nil, + ubiquitousInfo: nil + ) + } + ) { + self.renameProvider = renameProvider + } + + func rename(jotFileInfo: JotFile.Info, newName: String) throws -> JotFile.Info { + try renameProvider(jotFileInfo, newName) + } +} diff --git a/Tests/Mocks/SettingsCoordinatorMock.swift b/Tests/Mocks/SettingsCoordinatorMock.swift new file mode 100644 index 0000000..9c426c6 --- /dev/null +++ b/Tests/Mocks/SettingsCoordinatorMock.swift @@ -0,0 +1,53 @@ +/* + Jottre: Minimalistic jotting for iPhone, iPad and Mac. + Copyright (C) 2021-2026 Anton Lorani + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +import Foundation + +@testable import Jottre + +@MainActor +final class SettingsCoordinatorMock: SettingsCoordinatorProtocol { + + var onEnd: (() -> Void)? + + private let startProvider: () -> Void + private let openExternalLinkProvider: (_ url: URL) -> Void + private let dismissProvider: () -> Void + + init( + startProvider: @escaping () -> Void = {}, + openExternalLinkProvider: @escaping (_ url: URL) -> Void = { _ in }, + dismissProvider: @escaping () -> Void = {} + ) { + self.startProvider = startProvider + self.openExternalLinkProvider = openExternalLinkProvider + self.dismissProvider = dismissProvider + } + + func start() { + startProvider() + } + + func openExternalLink(url: URL) { + openExternalLinkProvider(url) + } + + func dismiss() { + dismissProvider() + } +} diff --git a/Tests/Mocks/SettingsRepositoryMock.swift b/Tests/Mocks/SettingsRepositoryMock.swift new file mode 100644 index 0000000..b843bf5 --- /dev/null +++ b/Tests/Mocks/SettingsRepositoryMock.swift @@ -0,0 +1,59 @@ +/* + Jottre: Minimalistic jotting for iPhone, iPad and Mac. + Copyright (C) 2021-2026 Anton Lorani + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +import UIKit + +@testable import Jottre + +final class SettingsRepositoryMock: SettingsRepositoryProtocol { + + private let shouldShowEnableICloudButtonProvider: @Sendable () -> Bool + private let appVersionProvider: @Sendable () -> String + private let userInterfaceStyleProvider: @Sendable () -> AsyncStream + private let updateUserInterfaceStyleProvider: @Sendable (_ style: UIUserInterfaceStyle) -> Void + + init( + shouldShowEnableICloudButtonProvider: @Sendable @escaping () -> Bool = { false }, + appVersionProvider: @Sendable @escaping () -> String = { "" }, + userInterfaceStyleProvider: @Sendable @escaping () -> AsyncStream = { + AsyncStream { $0.finish() } + }, + updateUserInterfaceStyleProvider: @Sendable @escaping (_ style: UIUserInterfaceStyle) -> Void = { _ in } + ) { + self.shouldShowEnableICloudButtonProvider = shouldShowEnableICloudButtonProvider + self.appVersionProvider = appVersionProvider + self.userInterfaceStyleProvider = userInterfaceStyleProvider + self.updateUserInterfaceStyleProvider = updateUserInterfaceStyleProvider + } + + func shouldShowEnableICloudButton() -> Bool { + shouldShowEnableICloudButtonProvider() + } + + func appVersion() -> String { + appVersionProvider() + } + + func userInterfaceStyle() -> AsyncStream { + userInterfaceStyleProvider() + } + + func updateUserInterfaceStyle(_ style: UIUserInterfaceStyle) { + updateUserInterfaceStyleProvider(style) + } +} diff --git a/Tests/Mocks/SettingsViewControllerFactoryMock.swift b/Tests/Mocks/SettingsViewControllerFactoryMock.swift new file mode 100644 index 0000000..d8d596a --- /dev/null +++ b/Tests/Mocks/SettingsViewControllerFactoryMock.swift @@ -0,0 +1,39 @@ +/* + Jottre: Minimalistic jotting for iPhone, iPad and Mac. + Copyright (C) 2021-2026 Anton Lorani + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +import UIKit + +@testable import Jottre + +@MainActor +final class SettingsViewControllerFactoryMock: SettingsViewControllerFactoryProtocol { + + private let makeProvider: @MainActor (_ coordinator: SettingsCoordinatorProtocol) -> UIViewController + + init( + makeProvider: @MainActor @escaping (_ coordinator: SettingsCoordinatorProtocol) -> UIViewController = { _ in + UIViewController() + } + ) { + self.makeProvider = makeProvider + } + + func make(coordinator: SettingsCoordinatorProtocol) -> UIViewController { + makeProvider(coordinator) + } +} diff --git a/Tests/Mocks/ShareJotRepositoryMock.swift b/Tests/Mocks/ShareJotRepositoryMock.swift new file mode 100644 index 0000000..3039522 --- /dev/null +++ b/Tests/Mocks/ShareJotRepositoryMock.swift @@ -0,0 +1,39 @@ +/* + Jottre: Minimalistic jotting for iPhone, iPad and Mac. + Copyright (C) 2021-2026 Anton Lorani + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +import Foundation + +@testable import Jottre + +final class ShareJotRepositoryMock: ShareJotRepositoryProtocol { + + private let exportJotProvider: @Sendable (_ jotFileInfo: JotFile.Info, _ format: ShareFormat) async throws -> URL + + init( + exportJotProvider: + @Sendable @escaping (_ jotFileInfo: JotFile.Info, _ format: ShareFormat) async throws -> URL = { _, _ in + URL(fileURLWithPath: "/tmp/share") + } + ) { + self.exportJotProvider = exportJotProvider + } + + func exportJot(jotFileInfo: JotFile.Info, format: ShareFormat) async throws -> URL { + try await exportJotProvider(jotFileInfo, format) + } +} diff --git a/Tests/Navigation/EditJotURLTests.swift b/Tests/Navigation/EditJotURLTests.swift new file mode 100644 index 0000000..95954fd --- /dev/null +++ b/Tests/Navigation/EditJotURLTests.swift @@ -0,0 +1,94 @@ +/* + Jottre: Minimalistic jotting for iPhone, iPad and Mac. + Copyright (C) 2021-2026 Anton Lorani + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +import XCTest + +@testable import Jottre + +final class EditJotURLTests: XCTestCase { + + func test_init_givenJotFileInfo_setsFileURLFromInfo() { + // Given + let fileURL = URL(staticString: "file:///tmp/note.jot") + let info = JotFile.Info( + url: fileURL, + name: "note", + modificationDate: nil, + ubiquitousInfo: nil + ) + + // When + let editJotURL = EditJotURL(jotFileInfo: info) + + // Then + XCTAssertEqual(editJotURL.fileURL, fileURL) + XCTAssertEqual(editJotURL.path, "/jots/edit") + } + + func test_initFromURL_givenMatchingPathAndQueryItem_succeeds() throws { + // Given + let url = URL(staticString: "scheme:///jots/edit?fileURL=file:///tmp/note.jot") + + // When + let editJotURL = try XCTUnwrap(EditJotURL(url: url)) + + // Then + XCTAssertEqual(editJotURL.fileURL, URL(staticString: "file:///tmp/note.jot")) + } + + func test_initFromURL_givenWrongPath_returnsNil() { + // Given + let url = URL(staticString: "scheme:///not/the/right/path?fileURL=file:///tmp/note.jot") + + // When + let editJotURL = EditJotURL(url: url) + + // Then + XCTAssertNil(editJotURL) + } + + func test_initFromURL_givenMissingFileURLQueryItem_returnsNil() { + // Given + let url = URL(staticString: "scheme:///jots/edit") + + // When + let editJotURL = EditJotURL(url: url) + + // Then + XCTAssertNil(editJotURL) + } + + func test_toURL_roundTripsFileURL() throws { + // Given + let fileURL = URL(staticString: "file:///tmp/note.jot") + let original = EditJotURL( + jotFileInfo: JotFile.Info( + url: fileURL, + name: "note", + modificationDate: nil, + ubiquitousInfo: nil + ) + ) + + // When + let roundTripped = try XCTUnwrap(EditJotURL(url: original.toURL())) + + // Then + XCTAssertEqual(roundTripped.fileURL, fileURL) + } +} diff --git a/Tests/Navigation/EnableICloudSupportURLTests.swift b/Tests/Navigation/EnableICloudSupportURLTests.swift new file mode 100644 index 0000000..9a41bd5 --- /dev/null +++ b/Tests/Navigation/EnableICloudSupportURLTests.swift @@ -0,0 +1,60 @@ +/* + Jottre: Minimalistic jotting for iPhone, iPad and Mac. + Copyright (C) 2021-2026 Anton Lorani + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +import XCTest + +@testable import Jottre + +final class EnableICloudSupportURLTests: XCTestCase { + + func test_init_givenLocaleWithLanguageAndRegion_buildsLocalizedSupportPath() { + // Given + let locale = Locale(identifier: "en_US") + + // When + let url = EnableICloudSupportURL(locale: locale) + + // Then + XCTAssertEqual(url.scheme, "https") + XCTAssertEqual(url.host, "support.apple.com") + XCTAssertEqual(url.path, "/en-us/guide/icloud/mmfc0f1e2a/icloud") + } + + func test_init_givenLocaleWithoutRegion_buildsGenericSupportPath() { + // Given + let locale = Locale(identifier: "en") + + // When + let url = EnableICloudSupportURL(locale: locale) + + // Then + XCTAssertEqual(url.path, "/guide/icloud/mmfc0f1e2a/icloud") + } + + func test_toURL_producesAppleSupportURL() { + // Given + let url = EnableICloudSupportURL(locale: Locale(identifier: "de_DE")) + + // When + let resolved = url.toURL() + + // Then + XCTAssertEqual(resolved.scheme, "https") + XCTAssertEqual(resolved.host, "support.apple.com") + } +} diff --git a/Tests/Navigation/JotsPageURLTests.swift b/Tests/Navigation/JotsPageURLTests.swift new file mode 100644 index 0000000..84935aa --- /dev/null +++ b/Tests/Navigation/JotsPageURLTests.swift @@ -0,0 +1,43 @@ +/* + Jottre: Minimalistic jotting for iPhone, iPad and Mac. + Copyright (C) 2021-2026 Anton Lorani + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +import XCTest + +@testable import Jottre + +final class JotsPageURLTests: XCTestCase { + + func test_path_isRoot() { + // Given / When + let url = JotsPageURL() + + // Then + XCTAssertEqual(url.path, "/") + } + + func test_toURL_producesURLWithRootPath() { + // Given + let url = JotsPageURL() + + // When + let result = url.toURL() + + // Then + XCTAssertEqual(result.path, "/") + } +} diff --git a/Tests/Navigation/JottreGithubURLTests.swift b/Tests/Navigation/JottreGithubURLTests.swift new file mode 100644 index 0000000..5a15237 --- /dev/null +++ b/Tests/Navigation/JottreGithubURLTests.swift @@ -0,0 +1,42 @@ +/* + Jottre: Minimalistic jotting for iPhone, iPad and Mac. + Copyright (C) 2021-2026 Anton Lorani + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +import XCTest + +@testable import Jottre + +final class JottreGithubURLTests: XCTestCase { + + func test_components_pointToProjectGithubRepository() { + // Given / When + let url = JottreGithubURL() + + // Then + XCTAssertEqual(url.scheme, "https") + XCTAssertEqual(url.host, "github.com") + XCTAssertEqual(url.path, "/antonlorani/jottre") + } + + func test_toURL_producesAbsoluteURL() { + // When + let resolved = JottreGithubURL().toURL() + + // Then + XCTAssertEqual(resolved.absoluteString, "https://github.com/antonlorani/jottre") + } +} diff --git a/Tests/Navigation/RevealFileURLTests.swift b/Tests/Navigation/RevealFileURLTests.swift new file mode 100644 index 0000000..d75311c --- /dev/null +++ b/Tests/Navigation/RevealFileURLTests.swift @@ -0,0 +1,59 @@ +/* + Jottre: Minimalistic jotting for iPhone, iPad and Mac. + Copyright (C) 2021-2026 Anton Lorani + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +import XCTest + +@testable import Jottre + +final class RevealFileURLTests: XCTestCase { + + func test_init_givenJotFileInfo_extractsPathFromInfoURL() { + // Given + let info = JotFile.Info( + url: URL(staticString: "file:///tmp/dir/note.jot"), + name: "note", + modificationDate: nil, + ubiquitousInfo: nil + ) + + // When + let revealFileURL = RevealFileURL(jotFileInfo: info) + + // Then + XCTAssertEqual(revealFileURL.scheme, "shareddocuments") + XCTAssertEqual(revealFileURL.host, "") + XCTAssertEqual(revealFileURL.path, "/tmp/dir/note.jot") + } + + func test_toURL_producesSharedDocumentsURL() throws { + // Given + let info = JotFile.Info( + url: URL(staticString: "file:///tmp/dir/note.jot"), + name: "note", + modificationDate: nil, + ubiquitousInfo: nil + ) + + // When + let result = RevealFileURL(jotFileInfo: info).toURL() + + // Then + XCTAssertEqual(result.scheme, "shareddocuments") + XCTAssertEqual(result.path, "/tmp/dir/note.jot") + } +} diff --git a/Tests/PageViewController/IOS18SymbolBarButtonItemFactoryTests.swift b/Tests/PageViewController/IOS18SymbolBarButtonItemFactoryTests.swift new file mode 100644 index 0000000..15056d6 --- /dev/null +++ b/Tests/PageViewController/IOS18SymbolBarButtonItemFactoryTests.swift @@ -0,0 +1,54 @@ +/* + Jottre: Minimalistic jotting for iPhone, iPad and Mac. + Copyright (C) 2021-2026 Anton Lorani + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +import UIKit +import XCTest + +@testable import Jottre + +@MainActor +final class IOS18SymbolBarButtonItemFactoryTests: XCTestCase { + + func test_make_givenActionPrimary_returnsBarButtonItemWrappingButtonWithAction() { + // Given + let factory = IOS18SymbolBarButtonItemFactory() + let action = UIAction { _ in } + + // When + let barButtonItem = factory.make(symbolName: "plus", primaryAction: .action(action)) + + // Then + let button = try? XCTUnwrap(barButtonItem.customView as? UIButton) + XCTAssertNotNil(button) + XCTAssertFalse(button?.showsMenuAsPrimaryAction ?? true) + } + + func test_make_givenMenuPrimary_returnsBarButtonItemWrappingButtonWithMenu() throws { + // Given + let factory = IOS18SymbolBarButtonItemFactory() + let menu = UIMenu(title: "Menu", children: []) + + // When + let barButtonItem = factory.make(symbolName: "ellipsis", primaryAction: .menu(menu)) + + // Then + let button = try XCTUnwrap(barButtonItem.customView as? UIButton) + XCTAssertTrue(button.showsMenuAsPrimaryAction) + XCTAssertNotNil(button.menu) + } +} diff --git a/Tests/PageViewController/IOS18TextBarButtonItemFactoryTests.swift b/Tests/PageViewController/IOS18TextBarButtonItemFactoryTests.swift new file mode 100644 index 0000000..94993b5 --- /dev/null +++ b/Tests/PageViewController/IOS18TextBarButtonItemFactoryTests.swift @@ -0,0 +1,39 @@ +/* + Jottre: Minimalistic jotting for iPhone, iPad and Mac. + Copyright (C) 2021-2026 Anton Lorani + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +import UIKit +import XCTest + +@testable import Jottre + +@MainActor +final class IOS18TextBarButtonItemFactoryTests: XCTestCase { + + func test_make_returnsBarButtonItemWrappingButtonConfiguredWithTitle() throws { + // Given + let factory = IOS18TextBarButtonItemFactory() + let action = UIAction { _ in } + + // When + let barButtonItem = factory.make(title: "Save", primaryAction: action) + + // Then + let button = try XCTUnwrap(barButtonItem.customView as? UIButton) + XCTAssertEqual(button.configuration?.title, "Save") + } +} diff --git a/Tests/PageViewController/PageCellSizingStrategyTests.swift b/Tests/PageViewController/PageCellSizingStrategyTests.swift new file mode 100644 index 0000000..3829fb0 --- /dev/null +++ b/Tests/PageViewController/PageCellSizingStrategyTests.swift @@ -0,0 +1,83 @@ +/* + Jottre: Minimalistic jotting for iPhone, iPad and Mac. + Copyright (C) 2021-2026 Anton Lorani + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +import XCTest + +@testable import Jottre + +final class PageCellSizingStrategyTests: XCTestCase { + + func test_columnSpacing_givenFullWidth_isZero() { + // Given + let strategy = PageCellSizingStrategy.fullWidth() + + // Then + XCTAssertEqual(strategy.columnSpacing, .zero) + } + + func test_columnSpacing_givenEqualSplit_returnsConfiguredColumnSpacing() { + // Given + let strategy = PageCellSizingStrategy.equalSplit( + perRow: 2, + itemHeight: 100, + columnSpacing: 12, + rowSpacing: 8 + ) + + // Then + XCTAssertEqual(strategy.columnSpacing, 12) + } + + func test_rowSpacing_givenEqualSplit_returnsConfiguredRowSpacing() { + // Given + let strategy = PageCellSizingStrategy.equalSplit( + perRow: 2, + itemHeight: 100, + columnSpacing: 12, + rowSpacing: 8 + ) + + // Then + XCTAssertEqual(strategy.rowSpacing, 8) + } + + func test_columnAndRowSpacing_givenAdaptiveGrid_returnsConfiguredSpacings() { + // Given + let strategy = PageCellSizingStrategy.adaptiveGrid( + minColumns: 2, + maxColumns: 8, + minItemWidth: 100, + maxItemWidth: 200, + columnSpacing: 16, + rowSpacing: 24, + aspectRatio: CGSize(width: 1, height: 1) + ) + + // Then + XCTAssertEqual(strategy.columnSpacing, 16) + XCTAssertEqual(strategy.rowSpacing, 24) + } + + func test_rowSpacing_givenFullWidthWithDefaultRowSpacing_returnsDefault() { + // Given + let strategy = PageCellSizingStrategy.fullWidth(estimatedHeight: 120, rowSpacing: 7) + + // Then + XCTAssertEqual(strategy.rowSpacing, 7) + } +} diff --git a/Tests/PageViewController/PageHeaderCellViewModelTests.swift b/Tests/PageViewController/PageHeaderCellViewModelTests.swift new file mode 100644 index 0000000..b5d29e7 --- /dev/null +++ b/Tests/PageViewController/PageHeaderCellViewModelTests.swift @@ -0,0 +1,37 @@ +/* + Jottre: Minimalistic jotting for iPhone, iPad and Mac. + Copyright (C) 2021-2026 Anton Lorani + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +import XCTest + +@testable import Jottre + +@MainActor +final class PageHeaderCellViewModelTests: XCTestCase { + + func test_init_storesHeadlineAndSubheadline() { + // When + let viewModel = PageHeaderCellViewModel( + headline: "Headline", + subheadline: "Subheadline" + ) + + // Then + XCTAssertEqual(viewModel.headline, "Headline") + XCTAssertEqual(viewModel.subheadline, "Subheadline") + } +} diff --git a/Tests/Resources/Calculator Pro.jot b/Tests/Resources/Calculator Pro.jot new file mode 100644 index 0000000..f3c4297 Binary files /dev/null and b/Tests/Resources/Calculator Pro.jot differ diff --git a/Tests/RevealFile/RevealFileCoordinatorTests.swift b/Tests/RevealFile/RevealFileCoordinatorTests.swift new file mode 100644 index 0000000..8afad00 --- /dev/null +++ b/Tests/RevealFile/RevealFileCoordinatorTests.swift @@ -0,0 +1,73 @@ +/* + Jottre: Minimalistic jotting for iPhone, iPad and Mac. + Copyright (C) 2021-2026 Anton Lorani + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +import XCTest + +@testable import Jottre + +@MainActor +final class RevealFileCoordinatorTests: XCTestCase { + + func test_start_givenInvoked_invokesApplicationServiceOpenWithRevealFileURL() { + // Given + let openExpectation = XCTestExpectation(description: "ApplicationService.open is called.") + let info = JotFile.Info( + url: URL(staticString: "file:///tmp/note.jot"), + name: "note", + modificationDate: nil, + ubiquitousInfo: nil + ) + let coordinator = RevealFileCoordinator( + jotFileInfo: info, + applicationService: ApplicationServiceMock( + openProvider: { receivedURL in + XCTAssertEqual(receivedURL, RevealFileURL(jotFileInfo: info).toURL()) + openExpectation.fulfill() + } + ) + ) + + // When + coordinator.start() + + // Then + wait(for: [openExpectation], timeout: 1) + } + + func test_start_givenInvoked_invokesOnEnd() { + // Given + let onEndExpectation = XCTestExpectation(description: "RevealFileCoordinator.onEnd is invoked.") + let info = JotFile.Info( + url: URL(staticString: "file:///tmp/note.jot"), + name: "note", + modificationDate: nil, + ubiquitousInfo: nil + ) + let coordinator = RevealFileCoordinator( + jotFileInfo: info, + applicationService: ApplicationServiceMock() + ) + coordinator.onEnd = { onEndExpectation.fulfill() } + + // When + coordinator.start() + + // Then + wait(for: [onEndExpectation], timeout: 1) + } +} diff --git a/Tests/SettingsPage/SettingsCoordinatorTests.swift b/Tests/SettingsPage/SettingsCoordinatorTests.swift new file mode 100644 index 0000000..6902289 --- /dev/null +++ b/Tests/SettingsPage/SettingsCoordinatorTests.swift @@ -0,0 +1,126 @@ +/* + Jottre: Minimalistic jotting for iPhone, iPad and Mac. + Copyright (C) 2021-2026 Anton Lorani + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +import UIKit +import XCTest + +@testable import Jottre + +@MainActor +final class SettingsCoordinatorTests: XCTestCase { + + func test_start_givenInvoked_presentsNavigationControllerWithFactoryProducedRoot() { + // Given + let presentExpectation = XCTestExpectation(description: "Navigation.present is called.") + let madeViewController = UIViewController() + let navigation = Navigation.test( + presentViewControllerProvider: { viewController, animated in + MainActor.assumeIsolated { + // Then + XCTAssertTrue(viewController is UINavigationController) + let navigationController = viewController as? UINavigationController + XCTAssertEqual(navigationController?.viewControllers.first, madeViewController) + XCTAssertTrue(navigationController?.navigationBar.prefersLargeTitles ?? false) + XCTAssertTrue(animated) + presentExpectation.fulfill() + } + } + ) + let coordinator = SettingsCoordinator( + navigation: navigation, + settingsViewControllerFactory: SettingsViewControllerFactoryMock( + makeProvider: { receivedCoordinator in + XCTAssertTrue(receivedCoordinator is SettingsCoordinator) + return madeViewController + } + ) + ) + + // When + coordinator.start() + + // Then + wait(for: [presentExpectation], timeout: 1) + } + + func test_openExternalLink_givenURL_invokesNavigationOpenExternal() { + // Given + let openExternalExpectation = XCTestExpectation(description: "Navigation.openExternal is called.") + let expectedURL = URL(staticString: "https://example.com") + let navigation = Navigation.test( + openExternalURLProvider: { receivedURL in + // Then + XCTAssertEqual(receivedURL, expectedURL) + openExternalExpectation.fulfill() + } + ) + let coordinator = SettingsCoordinator( + navigation: navigation, + settingsViewControllerFactory: SettingsViewControllerFactoryMock() + ) + + // When + coordinator.openExternalLink(url: expectedURL) + + // Then + wait(for: [openExternalExpectation], timeout: 1) + } + + func test_dismiss_givenInvoked_invokesNavigationDismissAnimated() { + // Given + let dismissExpectation = XCTestExpectation(description: "Navigation.dismiss is called.") + let navigation = Navigation.test( + dismissViewControllerProvider: { animated, _ in + // Then + XCTAssertTrue(animated) + dismissExpectation.fulfill() + } + ) + let coordinator = SettingsCoordinator( + navigation: navigation, + settingsViewControllerFactory: SettingsViewControllerFactoryMock() + ) + + // When + coordinator.dismiss() + + // Then + wait(for: [dismissExpectation], timeout: 1) + } + + func test_dismiss_givenCompletion_invokesOnEnd() async { + // Given + let onEndExpectation = XCTestExpectation(description: "SettingsCoordinator.onEnd is called.") + let navigation = Navigation.test( + dismissViewControllerProvider: { _, completion in + completion?() + } + ) + let coordinator = SettingsCoordinator( + navigation: navigation, + settingsViewControllerFactory: SettingsViewControllerFactoryMock() + ) + coordinator.onEnd = { onEndExpectation.fulfill() } + + // When + coordinator.dismiss() + + // Then + await fulfillment(of: [onEndExpectation], timeout: 1) + } +} diff --git a/Tests/SettingsPage/SettingsDropdownCellViewModelTests.swift b/Tests/SettingsPage/SettingsDropdownCellViewModelTests.swift new file mode 100644 index 0000000..9cb50a5 --- /dev/null +++ b/Tests/SettingsPage/SettingsDropdownCellViewModelTests.swift @@ -0,0 +1,68 @@ +/* + Jottre: Minimalistic jotting for iPhone, iPad and Mac. + Copyright (C) 2021-2026 Anton Lorani + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +import XCTest + +@testable import Jottre + +@MainActor +final class SettingsDropdownCellViewModelTests: XCTestCase { + + func test_init_storesNameCurrentAndOptionsFromBusinessModel() { + // Given + let light = SettingsDropdownBusinessModel.Option(label: "Light", value: "light") + let dark = SettingsDropdownBusinessModel.Option(label: "Dark", value: "dark") + let businessModel = SettingsDropdownBusinessModel( + name: "Appearance", + current: light, + options: [light, dark] + ) + + // When + let viewModel = SettingsDropdownCellViewModel( + settingsDropdown: businessModel, + onAction: { _ in } + ) + + // Then + XCTAssertEqual(viewModel.name, "Appearance") + XCTAssertEqual(viewModel.current, light) + XCTAssertEqual(viewModel.options, [light, dark]) + } + + func test_handleAction_givenTap_doesNotInvokeOnAction() async { + // Given + let onActionExpectation = XCTestExpectation(description: "onAction is not called for .tap") + onActionExpectation.isInverted = true + let option = SettingsDropdownBusinessModel.Option(label: "Light", value: "light") + let viewModel = SettingsDropdownCellViewModel( + settingsDropdown: SettingsDropdownBusinessModel( + name: "Appearance", + current: option, + options: [option] + ), + onAction: { _ in onActionExpectation.fulfill() } + ) + + // When + viewModel.handle(action: .tap) + + // Then + await fulfillment(of: [onActionExpectation], timeout: 0.05) + } +} diff --git a/Tests/SettingsPage/SettingsExternalLinkCellViewModelTests.swift b/Tests/SettingsPage/SettingsExternalLinkCellViewModelTests.swift new file mode 100644 index 0000000..a8ff5dd --- /dev/null +++ b/Tests/SettingsPage/SettingsExternalLinkCellViewModelTests.swift @@ -0,0 +1,69 @@ +/* + Jottre: Minimalistic jotting for iPhone, iPad and Mac. + Copyright (C) 2021-2026 Anton Lorani + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +import XCTest + +@testable import Jottre + +@MainActor +final class SettingsExternalLinkCellViewModelTests: XCTestCase { + + func test_init_storesNameAndInfoFromBusinessModel() { + // Given + let businessModel = SettingsExternalLinkBusinessModel(name: "GitHub", info: "Open repository") + + // When + let viewModel = SettingsExternalLinkCellViewModel( + settingsExternalLink: businessModel, + onAction: {} + ) + + // Then + XCTAssertEqual(viewModel.name, "GitHub") + XCTAssertEqual(viewModel.info, "Open repository") + } + + func test_init_givenNilInfoBusinessModel_storesNilInfo() { + // Given + let businessModel = SettingsExternalLinkBusinessModel(name: "GitHub", info: nil) + + // When + let viewModel = SettingsExternalLinkCellViewModel( + settingsExternalLink: businessModel, + onAction: {} + ) + + // Then + XCTAssertNil(viewModel.info) + } + + func test_handleAction_givenTap_invokesOnAction() async { + // Given + let onActionExpectation = XCTestExpectation(description: "onAction is called.") + let viewModel = SettingsExternalLinkCellViewModel( + settingsExternalLink: SettingsExternalLinkBusinessModel(name: "GitHub", info: nil), + onAction: { onActionExpectation.fulfill() } + ) + + // When + viewModel.handle(action: .tap) + + // Then + await fulfillment(of: [onActionExpectation], timeout: 0.2) + } +} diff --git a/Tests/SettingsPage/SettingsInfoCellViewModelTests.swift b/Tests/SettingsPage/SettingsInfoCellViewModelTests.swift new file mode 100644 index 0000000..6d91786 --- /dev/null +++ b/Tests/SettingsPage/SettingsInfoCellViewModelTests.swift @@ -0,0 +1,50 @@ +/* + Jottre: Minimalistic jotting for iPhone, iPad and Mac. + Copyright (C) 2021-2026 Anton Lorani + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +import XCTest + +@testable import Jottre + +@MainActor +final class SettingsInfoCellViewModelTests: XCTestCase { + + func test_init_storesNameAndValueFromBusinessModel() { + // Given + let businessModel = SettingsInfoBusinessModel(name: "Version", value: "1.0.0") + + // When + let viewModel = SettingsInfoCellViewModel(settingsInfo: businessModel) + + // Then + XCTAssertEqual(viewModel.name, "Version") + XCTAssertEqual(viewModel.value, "1.0.0") + } + + func test_handleAction_givenTap_doesNothing() { + // Given + let viewModel = SettingsInfoCellViewModel( + settingsInfo: SettingsInfoBusinessModel(name: "Version", value: "1.0.0") + ) + + // When + viewModel.handle(action: .tap) + + // Then + XCTAssertEqual(viewModel.value, "1.0.0") + } +} diff --git a/Tests/SettingsPage/SettingsRepositoryTests.swift b/Tests/SettingsPage/SettingsRepositoryTests.swift new file mode 100644 index 0000000..193ca2f --- /dev/null +++ b/Tests/SettingsPage/SettingsRepositoryTests.swift @@ -0,0 +1,133 @@ +/* + Jottre: Minimalistic jotting for iPhone, iPad and Mac. + Copyright (C) 2021-2026 Anton Lorani + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +import UIKit +import XCTest + +@testable import Jottre + +final class SettingsRepositoryTests: XCTestCase { + + func test_shouldShowEnableICloudButton_givenUbiquitousServiceDisabled_returnsTrue() { + // Given + let repository = SettingsRepository( + ubiquitousFileService: FileServiceMock(isEnabledProvider: { false }), + bundleService: BundleServiceMock(), + defaultsService: DefaultsServiceMock() + ) + + // Then + XCTAssertTrue(repository.shouldShowEnableICloudButton()) + } + + func test_shouldShowEnableICloudButton_givenUbiquitousServiceEnabled_returnsFalse() { + // Given + let repository = SettingsRepository( + ubiquitousFileService: FileServiceMock(isEnabledProvider: { true }), + bundleService: BundleServiceMock(), + defaultsService: DefaultsServiceMock() + ) + + // Then + XCTAssertFalse(repository.shouldShowEnableICloudButton()) + } + + func test_appVersion_givenBundleProvidesValue_returnsValue() { + // Given + let repository = SettingsRepository( + ubiquitousFileService: FileServiceMock(), + bundleService: BundleServiceMock(shortVersionStringProvider: { "1.2.3" }), + defaultsService: DefaultsServiceMock() + ) + + // Then + XCTAssertEqual(repository.appVersion(), "1.2.3") + } + + func test_appVersion_givenBundleReturnsNil_returnsDash() { + // Given + let repository = SettingsRepository( + ubiquitousFileService: FileServiceMock(), + bundleService: BundleServiceMock(shortVersionStringProvider: { nil }), + defaultsService: DefaultsServiceMock() + ) + + // Then + XCTAssertEqual(repository.appVersion(), "-") + } + + func test_userInterfaceStyle_givenStoredRawValue_emitsMatchingStyle() async throws { + // Given + let defaultsServiceMock = DefaultsServiceMock( + initialValues: [DefaultsKey.userInterfaceStyle.description: UIUserInterfaceStyle.dark.rawValue] + ) + let repository = SettingsRepository( + ubiquitousFileService: FileServiceMock(), + bundleService: BundleServiceMock(), + defaultsService: defaultsServiceMock + ) + + // When + var iterator = repository.userInterfaceStyle().makeAsyncIterator() + let first = try await XCTUnwrapAsync(await iterator.next()) + + // Then + XCTAssertEqual(first, .dark) + } + + func test_userInterfaceStyle_givenNoStoredValue_emitsUnspecified() async throws { + // Given + let repository = SettingsRepository( + ubiquitousFileService: FileServiceMock(), + bundleService: BundleServiceMock(), + defaultsService: DefaultsServiceMock() + ) + + // When + var iterator = repository.userInterfaceStyle().makeAsyncIterator() + let first = try await XCTUnwrapAsync(await iterator.next()) + + // Then + XCTAssertEqual(first, .unspecified) + } + + func test_updateUserInterfaceStyle_persistsRawValueInDefaults() { + // Given + let defaultsServiceMock = DefaultsServiceMock() + let repository = SettingsRepository( + ubiquitousFileService: FileServiceMock(), + bundleService: BundleServiceMock(), + defaultsService: defaultsServiceMock + ) + + // When + repository.updateUserInterfaceStyle(.light) + + // Then + XCTAssertEqual(defaultsServiceMock.getValue(.userInterfaceStyle), UIUserInterfaceStyle.light.rawValue) + } +} + +private func XCTUnwrapAsync( + _ expression: @autoclosure () async throws -> T?, + file: StaticString = #filePath, + line: UInt = #line +) async throws -> T { + let value = try await expression() + return try XCTUnwrap(value, file: file, line: line) +} diff --git a/Tests/SettingsPage/SettingsToggleCellViewModelTests.swift b/Tests/SettingsPage/SettingsToggleCellViewModelTests.swift new file mode 100644 index 0000000..b63403f --- /dev/null +++ b/Tests/SettingsPage/SettingsToggleCellViewModelTests.swift @@ -0,0 +1,50 @@ +/* + Jottre: Minimalistic jotting for iPhone, iPad and Mac. + Copyright (C) 2021-2026 Anton Lorani + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +import XCTest + +@testable import Jottre + +@MainActor +final class SettingsToggleCellViewModelTests: XCTestCase { + + func test_init_storesNameAndIsOnFromBusinessModel() { + // Given + let businessModel = SettingsToggleBusinessModel(name: "iCloud", isOn: true) + + // When + let viewModel = SettingsToggleCellViewModel(settingsToggle: businessModel) + + // Then + XCTAssertEqual(viewModel.name, "iCloud") + XCTAssertTrue(viewModel.isOn) + } + + func test_handleAction_givenTap_doesNothing() { + // Given + let viewModel = SettingsToggleCellViewModel( + settingsToggle: SettingsToggleBusinessModel(name: "iCloud", isOn: false) + ) + + // When + viewModel.handle(action: .tap) + + // Then + XCTAssertFalse(viewModel.isOn) + } +} diff --git a/Tests/SettingsPage/SettingsViewModelTests.swift b/Tests/SettingsPage/SettingsViewModelTests.swift new file mode 100644 index 0000000..2c9a31b --- /dev/null +++ b/Tests/SettingsPage/SettingsViewModelTests.swift @@ -0,0 +1,218 @@ +/* + Jottre: Minimalistic jotting for iPhone, iPad and Mac. + Copyright (C) 2021-2026 Anton Lorani + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +import UIKit +import XCTest + +@testable import Jottre + +@MainActor +final class SettingsViewModelTests: XCTestCase { + + func test_rightNavigationItems_givenInit_yieldsDismissSymbol() async throws { + // Given + let viewModel = SettingsViewModel( + repository: SettingsRepositoryMock(), + coordinator: SettingsCoordinatorMock() + ) + + // When + let items = try await firstValue(of: viewModel.rightNavigationItems) + + // Then + XCTAssertEqual(items.count, 1) + guard case let .symbol(systemImageName, _) = items[0] else { + XCTFail("Expected .symbol") + return + } + XCTAssertEqual(systemImageName, "xmark") + } + + func test_rightNavigationItem_givenTap_invokesCoordinatorDismiss() async throws { + // Given + let dismissExpectation = XCTestExpectation(description: "SettingsCoordinatorMock.dismiss is called.") + let coordinatorMock = SettingsCoordinatorMock( + dismissProvider: { dismissExpectation.fulfill() } + ) + let viewModel = SettingsViewModel( + repository: SettingsRepositoryMock(), + coordinator: coordinatorMock + ) + + // When + let items = try await firstValue(of: viewModel.rightNavigationItems) + guard case let .symbol(_, onAction) = items[0] else { + XCTFail("Expected .symbol") + return + } + onAction() + + // Then + await fulfillment(of: [dismissExpectation], timeout: 1) + } + + func test_didLoad_givenShouldNotShowICloud_yieldsThreeItems() async throws { + // Given + let userInterfaceStyleStream = AsyncStream { continuation in + continuation.yield(.unspecified) + continuation.finish() + } + let viewModel = SettingsViewModel( + repository: SettingsRepositoryMock( + shouldShowEnableICloudButtonProvider: { false }, + appVersionProvider: { "1.2.3" }, + userInterfaceStyleProvider: { userInterfaceStyleStream } + ), + coordinator: SettingsCoordinatorMock() + ) + + // When + viewModel.didLoad() + let items = try await firstValue(of: viewModel.items) + + // Then + XCTAssertEqual(items.count, 3) + XCTAssertNotNil(items[0].id as? SettingsDropdownBusinessModel) + XCTAssertNotNil(items[1].id as? SettingsExternalLinkBusinessModel) + XCTAssertNotNil(items[2].id as? SettingsInfoBusinessModel) + let info = try XCTUnwrap(items[2].id as? SettingsInfoBusinessModel) + XCTAssertEqual(info.value, "1.2.3") + } + + func test_didLoad_givenShouldShowICloud_yieldsFourItemsWithICloudLink() async throws { + // Given + let userInterfaceStyleStream = AsyncStream { continuation in + continuation.yield(.dark) + continuation.finish() + } + let viewModel = SettingsViewModel( + repository: SettingsRepositoryMock( + shouldShowEnableICloudButtonProvider: { true }, + userInterfaceStyleProvider: { userInterfaceStyleStream } + ), + coordinator: SettingsCoordinatorMock() + ) + + // When + viewModel.didLoad() + let items = try await firstValue(of: viewModel.items) + + // Then + XCTAssertEqual(items.count, 4) + XCTAssertNotNil(items[1].id as? SettingsExternalLinkBusinessModel) + XCTAssertNotNil(items[2].id as? SettingsExternalLinkBusinessModel) + } + + func test_didLoad_givenDarkStyle_yieldsDropdownWithDarkAsCurrent() async throws { + // Given + let userInterfaceStyleStream = AsyncStream { continuation in + continuation.yield(.dark) + continuation.finish() + } + let viewModel = SettingsViewModel( + repository: SettingsRepositoryMock( + userInterfaceStyleProvider: { userInterfaceStyleStream } + ), + coordinator: SettingsCoordinatorMock() + ) + + // When + viewModel.didLoad() + let items = try await firstValue(of: viewModel.items) + + // Then + let dropdown = try XCTUnwrap(items[0].id as? SettingsDropdownBusinessModel) + XCTAssertEqual(dropdown.current.value as? UIUserInterfaceStyle, .dark) + XCTAssertEqual(dropdown.options.count, 3) + } + + func test_didLoadExternalLinkTap_givenICloudVisible_invokesOpenExternalLinkWithICloudURL() async throws { + // Given + let openExternalLinkExpectation = + XCTestExpectation(description: "SettingsCoordinatorMock.openExternalLink is called.") + let userInterfaceStyleStream = AsyncStream { continuation in + continuation.yield(.unspecified) + continuation.finish() + } + let coordinatorMock = SettingsCoordinatorMock( + openExternalLinkProvider: { receivedURL in + // Then + XCTAssertEqual(receivedURL, EnableICloudSupportURL().toURL()) + openExternalLinkExpectation.fulfill() + } + ) + let viewModel = SettingsViewModel( + repository: SettingsRepositoryMock( + shouldShowEnableICloudButtonProvider: { true }, + userInterfaceStyleProvider: { userInterfaceStyleStream } + ), + coordinator: coordinatorMock + ) + + // When + viewModel.didLoad() + let items = try await firstValue(of: viewModel.items) + items[1].handleAction(.tap) + + // Then + await fulfillment(of: [openExternalLinkExpectation], timeout: 1) + } + + func test_didLoadExternalLinkTap_givenGithubLink_invokesOpenExternalLinkWithGithubURL() async throws { + // Given + let openExternalLinkExpectation = + XCTestExpectation(description: "SettingsCoordinatorMock.openExternalLink is called.") + let userInterfaceStyleStream = AsyncStream { continuation in + continuation.yield(.unspecified) + continuation.finish() + } + let coordinatorMock = SettingsCoordinatorMock( + openExternalLinkProvider: { receivedURL in + // Then + XCTAssertEqual(receivedURL, JottreGithubURL().toURL()) + openExternalLinkExpectation.fulfill() + } + ) + let viewModel = SettingsViewModel( + repository: SettingsRepositoryMock( + shouldShowEnableICloudButtonProvider: { false }, + userInterfaceStyleProvider: { userInterfaceStyleStream } + ), + coordinator: coordinatorMock + ) + + // When + viewModel.didLoad() + let items = try await firstValue(of: viewModel.items) + items[1].handleAction(.tap) + + // Then + await fulfillment(of: [openExternalLinkExpectation], timeout: 1) + } +} + +@MainActor +private func firstValue( + of sequence: S +) async throws -> S.Element where S.Element: Sendable { + var iterator = sequence.makeAsyncIterator() + guard let value = try await iterator.next() else { + throw NSError(domain: "SettingsViewModelTests", code: 0) + } + return value +} diff --git a/Tests/Utilities/Array+safeIndexTests.swift b/Tests/Utilities/Array+safeIndexTests.swift new file mode 100644 index 0000000..a606154 --- /dev/null +++ b/Tests/Utilities/Array+safeIndexTests.swift @@ -0,0 +1,57 @@ +/* + Jottre: Minimalistic jotting for iPhone, iPad and Mac. + Copyright (C) 2021-2026 Anton Lorani + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +import XCTest + +@testable import Jottre + +final class ArraySafeIndexTests: XCTestCase { + + func test_safeSubscript_givenIndexInBounds_returnsElement() { + // Given + let array = [10, 20, 30] + + // When + let element = array[safe: 1] + + // Then + XCTAssertEqual(element, 20) + } + + func test_safeSubscript_givenIndexOutOfBounds_returnsNil() { + // Given + let array = [10, 20, 30] + + // When + let element = array[safe: 5] + + // Then + XCTAssertNil(element) + } + + func test_safeSubscript_givenEmptyArray_returnsNil() { + // Given + let array: [Int] = [] + + // When + let element = array[safe: 0] + + // Then + XCTAssertNil(element) + } +} diff --git a/Tests/Utilities/AsyncSequenceDebounceTests.swift b/Tests/Utilities/AsyncSequenceDebounceTests.swift new file mode 100644 index 0000000..ee32940 --- /dev/null +++ b/Tests/Utilities/AsyncSequenceDebounceTests.swift @@ -0,0 +1,69 @@ +/* + Jottre: Minimalistic jotting for iPhone, iPad and Mac. + Copyright (C) 2021-2026 Anton Lorani + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +import XCTest + +@testable import Jottre + +final class AsyncSequenceDebounceTests: XCTestCase { + + func test_debounce_givenSingleValue_yieldsValueAfterInterval() async throws { + // Given + let stream = AsyncStream { continuation in + continuation.yield(1) + continuation.finish() + } + + // When + var iterator = stream.debounce(for: 0.05).makeAsyncIterator() + let first = try await XCTUnwrapAsync(await iterator.next()) + let second = await iterator.next() + + // Then + XCTAssertEqual(first, 1) + XCTAssertNil(second) + } + + func test_debounce_givenBurstOfValues_yieldsOnlyLastValue() async throws { + // Given + let stream = AsyncStream { continuation in + continuation.yield(1) + continuation.yield(2) + continuation.yield(3) + continuation.finish() + } + + // When + var values: [Int] = [] + for await value in stream.debounce(for: 0.05) { + values.append(value) + } + + // Then + XCTAssertEqual(values, [3]) + } +} + +private func XCTUnwrapAsync( + _ expression: @autoclosure () async throws -> T?, + file: StaticString = #filePath, + line: UInt = #line +) async throws -> T { + let value = try await expression() + return try XCTUnwrap(value, file: file, line: line) +} diff --git a/Tests/Utilities/AsyncSequenceToAsyncThrowingStreamTests.swift b/Tests/Utilities/AsyncSequenceToAsyncThrowingStreamTests.swift new file mode 100644 index 0000000..b752a5b --- /dev/null +++ b/Tests/Utilities/AsyncSequenceToAsyncThrowingStreamTests.swift @@ -0,0 +1,82 @@ +/* + Jottre: Minimalistic jotting for iPhone, iPad and Mac. + Copyright (C) 2021-2026 Anton Lorani + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +import XCTest + +@testable import Jottre + +final class AsyncSequenceToAsyncThrowingStreamTests: XCTestCase { + + func test_toAsyncThrowingStream_givenFiniteSequence_yieldsAllElementsThenFinishes() async throws { + // Given + let source = AsyncStream { continuation in + continuation.yield(1) + continuation.yield(2) + continuation.yield(3) + continuation.finish() + } + + // When + var collected: [Int] = [] + for try await value in source.toAsyncThrowingStream() { + collected.append(value) + } + + // Then + XCTAssertEqual(collected, [1, 2, 3]) + } + + func test_toAsyncStream_givenFiniteSequence_yieldsAllElementsThenFinishes() async { + // Given + let source = AsyncStream { continuation in + continuation.yield(10) + continuation.yield(20) + continuation.finish() + } + + // When + var collected: [Int] = [] + for await value in source.toAsyncStream() { + collected.append(value) + } + + // Then + XCTAssertEqual(collected, [10, 20]) + } + + func test_toAsyncThrowingStream_givenThrowingUpstream_propagatesError() async { + // Given + struct DummyError: Error, Equatable {} + let source = AsyncThrowingStream { continuation in + continuation.yield(1) + continuation.finish(throwing: DummyError()) + } + + // When / Then + do { + for try await _ in source.toAsyncThrowingStream() { + /* no-op */ + } + XCTFail("Expected the upstream error to propagate.") + } catch is DummyError { + // Expected + } catch { + XCTFail("Unexpected error: \(error)") + } + } +} diff --git a/Tests/Utilities/NSLayoutConstraintWithPriorityTests.swift b/Tests/Utilities/NSLayoutConstraintWithPriorityTests.swift new file mode 100644 index 0000000..2c8b97b --- /dev/null +++ b/Tests/Utilities/NSLayoutConstraintWithPriorityTests.swift @@ -0,0 +1,51 @@ +/* + Jottre: Minimalistic jotting for iPhone, iPad and Mac. + Copyright (C) 2021-2026 Anton Lorani + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +import UIKit +import XCTest + +@testable import Jottre + +@MainActor +final class NSLayoutConstraintWithPriorityTests: XCTestCase { + + func test_withPriority_setsPriorityAndReturnsSameConstraint() { + // Given + let view = UIView() + let constraint = view.widthAnchor.constraint(equalToConstant: 100) + + // When + let returned = constraint.withPriority(.defaultLow) + + // Then + XCTAssertEqual(constraint.priority, .defaultLow) + XCTAssertTrue(returned === constraint) + } + + func test_withPriority_givenChainedCalls_appliesLastValue() { + // Given + let view = UIView() + let constraint = view.widthAnchor.constraint(equalToConstant: 100) + + // When + _ = constraint.withPriority(.defaultLow).withPriority(.required) + + // Then + XCTAssertEqual(constraint.priority, .required) + } +} diff --git a/Tests/Utilities/UIColorAdaptiveBlackWhiteTests.swift b/Tests/Utilities/UIColorAdaptiveBlackWhiteTests.swift new file mode 100644 index 0000000..894e04f --- /dev/null +++ b/Tests/Utilities/UIColorAdaptiveBlackWhiteTests.swift @@ -0,0 +1,47 @@ +/* + Jottre: Minimalistic jotting for iPhone, iPad and Mac. + Copyright (C) 2021-2026 Anton Lorani + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +import UIKit +import XCTest + +@testable import Jottre + +final class UIColorAdaptiveBlackWhiteTests: XCTestCase { + + func test_adaptiveBlackWhite_givenLightUserInterfaceStyle_resolvesToWhite() { + // Given + let lightTraits = UITraitCollection(userInterfaceStyle: .light) + + // When + let resolved = UIColor.adaptiveBlackWhite.resolvedColor(with: lightTraits) + + // Then + XCTAssertEqual(resolved, UIColor.white) + } + + func test_adaptiveBlackWhite_givenDarkUserInterfaceStyle_resolvesToBlack() { + // Given + let darkTraits = UITraitCollection(userInterfaceStyle: .dark) + + // When + let resolved = UIColor.adaptiveBlackWhite.resolvedColor(with: darkTraits) + + // Then + XCTAssertEqual(resolved, UIColor.black) + } +} diff --git a/Tests/Utilities/UIFontPreferredFontTests.swift b/Tests/Utilities/UIFontPreferredFontTests.swift new file mode 100644 index 0000000..f6307a8 --- /dev/null +++ b/Tests/Utilities/UIFontPreferredFontTests.swift @@ -0,0 +1,44 @@ +/* + Jottre: Minimalistic jotting for iPhone, iPad and Mac. + Copyright (C) 2021-2026 Anton Lorani + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +import UIKit +import XCTest + +@testable import Jottre + +final class UIFontPreferredFontTests: XCTestCase { + + func test_preferredFont_givenBodyTextStyleAndBoldWeight_returnsScaledBoldFont() { + // When + let font = UIFont.preferredFont(forTextStyle: .body, weight: .bold) + + // Then + let traits = font.fontDescriptor.symbolicTraits + XCTAssertTrue(traits.contains(.traitBold)) + XCTAssertGreaterThan(font.pointSize, 0) + } + + func test_preferredFont_givenLargerTextStyle_producesLargerOrEqualPointSize() { + // When + let captionFont = UIFont.preferredFont(forTextStyle: .caption2, weight: .regular) + let titleFont = UIFont.preferredFont(forTextStyle: .title1, weight: .regular) + + // Then + XCTAssertGreaterThan(titleFont.pointSize, captionFont.pointSize) + } +} diff --git a/Tests/Utilities/UITraitCollectionHasRenderingChangeTests.swift b/Tests/Utilities/UITraitCollectionHasRenderingChangeTests.swift new file mode 100644 index 0000000..97c5f10 --- /dev/null +++ b/Tests/Utilities/UITraitCollectionHasRenderingChangeTests.swift @@ -0,0 +1,89 @@ +/* + Jottre: Minimalistic jotting for iPhone, iPad and Mac. + Copyright (C) 2021-2026 Anton Lorani + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +import UIKit +import XCTest + +@testable import Jottre + +final class UITraitCollectionHasRenderingChangeTests: XCTestCase { + + func test_hasRenderingChange_givenIdenticalTraits_returnsFalse() { + // Given + let traits = UITraitCollection(traitsFrom: [ + UITraitCollection(userInterfaceStyle: .light), + UITraitCollection(displayScale: 2.0), + ]) + + // When + let result = traits.hasRenderingChange(comparedTo: traits) + + // Then + XCTAssertFalse(result) + } + + func test_hasRenderingChange_givenNilPrevious_returnsTrue() { + // Given + let traits = UITraitCollection(traitsFrom: [ + UITraitCollection(userInterfaceStyle: .light), + UITraitCollection(displayScale: 2.0), + ]) + + // When + let result = traits.hasRenderingChange(comparedTo: nil) + + // Then + XCTAssertTrue(result) + } + + func test_hasRenderingChange_givenDifferingUserInterfaceStyle_returnsTrue() { + // Given + let lightTraits = UITraitCollection(traitsFrom: [ + UITraitCollection(userInterfaceStyle: .light), + UITraitCollection(displayScale: 2.0), + ]) + let darkTraits = UITraitCollection(traitsFrom: [ + UITraitCollection(userInterfaceStyle: .dark), + UITraitCollection(displayScale: 2.0), + ]) + + // When + let result = lightTraits.hasRenderingChange(comparedTo: darkTraits) + + // Then + XCTAssertTrue(result) + } + + func test_hasRenderingChange_givenDifferingDisplayScale_returnsTrue() { + // Given + let twoXTraits = UITraitCollection(traitsFrom: [ + UITraitCollection(userInterfaceStyle: .light), + UITraitCollection(displayScale: 2.0), + ]) + let threeXTraits = UITraitCollection(traitsFrom: [ + UITraitCollection(userInterfaceStyle: .light), + UITraitCollection(displayScale: 3.0), + ]) + + // When + let result = twoXTraits.hasRenderingChange(comparedTo: threeXTraits) + + // Then + XCTAssertTrue(result) + } +} diff --git a/fastlane/Fastfile b/fastlane/Fastfile index c0115e5..4fa443f 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -141,6 +141,18 @@ platform :ios do sh('xcodegen generate --spec ../project.yml') end + desc 'Run unit tests' + lane :test do + generate_project + + run_tests( + scheme: SCHEME, + destination: 'platform=iOS Simulator,name=iPhone 17', + xcargs: NO_SIGN_XCARGS, + output_directory: 'build' + ) + end + desc 'Verify iOS/iPadOS debug build' lane :build_debug do generate_project diff --git a/fastlane/README.md b/fastlane/README.md index 4e4eba8..23fc852 100644 --- a/fastlane/README.md +++ b/fastlane/README.md @@ -42,6 +42,14 @@ Bump version from merge commit and push tag Generate the Xcode project using XcodeGen +### ios test + +```sh +[bundle exec] fastlane ios test +``` + +Run unit tests + ### ios build_debug ```sh diff --git a/project.yml b/project.yml index fe7bacd..48da6c1 100644 --- a/project.yml +++ b/project.yml @@ -122,6 +122,25 @@ targets: codeSign: true packages: [] + JottreTests: + type: bundle.unit-test + platform: iOS + deploymentTarget: "15.0" + sources: + - path: Tests + excludes: + - Resources/** + - path: Tests/Resources + buildPhase: resources + dependencies: + - target: Jottre + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: com.antonlorani.jottre.JottreTests + SWIFT_VERSION: "6.0" + SWIFT_STRICT_CONCURRENCY: complete + GENERATE_INFOPLIST_FILE: YES + AppKitPlugin: type: bundle platform: macOS @@ -145,3 +164,48 @@ targets: CODE_SIGN_IDENTITY: "-" MACH_O_TYPE: mh_bundle ENABLE_HARDENED_RUNTIME: YES + +schemes: + Jottre: + build: + targets: + Jottre: all + AppKitPlugin: all + run: + config: Debug + executable: Jottre + test: + config: Debug + targets: + - name: JottreTests + archive: + config: Release + profile: + config: Release + analyze: + config: Debug + + JottreTests: + build: + targets: + JottreTests: all + Jottre: all + test: + config: Debug + targets: + - name: JottreTests + analyze: + config: Debug + + AppKitPlugin: + build: + targets: + AppKitPlugin: all + run: + config: Debug + archive: + config: Release + profile: + config: Release + analyze: + config: Debug