Going back from @Service property wrapper to init injection (for parallel testing support, and better testing overall)

This commit is contained in:
Selim Mustafaev 2024-12-24 18:49:47 +03:00
parent 96ebf45dcc
commit 9f08dfb358
35 changed files with 312 additions and 226 deletions

View File

@ -44,8 +44,6 @@
7A1E78F82CE900440004B740 /* ReportViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A1E78F72CE900440004B740 /* ReportViewModel.swift */; };
7A1E78FA2CE9005C0004B740 /* ReportCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A1E78F92CE9005C0004B740 /* ReportCoordinator.swift */; };
7A1E78FF2CE91A740004B740 /* Vehicle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A1E78FE2CE91A740004B740 /* Vehicle.swift */; };
7A22B6ED2C67FDEA00E60173 /* SwiftLocationMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A22B6EB2C67FDEA00E60173 /* SwiftLocationMock.swift */; };
7A22B6EE2C67FDEA00E60173 /* GeocoderMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A22B6EA2C67FDEA00E60173 /* GeocoderMock.swift */; };
7A27ADF3249F8B650035F39E /* RecordsController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A27ADF2249F8B650035F39E /* RecordsController.swift */; };
7A27ADF5249FD2F90035F39E /* FileManagerExt.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A27ADF4249FD2F90035F39E /* FileManagerExt.swift */; };
7A27ADF7249FEF690035F39E /* Recorder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A27ADF6249FEF690035F39E /* Recorder.swift */; };
@ -306,8 +304,6 @@
7A1E78F72CE900440004B740 /* ReportViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportViewModel.swift; sourceTree = "<group>"; };
7A1E78F92CE9005C0004B740 /* ReportCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportCoordinator.swift; sourceTree = "<group>"; };
7A1E78FE2CE91A740004B740 /* Vehicle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Vehicle.swift; sourceTree = "<group>"; };
7A22B6EA2C67FDEA00E60173 /* GeocoderMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeocoderMock.swift; sourceTree = "<group>"; };
7A22B6EB2C67FDEA00E60173 /* SwiftLocationMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwiftLocationMock.swift; sourceTree = "<group>"; };
7A27ADF2249F8B650035F39E /* RecordsController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecordsController.swift; sourceTree = "<group>"; };
7A27ADF4249FD2F90035F39E /* FileManagerExt.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileManagerExt.swift; sourceTree = "<group>"; };
7A27ADF6249FEF690035F39E /* Recorder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Recorder.swift; sourceTree = "<group>"; };
@ -723,15 +719,6 @@
path = Data;
sourceTree = "<group>";
};
7A22B6EC2C67FDEA00E60173 /* Mocks */ = {
isa = PBXGroup;
children = (
7A22B6EA2C67FDEA00E60173 /* GeocoderMock.swift */,
7A22B6EB2C67FDEA00E60173 /* SwiftLocationMock.swift */,
);
path = Mocks;
sourceTree = "<group>";
};
7A3F07A924360D9100E59687 /* Extensions */ = {
isa = PBXGroup;
children = (
@ -1044,7 +1031,6 @@
7AF6D2292677C3950086EA64 /* Extensions */,
7A11474523FF2A9000B424AF /* Models */,
7AF6D20D2677C0C30086EA64 /* Utils */,
7A22B6EC2C67FDEA00E60173 /* Mocks */,
7AF6D1F12677C03B0086EA64 /* AutoCatCore.h */,
7AF6D1F22677C03B0086EA64 /* Info.plist */,
);
@ -1451,8 +1437,6 @@
buildActionMask = 2147483647;
files = (
7A5D84C22C1AE5C900C2209B /* VehicleModel.swift in Sources */,
7A22B6ED2C67FDEA00E60173 /* SwiftLocationMock.swift in Sources */,
7A22B6EE2C67FDEA00E60173 /* GeocoderMock.swift in Sources */,
7A5D84B92C1AD3C200C2209B /* DtoConvertible.swift in Sources */,
7AF6D2182677C1680086EA64 /* VehicleAd.swift in Sources */,
7A761C08267E8EA20005F28F /* JWT.swift in Sources */,
@ -1777,6 +1761,7 @@
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
SWIFT_EMIT_LOC_STRINGS = NO;
SWIFT_UPCOMING_FEATURE_DISABLE_OUTWARD_ACTOR_ISOLATION = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
@ -1801,6 +1786,7 @@
PRODUCT_BUNDLE_IDENTIFIER = pro.aliencat.AutoCatCoreTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = NO;
SWIFT_UPCOMING_FEATURE_DISABLE_OUTWARD_ACTOR_ISOLATION = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
@ -1827,6 +1813,7 @@
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
SWIFT_EMIT_LOC_STRINGS = NO;
SWIFT_UPCOMING_FEATURE_DISABLE_OUTWARD_ACTOR_ISOLATION = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/AutoCat.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/AutoCat";
@ -1853,6 +1840,7 @@
PRODUCT_BUNDLE_IDENTIFIER = pro.aliencat.AutoCatTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = NO;
SWIFT_UPCOMING_FEATURE_DISABLE_OUTWARD_ACTOR_ISOLATION = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/AutoCat.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/AutoCat";
@ -1883,6 +1871,7 @@
PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)";
SKIP_INSTALL = YES;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG MOCKING";
SWIFT_UPCOMING_FEATURE_DISABLE_OUTWARD_ACTOR_ISOLATION = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
VERSIONING_SYSTEM = "apple-generic";
@ -1913,6 +1902,7 @@
PRODUCT_BUNDLE_IDENTIFIER = pro.aliencat.AutoCatCore;
PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)";
SKIP_INSTALL = YES;
SWIFT_UPCOMING_FEATURE_DISABLE_OUTWARD_ACTOR_ISOLATION = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
VERSIONING_SYSTEM = "apple-generic";

