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

View File

@ -83,14 +83,22 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
let container = ServiceContainer.shared 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(ApiServiceProtocol.self, instance: ApiService())
container.register(GeocoderProtocol.self, instance: CLGeocoder())
container.register(SwiftLocationProtocol.self, instance: Location()) let locationService = LocationService(
container.register(LocationServiceProtocol.self, instance: LocationService()) geocoder: CLGeocoder(),
locationManager: Location(),
settingsService: settingsService
)
container.register(LocationServiceProtocol.self, instance: locationService)
Task { 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 { 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 viewModel.coordinator = self
let controller = CustomHostingController(rootView: EventsScreen(viewModel: viewModel)) let controller = CustomHostingController(rootView: EventsScreen(viewModel: viewModel))
navController.pushViewController(controller, animated: true) navController.pushViewController(controller, animated: true)

View File

@ -138,5 +138,8 @@ struct EventsScreen: View {
} }
#Preview { #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 typealias VehicleOperation = () async throws -> VehicleDto
@ObservationIgnored @Service var apiService: ApiServiceProtocol let apiService: ApiServiceProtocol
@ObservationIgnored @Service var storageService: StorageServiceProtocol let storageService: StorageServiceProtocol
@ObservationIgnored @Service var settingsService: SettingsServiceProtocol let settingsService: SettingsServiceProtocol
weak var coordinator: EventsCoordinator? weak var coordinator: EventsCoordinator?
@ -45,8 +45,14 @@ class EventsViewModel: ACHudContainer {
UIPasteboard.general.data(forPasteboardType: UTType.vehicleEvent.identifier) != nil 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 self.vehicle = vehicle
updateEvents() updateEvents()

View File

@ -22,7 +22,10 @@ class FiltersCoordinator: Coordinator {
} }
func start() async throws -> Filter? { 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)) let controller = CustomHostingController(rootView: FiltersScreen(viewModel: viewModel))
viewController?.pushViewController(controller, animated: true) viewController?.pushViewController(controller, animated: true)
await controller.waitForDisappear() await controller.waitForDisappear()

View File

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

View File

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

View File

@ -23,7 +23,10 @@ final class LocationPickerCoordinator: Coordinator {
func start() async throws -> VehicleEventDto? { 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 screen = LocationPickerScreen(viewModel: viewModel)
let controller = CustomHostingController(rootView: screen) let controller = CustomHostingController(rootView: screen)
viewController?.pushViewController(controller, animated: true) 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) var event = VehicleEventDto(lat: 47.250049, lon: 39.711821, addedBy: nil)
event.address = "Ул. Ленина, 123" event.address = "Ул. Ленина, 123"
let viewModel = LocationPickerViewModel(event: event) let viewModel = LocationPickerViewModel(
locationService: ServiceContainer.shared.resolve(LocationServiceProtocol.self),
event: event
)
return LocationPickerScreen(viewModel: viewModel) return LocationPickerScreen(viewModel: viewModel)
} }

View File

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

View File

@ -24,7 +24,12 @@ class NotesCoordinator: Coordinator {
func start() async throws -> VehicleDto { 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)) let controller = CustomHostingController(rootView: NotesScreen(viewModel: viewModel))
viewController?.pushViewController(controller, animated: true) viewController?.pushViewController(controller, animated: true)
await controller.waitForDisappear() await controller.waitForDisappear()

View File

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

View File

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

View File

@ -28,7 +28,14 @@ class ReportCoordinator: Coordinator {
func start() async throws -> VehicleDto { 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 viewModel.coordinator = self
let controller = CustomHostingController(rootView: ReportScreen(viewModel: viewModel)) let controller = CustomHostingController(rootView: ReportScreen(viewModel: viewModel))
let newNavController = UINavigationController(rootViewController: controller) let newNavController = UINavigationController(rootViewController: controller)

View File

@ -126,5 +126,11 @@ struct ReportScreen: View {
} }
#Preview { #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 @Observable
class ReportViewModel: ACHudContainer { class ReportViewModel: ACHudContainer {
@ObservationIgnored @Service var api: ApiServiceProtocol let apiService: ApiServiceProtocol
@ObservationIgnored @Service var storageService: StorageServiceProtocol let storageService: StorageServiceProtocol
@ObservationIgnored @Service var settings: SettingsServiceProtocol let settingsService: SettingsServiceProtocol
var coordinator: ReportCoordinator? var coordinator: ReportCoordinator?
@ -44,7 +44,7 @@ class ReportViewModel: ACHudContainer {
} }
var showDebugInfo: Bool { var showDebugInfo: Bool {
settings.showDebugInfo settingsService.showDebugInfo
} }
var shareLink: URL? { var shareLink: URL? {
@ -55,7 +55,15 @@ class ReportViewModel: ACHudContainer {
return URL(string: Constants.reportLinkBaseURL + "?token=" + jwt) 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.vehicle = vehicle
self.isPersistent = isPersistent self.isPersistent = isPersistent
} }
@ -84,7 +92,7 @@ class ReportViewModel: ACHudContainer {
func checkGB() async { func checkGB() async {
await wrapWithToast { 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) try await self.storageService.updateVehicleIfExists(dto: self.vehicle)
} }
} }

View File

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

View File

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

View File

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

View File

@ -6,6 +6,8 @@
// Copyright © 2024 Selim Mustafaev. All rights reserved. // Copyright © 2024 Selim Mustafaev. All rights reserved.
// //
import Foundation
public enum DIError: Error { public enum DIError: Error {
case wrongServiceType(String) case wrongServiceType(String)
@ -47,18 +49,18 @@ public class ServiceContainer {
if let service = cachedService as? Service { if let service = cachedService as? Service {
return service return service
} else { } else {
throw DIError.wrongServiceType(type) fatalError("Wrong service type for service: \(type)")
} }
} }
guard let factory = factories[type] else { guard let factory = factories[type] else {
throw DIError.serviceNotFound(type) fatalError("Service \(type) not found")
} }
if let service = factory() as? Service { if let service = factory() as? Service {
return service return service
} else { } 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. // Copyright © 2024 Selim Mustafaev. All rights reserved.
// //
@MainActor
@propertyWrapper @propertyWrapper
@MainActor
public struct Service<Service> { public struct Service<Service> {
public var 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 CoreLocation
import Mockable
@Mockable
public protocol GeocoderProtocol { public protocol GeocoderProtocol {
func reverseGeocodeLocation(_ location: CLLocation) async throws -> [CLPlacemark] func reverseGeocodeLocation(_ location: CLLocation) async throws -> [CLPlacemark]

View File

@ -12,14 +12,19 @@ import SwiftLocation
@MainActor @MainActor
public final class LocationService { public final class LocationService {
@Service var geocoder: GeocoderProtocol let geocoder: GeocoderProtocol
@Service var locationManager: SwiftLocationProtocol let locationManager: SwiftLocationProtocol
@Service var settingsService: SettingsServiceProtocol let settingsService: SettingsServiceProtocol
private var eventTask: Task<VehicleEventDto,Error>? 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 { private func checkPermissions() async throws {

View File

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

View File

@ -26,12 +26,14 @@ public enum StorageError: LocalizedError {
public actor StorageService: StorageServiceProtocol { public actor StorageService: StorageServiceProtocol {
@Service var settingsService: SettingsServiceProtocol let settingsService: SettingsServiceProtocol
var realm: Realm! 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) realm = try await Realm(configuration: config, actor: self)
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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