View File

@ -83,14 +83,22 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
let container = ServiceContainer.shared
container.register(SettingsServiceProtocol.self, instance: SettingsService(defaults: .standard))
let settingsService = SettingsService(defaults: .standard)
container.register(SettingsServiceProtocol.self, instance: settingsService)
container.register(ApiServiceProtocol.self, instance: ApiService())
container.register(GeocoderProtocol.self, instance: CLGeocoder())
container.register(SwiftLocationProtocol.self, instance: Location())
container.register(LocationServiceProtocol.self, instance: LocationService())
let locationService = LocationService(
geocoder: CLGeocoder(),
locationManager: Location(),
settingsService: settingsService
)
container.register(LocationServiceProtocol.self, instance: locationService)
Task {
container.register(StorageServiceProtocol.self, instance: try await StorageService())
container.register(StorageServiceProtocol.self,
instance: try await StorageService(settingsService: settingsService))
}
}

View File

@ -21,7 +21,13 @@ class EventsCoordinator {
func start(vehicle: VehicleDto) async -> VehicleDto {
let viewModel = EventsViewModel(vehicle: vehicle)
let resolver = ServiceContainer.shared
let viewModel = EventsViewModel(
apiService: resolver.resolve(ApiServiceProtocol.self),
storageService: resolver.resolve(StorageServiceProtocol.self),
settingsService: resolver.resolve(SettingsServiceProtocol.self),
vehicle: vehicle
)
viewModel.coordinator = self
let controller = CustomHostingController(rootView: EventsScreen(viewModel: viewModel))
navController.pushViewController(controller, animated: true)

View File

@ -138,5 +138,8 @@ struct EventsScreen: View {
}
#Preview {
EventsScreen(viewModel: .init(vehicle: .preview))
EventsScreen(viewModel: .init(apiService: MockApiServiceProtocol(),
storageService: MockStorageServiceProtocol(),
settingsService: MockSettingsServiceProtocol(),
vehicle: .preview))
}

View File

@ -26,9 +26,9 @@ class EventsViewModel: ACHudContainer {
typealias VehicleOperation = () async throws -> VehicleDto
@ObservationIgnored @Service var apiService: ApiServiceProtocol
@ObservationIgnored @Service var storageService: StorageServiceProtocol
@ObservationIgnored @Service var settingsService: SettingsServiceProtocol
let apiService: ApiServiceProtocol
let storageService: StorageServiceProtocol
let settingsService: SettingsServiceProtocol
weak var coordinator: EventsCoordinator?
@ -45,8 +45,14 @@ class EventsViewModel: ACHudContainer {
UIPasteboard.general.data(forPasteboardType: UTType.vehicleEvent.identifier) != nil
}
init(vehicle: VehicleDto) {
init(apiService: ApiServiceProtocol,
storageService: StorageServiceProtocol,
settingsService: SettingsServiceProtocol,
vehicle: VehicleDto) {
self.apiService = apiService
self.storageService = storageService
self.settingsService = settingsService
self.vehicle = vehicle
updateEvents()

View File

@ -22,7 +22,10 @@ class FiltersCoordinator: Coordinator {
}
func start() async throws -> Filter? {
let viewModel = FiltersViewModel(filter: filter)
let viewModel = FiltersViewModel(
apiService: ServiceContainer.shared.resolve(ApiServiceProtocol.self),
filter: filter
)
let controller = CustomHostingController(rootView: FiltersScreen(viewModel: viewModel))
viewController?.pushViewController(controller, animated: true)
await controller.waitForDisappear()

View File

@ -120,5 +120,8 @@ struct FiltersScreen: View {
}
#Preview {
FiltersScreen(viewModel: .init(filter: Filter()))
FiltersScreen(viewModel: .init(
apiService: MockApiServiceProtocol(),
filter: Filter()
))
}

View File

@ -13,7 +13,7 @@ import AutoCatCore
@Observable
class FiltersViewModel {
@ObservationIgnored @Service var api: ApiServiceProtocol
let apiService: ApiServiceProtocol
var filter: Filter {
didSet {
@ -35,7 +35,9 @@ class FiltersViewModel {
@ObservationIgnored var currentBrand: StringOption = .any
init(filter: Filter) {
init(apiService: ApiServiceProtocol, filter: Filter) {
self.apiService = apiService
self.filter = filter
}
@ -44,13 +46,13 @@ class FiltersViewModel {
return
}
brands = [.any] + ((try? await api.getBrands()) ?? []).map { .value($0) }
colors = [.any] + ((try? await api.getColors()) ?? []).map { .value($0) }
years = [.any] + ((try? await api.getYears())?.map(String.init) ?? []).map { .value($0) }
brands = [.any] + ((try? await apiService.getBrands()) ?? []).map { .value($0) }
colors = [.any] + ((try? await apiService.getColors()) ?? []).map { .value($0) }
years = [.any] + ((try? await apiService.getYears())?.map(String.init) ?? []).map { .value($0) }
}
func loadModels(brand: String) async {
models = [.any] + ((try? await api.getModels(of: brand)) ?? []).map { .value($0) }
models = [.any] + ((try? await apiService.getModels(of: brand)) ?? []).map { .value($0) }
filter.model = .any
}

View File

@ -23,7 +23,10 @@ final class LocationPickerCoordinator: Coordinator {
func start() async throws -> VehicleEventDto? {
let viewModel = LocationPickerViewModel(event: event)
let viewModel = LocationPickerViewModel(
locationService: ServiceContainer.shared.resolve(LocationServiceProtocol.self),
event: event
)
let screen = LocationPickerScreen(viewModel: viewModel)
let controller = CustomHostingController(rootView: screen)
viewController?.pushViewController(controller, animated: true)

View File

@ -51,7 +51,10 @@ struct LocationPickerScreen: View {
var event = VehicleEventDto(lat: 47.250049, lon: 39.711821, addedBy: nil)
event.address = "Ул. Ленина, 123"
let viewModel = LocationPickerViewModel(event: event)
let viewModel = LocationPickerViewModel(
locationService: ServiceContainer.shared.resolve(LocationServiceProtocol.self),
event: event
)
return LocationPickerScreen(viewModel: viewModel)
}

View File

@ -15,14 +15,16 @@ import SwiftUI
@Observable
final class LocationPickerViewModel {
@ObservationIgnored @Service var locationService: LocationServiceProtocol
let locationService: LocationServiceProtocol
var event: VehicleEventDto
var position: MapCameraPosition
var result: VehicleEventDto?
init(event: VehicleEventDto) {
init(locationService: LocationServiceProtocol, event: VehicleEventDto) {
self.locationService = locationService
self.event = event
if event.latitude == 0 && event.longitude == 0 {

View File

@ -24,7 +24,12 @@ class NotesCoordinator: Coordinator {
func start() async throws -> VehicleDto {
let viewModel = NotesViewModel(vehicle: vehicle)
let resolver = ServiceContainer.shared
let viewModel = NotesViewModel(
storageService: resolver.resolve(StorageServiceProtocol.self),
apiService: resolver.resolve(ApiServiceProtocol.self),
vehicle: vehicle
)
let controller = CustomHostingController(rootView: NotesScreen(viewModel: viewModel))
viewController?.pushViewController(controller, animated: true)
await controller.waitForDisappear()

View File

@ -95,7 +95,11 @@ struct NotesScreen: View {
.init(text: "zxcv", user: "")
]
let vm = NotesViewModel(vehicle: vehicle)
let vm = NotesViewModel(
storageService: MockStorageServiceProtocol(),
apiService: MockApiServiceProtocol(),
vehicle: vehicle
)
return NotesScreen(viewModel: vm)
}

View File

@ -15,14 +15,18 @@ import UniformTypeIdentifiers
@Observable
class NotesViewModel: ACHudContainer {
@ObservationIgnored @Service var storageService: StorageServiceProtocol
@ObservationIgnored @Service var apiService: ApiServiceProtocol
let storageService: StorageServiceProtocol
let apiService: ApiServiceProtocol
var vehicle: VehicleDto
var hud: ACHud?
init(vehicle: VehicleDto) {
init(storageService: StorageServiceProtocol,
apiService: ApiServiceProtocol,
vehicle: VehicleDto) {
self.storageService = storageService
self.apiService = apiService
self.vehicle = vehicle
}

View File

@ -28,7 +28,14 @@ class ReportCoordinator: Coordinator {
func start() async throws -> VehicleDto {
let viewModel = ReportViewModel(vehicle: vehicle, isPersistent: isPersistent)
let resolver = ServiceContainer.shared
let viewModel = await ReportViewModel(
apiService: resolver.resolve(ApiServiceProtocol.self),
storageService: resolver.resolve(StorageServiceProtocol.self),
settingsService: resolver.resolve(SettingsServiceProtocol.self),
vehicle: vehicle,
isPersistent: isPersistent
)
viewModel.coordinator = self
let controller = CustomHostingController(rootView: ReportScreen(viewModel: viewModel))
let newNavController = UINavigationController(rootViewController: controller)

View File

@ -126,5 +126,11 @@ struct ReportScreen: View {
}
#Preview {
ReportScreen(viewModel: .init(vehicle: .preview, isPersistent: false))
ReportScreen(viewModel: .init(
apiService: MockApiServiceProtocol(),
storageService: MockStorageServiceProtocol(),
settingsService: MockSettingsServiceProtocol(),
vehicle: .preview,
isPersistent: false
))
}

View File

@ -13,9 +13,9 @@ import SwiftUI
@Observable
class ReportViewModel: ACHudContainer {
@ObservationIgnored @Service var api: ApiServiceProtocol
@ObservationIgnored @Service var storageService: StorageServiceProtocol
@ObservationIgnored @Service var settings: SettingsServiceProtocol
let apiService: ApiServiceProtocol
let storageService: StorageServiceProtocol
let settingsService: SettingsServiceProtocol
var coordinator: ReportCoordinator?
@ -44,7 +44,7 @@ class ReportViewModel: ACHudContainer {
}
var showDebugInfo: Bool {
settings.showDebugInfo
settingsService.showDebugInfo
}
var shareLink: URL? {
@ -55,7 +55,15 @@ class ReportViewModel: ACHudContainer {
return URL(string: Constants.reportLinkBaseURL + "?token=" + jwt)
}
init(vehicle: VehicleDto, isPersistent: Bool) {
init(apiService: ApiServiceProtocol,
storageService: StorageServiceProtocol,
settingsService: SettingsServiceProtocol,
vehicle: VehicleDto,
isPersistent: Bool) {
self.apiService = apiService
self.storageService = storageService
self.settingsService = settingsService
self.vehicle = vehicle
self.isPersistent = isPersistent
}
@ -84,7 +92,7 @@ class ReportViewModel: ACHudContainer {
func checkGB() async {
await wrapWithToast {
self.vehicle = try await self.api.checkVehicleGb(by: self.vehicle.getNumber())
self.vehicle = try await self.apiService.checkVehicleGb(by: self.vehicle.getNumber())
try await self.storageService.updateVehicleIfExists(dto: self.vehicle)
}
}

View File

@ -23,7 +23,7 @@ class SettingsCoordinator: Coordinator {
func start() async throws {
let viewModel = SettingsViewModel()
let viewModel = SettingsViewModel(settingsService: ServiceContainer.shared.resolve(SettingsServiceProtocol.self))
viewModel.coordinator = self
let controller = UIHostingController(rootView: SettingsScreen(viewModel: viewModel))
settingsController = controller

View File

@ -88,5 +88,5 @@ struct SettingsScreen: View {
}
#Preview {
SettingsScreen(viewModel: .init())
SettingsScreen(viewModel: .init(settingsService: MockSettingsServiceProtocol()))
}

View File

@ -42,8 +42,9 @@ class SettingsViewModel {
return jwt.payload.email
}
init() {
self.settingService = try! ServiceContainer.shared.resolve(SettingsServiceProtocol.self)
init(settingsService: SettingsServiceProtocol) {
self.settingService = settingsService
}
func signOut() {

View File

@ -6,6 +6,8 @@
// Copyright © 2024 Selim Mustafaev. All rights reserved.
//
import Foundation
public enum DIError: Error {
case wrongServiceType(String)
@ -47,18 +49,18 @@ public class ServiceContainer {
if let service = cachedService as? Service {
return service
} else {
throw DIError.wrongServiceType(type)
fatalError("Wrong service type for service: \(type)")
}
}
guard let factory = factories[type] else {
throw DIError.serviceNotFound(type)
fatalError("Service \(type) not found")
}
if let service = factory() as? Service {
return service
} else {
throw DIError.wrongServiceType(type)
fatalError("Wrong service type for service: \(type)")
}
}
}

View File

@ -6,8 +6,8 @@
// Copyright © 2024 Selim Mustafaev. All rights reserved.
//
@MainActor
@propertyWrapper
@MainActor
public struct Service<Service> {
public var service: Service

View File

@ -1,51 +0,0 @@
//
// GeocoderMock.swift
// AutoCatCoreTests
//
// Created by Selim Mustafaev on 31.07.2024.
// Copyright © 2024 Selim Mustafaev. All rights reserved.
//
import CoreLocation
import AutoCatCore
import Intents
import Contacts
final class GeocoderMock {
struct Location {
let latitude: CLLocationDegrees
let longitude: CLLocationDegrees
let address: String?
}
var locations: [Location] = []
func addLocation(latitude: CLLocationDegrees, longitude: CLLocationDegrees, address: String?) {
locations.append(Location(latitude: latitude,
longitude: longitude,
address: address))
}
}
extension GeocoderMock: GeocoderProtocol {
func reverseGeocodeLocation(_ location: CLLocation) async throws -> [CLPlacemark] {
let first = locations.first {
$0.latitude == location.coordinate.latitude && $0.longitude == location.coordinate.longitude
}
guard let first else {
return []
}
let placemark = CLPlacemark(location: CLLocation(latitude: first.latitude, longitude: first.longitude),
name: first.address,
postalAddress: nil)
return [placemark]
}
}

View File

@ -1,45 +0,0 @@
//
// SwiftLocationMock.swift
// AutoCatCoreTests
//
// Created by Selim Mustafaev on 02.08.2024.
// Copyright © 2024 Selim Mustafaev. All rights reserved.
//
import AutoCatCore
import CoreLocation
import SwiftLocation
final class SwiftLocationMock {
var authorizationStatus: CLAuthorizationStatus = .notDetermined
var requestedStatus: CLAuthorizationStatus = .notDetermined
var location: CLLocation?
var requestLocationTime: TimeInterval = 0
var requestLocationCount = 0
}
extension SwiftLocationMock: SwiftLocationProtocol {
func requestPermission(_ permission: LocationPermission) async throws -> CLAuthorizationStatus {
authorizationStatus = requestedStatus
return requestedStatus
}
func requestLocation(accuracy filters: AccuracyFilters?,
timeout: TimeInterval?) async throws -> Tasks.ContinuousUpdateLocation.StreamEvent {
requestLocationCount += 1
if requestLocationTime > 0 {
try await Task.sleep(nanoseconds: UInt64(requestLocationTime*1_000_000_000))
}
if let location {
return .didUpdateLocations([location])
} else {
return .didUpdateLocations([])
}
}
}

View File

@ -7,7 +7,9 @@
//
import CoreLocation
import Mockable
@Mockable
public protocol GeocoderProtocol {
func reverseGeocodeLocation(_ location: CLLocation) async throws -> [CLPlacemark]

View File

@ -12,14 +12,19 @@ import SwiftLocation
@MainActor
public final class LocationService {
@Service var geocoder: GeocoderProtocol
@Service var locationManager: SwiftLocationProtocol
@Service var settingsService: SettingsServiceProtocol
let geocoder: GeocoderProtocol
let locationManager: SwiftLocationProtocol
let settingsService: SettingsServiceProtocol
private var eventTask: Task<VehicleEventDto,Error>?
public init() {
public init(geocoder: GeocoderProtocol,
locationManager: SwiftLocationProtocol,
settingsService: SettingsServiceProtocol) {
self.geocoder = geocoder
self.locationManager = locationManager
self.settingsService = settingsService
}
private func checkPermissions() async throws {

View File

@ -8,7 +8,9 @@
import SwiftLocation
import CoreLocation
import Mockable
@Mockable
public protocol SwiftLocationProtocol {
var authorizationStatus: CLAuthorizationStatus { get }

View File

@ -26,12 +26,14 @@ public enum StorageError: LocalizedError {
public actor StorageService: StorageServiceProtocol {
@Service var settingsService: SettingsServiceProtocol
let settingsService: SettingsServiceProtocol
var realm: Realm!
public init(config: Realm.Configuration = .defaultConfiguration) async throws {
public init(settingsService: SettingsServiceProtocol,
config: Realm.Configuration = .defaultConfiguration) async throws {
self.settingsService = settingsService
realm = try await Realm(configuration: config, actor: self)
}

View File

@ -9,6 +9,7 @@
import Testing
import CoreLocation
import Mockable
import Intents
@testable import AutoCatCore
@MainActor
@ -18,18 +19,20 @@ struct LocationServiceTests {
let longitude: CLLocationDegrees = 10
let address = "Test Address"
let geocoder = GeocoderMock()
let locationManager = SwiftLocationMock()
let location: CLLocation
let geocoderMock = MockGeocoderProtocol()
let locationManagerMock = MockSwiftLocationProtocol()
let settingsServiceMock = MockSettingsServiceProtocol()
let locationService: LocationService
init() {
ServiceContainer.shared.register(GeocoderProtocol.self, instance: geocoder)
ServiceContainer.shared.register(SwiftLocationProtocol.self, instance: locationManager)
ServiceContainer.shared.register(SettingsServiceProtocol.self, instance: settingsServiceMock)
self.location = CLLocation(latitude: latitude, longitude: longitude)
self.locationService = LocationService()
self.locationService = LocationService(geocoder: geocoderMock,
locationManager: locationManagerMock,
settingsService: settingsServiceMock)
given(settingsServiceMock)
.user.willReturn(User())
@ -38,42 +41,64 @@ struct LocationServiceTests {
@Test
func getValidAddress() async throws {
geocoder.addLocation(latitude: latitude,
longitude: longitude,
address: address)
let placemark = CLPlacemark(location: location, name: address, postalAddress: nil)
given(geocoderMock)
.reverseGeocodeLocation(.any)
.willReturn([placemark])
let result = try await locationService.getAddressForLocation(latitude: latitude,
longitude: longitude)
verify(geocoderMock)
.reverseGeocodeLocation(.any)
.called(.once)
#expect(result == address)
}
@Test
func getNilAddress() async throws {
geocoder.addLocation(latitude: latitude,
longitude: longitude,
address: nil)
let placemark = CLPlacemark(location: location, name: nil, postalAddress: nil)
given(geocoderMock)
.reverseGeocodeLocation(.any)
.willReturn([placemark])
await #expect(throws: LocationError.reverseGeocode) {
_ = try await locationService.getAddressForLocation(latitude: latitude,
longitude: longitude)
}
verify(geocoderMock)
.reverseGeocodeLocation(.any)
.called(.once)
}
@Test
func addressNotFound() async throws {
given(geocoderMock)
.reverseGeocodeLocation(.any)
.willReturn([])
await #expect(throws: LocationError.reverseGeocode) {
_ = try await locationService.getAddressForLocation(latitude: latitude,
longitude: longitude)
}
verify(geocoderMock)
.reverseGeocodeLocation(.any)
.called(.once)
}
@Test("Get location: denied")
func getLocationDenied() async throws {
locationManager.authorizationStatus = .denied
given(locationManagerMock)
.authorizationStatus
.willReturn(.denied)
await #expect(throws: CLError(.denied)) {
_ = try await locationService.requestCurrentLocation()
@ -83,8 +108,21 @@ struct LocationServiceTests {
@Test("Get location: not determined -> denied")
func getLocationNotDeterminedDenied() async throws {
locationManager.authorizationStatus = .notDetermined
locationManager.requestedStatus = .denied
given(locationManagerMock)
.authorizationStatus
.willReturn(.notDetermined)
given(locationManagerMock)
.requestPermission(.value(.always))
.willReturn(.denied)
when(locationManagerMock)
.requestPermission(.value(.always))
.perform {
given(locationManagerMock)
.authorizationStatus
.willReturn(.denied)
}
await #expect(throws: CLError(.denied)) {
_ = try await locationService.requestCurrentLocation()
@ -94,9 +132,25 @@ struct LocationServiceTests {
@Test("Get location: not determined -> allow")
func getLocationNotDeterminedAllow() async throws {
locationManager.authorizationStatus = .notDetermined
locationManager.requestedStatus = .authorizedWhenInUse
locationManager.location = CLLocation(latitude: latitude, longitude: longitude)
given(locationManagerMock)
.authorizationStatus
.willReturn(.notDetermined)
given(locationManagerMock)
.requestPermission(.value(.always))
.willReturn(.authorizedWhenInUse)
when(locationManagerMock)
.requestPermission(.value(.always))
.perform {
given(locationManagerMock)
.authorizationStatus
.willReturn(.authorizedWhenInUse)
}
given(locationManagerMock)
.requestLocation(accuracy: .any, timeout: .any)
.willReturn(.didUpdateLocations([location]))
let event = try await locationService.requestCurrentLocation()
@ -107,8 +161,13 @@ struct LocationServiceTests {
@Test("Get location: normal")
func getLocationNormal() async throws {
locationManager.authorizationStatus = .authorizedWhenInUse
locationManager.location = CLLocation(latitude: latitude, longitude: longitude)
given(locationManagerMock)
.authorizationStatus
.willReturn(.authorizedWhenInUse)
given(locationManagerMock)
.requestLocation(accuracy: .any, timeout: .any)
.willReturn(.didUpdateLocations([location]))
let event = try await locationService.requestCurrentLocation()
@ -119,7 +178,13 @@ struct LocationServiceTests {
@Test("Get location: no location")
func getLocationNone() async throws {
locationManager.authorizationStatus = .authorizedWhenInUse
given(locationManagerMock)
.authorizationStatus
.willReturn(.authorizedWhenInUse)
given(locationManagerMock)
.requestLocation(accuracy: .any, timeout: .any)
.willReturn(.didUpdateLocations([]))
await #expect(throws: LocationError.generic) {
_ = try await locationService.requestCurrentLocation()
@ -129,19 +194,25 @@ struct LocationServiceTests {
@Test("Get location: parallel requests")
func getLocationParallel() async throws {
locationManager.authorizationStatus = .authorizedWhenInUse
locationManager.location = CLLocation(latitude: latitude, longitude: longitude)
locationManager.requestLocationTime = 1
given(locationManagerMock)
.authorizationStatus
.willReturn(.authorizedWhenInUse)
given(locationManagerMock)
.requestLocation(accuracy: .any, timeout: .any)
.willReturn(.didUpdateLocations([location]))
async let task1 = locationService.requestCurrentLocation()
async let task2 = locationService.requestCurrentLocation()
try await Task.sleep(nanoseconds: 1_500_000_000)
try await Task.sleep(nanoseconds: 500_000_000)
async let task3 = locationService.requestCurrentLocation()
let (event1, event2, event3) = try await (task1, task2, task3)
#expect(locationManager.requestLocationCount == 2)
verify(locationManagerMock)
.requestLocation(accuracy: .any, timeout: .any)
.called(.exactly(2))
#expect(event1.latitude == latitude)
#expect(event1.longitude == longitude)

View File

@ -30,9 +30,7 @@ struct StorageServiceTests {
config.inMemoryIdentifier = UUID().uuidString
settingsServiceMock = MockSettingsServiceProtocol()
await ServiceContainer.shared.register(SettingsServiceProtocol.self, instance: settingsServiceMock)
self.storageService = try await StorageService(config: config)
self.storageService = try await StorageService(settingsService: settingsServiceMock, config: config)
try addTestVehicle(config: config)

View File

@ -29,11 +29,10 @@ struct EventsTests {
storageServiceMock = MockStorageServiceProtocol()
apiServiceMock = MockApiServiceProtocol()
ServiceContainer.shared.register(SettingsServiceProtocol.self, instance: settingsServiceMock)
ServiceContainer.shared.register(StorageServiceProtocol.self, instance: storageServiceMock)
ServiceContainer.shared.register(ApiServiceProtocol.self, instance: apiServiceMock)
viewModel = EventsViewModel(vehicle: VehicleDto())
viewModel = EventsViewModel(apiService: apiServiceMock,
storageService: storageServiceMock,
settingsService: settingsServiceMock,
vehicle: VehicleDto())
given(settingsServiceMock)
.user.willReturn(User())
@ -56,7 +55,7 @@ struct EventsTests {
.add(event: .value(.valid), to: .value(VehicleDto.validNumber))
.willReturn(unrecognizedVehicleWithEvent)
viewModel = EventsViewModel(vehicle: isUnrecognized ? .unrecognized : .normal)
viewModel.vehicle = isUnrecognized ? .unrecognized : .normal
await viewModel.addEvent(.valid)
verify(apiServiceMock)
@ -96,7 +95,7 @@ struct EventsTests {
.remove(event: .value(VehicleEventDto.validId), from: .value(VehicleDto.validNumber))
.willReturn(.unrecognized)
viewModel = EventsViewModel(vehicle: vehicleWithEvent)
viewModel.vehicle = vehicleWithEvent
await viewModel.deleteEvent(VehicleEventDto.valid.viewModel)
verify(apiServiceMock)

View File

@ -26,9 +26,7 @@ struct FiltersTests {
let testYear = 2222
init() {
ServiceContainer.shared.register(SettingsServiceProtocol.self, instance: settingsServiceMock)
ServiceContainer.shared.register(ApiServiceProtocol.self, instance: apiServiceMock)
viewModel = FiltersViewModel(filter: Filter())
viewModel = FiltersViewModel(apiService: apiServiceMock, filter: Filter())
}
@Test("Main filters data loaded")

View File

@ -8,6 +8,8 @@
import Testing
import CoreLocation
import Mockable
import Intents
@testable import AutoCat
@testable import AutoCatCore
@ -19,19 +21,24 @@ struct LocationPickerTests {
let longitude: CLLocationDegrees = 10
let address = "Test Address"
let geocoder = GeocoderMock()
let geocoderMock = MockGeocoderProtocol()
init() {
func makeViewModel(event: VehicleEventDto) -> LocationPickerViewModel {
ServiceContainer.shared.register(GeocoderProtocol.self, instance: geocoder)
ServiceContainer.shared.register(SwiftLocationProtocol.self, instance: SwiftLocationMock())
ServiceContainer.shared.register(LocationServiceProtocol.self, instance: LocationService())
let locationService = LocationService(
geocoder: geocoderMock,
locationManager: MockSwiftLocationProtocol(),
settingsService: MockSettingsServiceProtocol()
)
return LocationPickerViewModel(locationService: locationService,
event: event)
}
@Test("Set initial location (user)")
func setInitialLocationUser() async throws {
let viewModel = LocationPickerViewModel(event: .init(lat: 0, lon: 0, addedBy: nil))
let viewModel = makeViewModel(event: .init(lat: 0, lon: 0, addedBy: nil))
#expect(viewModel.position == .userLocation(fallback: .automatic))
}
@ -39,7 +46,7 @@ struct LocationPickerTests {
@Test("Set initial location (custom)")
func setInitialLocationCustom() async throws {
let viewModel = LocationPickerViewModel(event: .init(lat: latitude, lon: longitude, addedBy: nil))
let viewModel = makeViewModel(event: .init(lat: latitude, lon: longitude, addedBy: nil))
#expect(viewModel.position.region?.center.latitude == latitude)
#expect(viewModel.position.region?.center.longitude == longitude)
@ -48,11 +55,14 @@ struct LocationPickerTests {
@Test("Update event")
func updateEvent() async throws {
let viewModel = LocationPickerViewModel(event: .init(lat: 0, lon: 0, addedBy: nil))
let viewModel = makeViewModel(event: .init(lat: 0, lon: 0, addedBy: nil))
geocoder.addLocation(latitude: latitude,
longitude: longitude,
address: address)
let location = CLLocation(latitude: latitude, longitude: longitude)
let placemark = CLPlacemark(location: location, name: address, postalAddress: nil)
given(geocoderMock)
.reverseGeocodeLocation(.any)
.willReturn([placemark])
await viewModel.updateEvent(center: .init(latitude: latitude, longitude: longitude))

View File

@ -29,9 +29,11 @@ final class NotesTests {
storageServiceMock = MockStorageServiceProtocol()
apiServiceMock = MockApiServiceProtocol()
ServiceContainer.shared.register(StorageServiceProtocol.self, instance: storageServiceMock)
ServiceContainer.shared.register(ApiServiceProtocol.self, instance: apiServiceMock)
viewModel = NotesViewModel(vehicle: VehicleDto())
viewModel = NotesViewModel(
storageService: storageServiceMock,
apiService: apiServiceMock,
vehicle: VehicleDto()
)
}
@Test("Add note (normal vehicle)")

View File

@ -29,10 +29,23 @@ class ReportTests {
settingsServiceMock = MockSettingsServiceProtocol()
apiServiceMock = MockApiServiceProtocol()
ServiceContainer.shared.register(StorageServiceProtocol.self, instance: storageServiceMock)
ServiceContainer.shared.register(SettingsServiceProtocol.self, instance: settingsServiceMock)
ServiceContainer.shared.register(ApiServiceProtocol.self, instance: apiServiceMock)
viewModel = ReportViewModel(vehicle: VehicleDto(), isPersistent: true)
viewModel = ReportViewModel(
apiService: apiServiceMock,
storageService: storageServiceMock,
settingsService: settingsServiceMock,
vehicle: VehicleDto(),
isPersistent: true
)
}
func makeViewModel(vehicle: VehicleDto, isPersistent: Bool) -> ReportViewModel {
ReportViewModel(
apiService: apiServiceMock,
storageService: storageServiceMock,
settingsService: settingsServiceMock,
vehicle: vehicle,
isPersistent: isPersistent
)
}
@Test("Load vehicle detail")
@ -41,7 +54,7 @@ class ReportTests {
let incompleteVehicleModel = VehicleDto(number: existingVehicleNumber)
let fullVehicleModel = VehicleDto(number: existingVehicleNumber, color: testColor)
viewModel = ReportViewModel(vehicle: incompleteVehicleModel, isPersistent: true)
viewModel.vehicle = incompleteVehicleModel
#expect(viewModel.vehicle.color == nil)
@ -62,8 +75,6 @@ class ReportTests {
@Test("Load vehicle error")
func loadVehicleError() async throws {
viewModel = ReportViewModel(vehicle: VehicleDto(), isPersistent: true)
given(storageServiceMock)
.loadVehicle(number: .any)
.willThrow(StorageError.vehicleNotFound)
@ -80,8 +91,6 @@ class ReportTests {
@Test("Show debug info", arguments: [true, false])
func showDebugInfo(value: Bool) async throws {
viewModel = ReportViewModel(vehicle: VehicleDto(), isPersistent: false)
given(settingsServiceMock)
.showDebugInfo.willReturn(value)
@ -94,7 +103,7 @@ class ReportTests {
let vehicle = VehicleDto(number: existingVehicleNumber)
let updatedVehicle = VehicleDto(number: existingVehicleNumber, color: testColor)
viewModel = ReportViewModel(vehicle: vehicle, isPersistent: isPersistent)
viewModel = makeViewModel(vehicle: vehicle, isPersistent: isPersistent)
given(apiServiceMock)
.checkVehicleGb(by: .value(existingVehicleNumber))
@ -104,6 +113,11 @@ class ReportTests {
.updateVehicleIfExists(dto: .value(updatedVehicle))
.willReturn()
given(storageServiceMock)
.loadVehicle(number: .value(existingVehicleNumber))
.willReturn(vehicle)
await viewModel.onAppear()
await viewModel.checkGB()
verify(apiServiceMock)
@ -114,6 +128,10 @@ class ReportTests {
.updateVehicleIfExists(dto: .value(updatedVehicle))
.called(.once)
verify(storageServiceMock)
.loadVehicle(number: .value(existingVehicleNumber))
.called(isPersistent ? .once : .never)
#expect(viewModel.vehicle.color == testColor)
#expect(viewModel.hud == nil)
}
@ -122,18 +140,27 @@ class ReportTests {
func checkGbError(isPersistent: Bool) async throws {
let vehicle = VehicleDto(number: existingVehicleNumber)
viewModel = ReportViewModel(vehicle: vehicle, isPersistent: isPersistent)
viewModel = makeViewModel(vehicle: vehicle, isPersistent: isPersistent)
given(apiServiceMock)
.checkVehicleGb(by: .value(existingVehicleNumber))
.willThrow(TestError.generic)
given(storageServiceMock)
.loadVehicle(number: .value(existingVehicleNumber))
.willReturn(vehicle)
await viewModel.onAppear()
await viewModel.checkGB()
verify(apiServiceMock)
.checkVehicleGb(by: .value(existingVehicleNumber))
.called(.once)
verify(storageServiceMock)
.loadVehicle(number: .value(existingVehicleNumber))
.called(isPersistent ? .once : .never)
#expect(viewModel.hud == .error(TestError.generic))
}
}