From 3c1f69ae4159a0e855d3d6b314153c0234ccc46a Mon Sep 17 00:00:00 2001 From: Thales Matheus <133025183+ThalesMMS@users.noreply.github.com> Date: Mon, 22 Sep 2025 14:31:53 -0300 Subject: [PATCH] Add exam repository and reuse exams in camera service --- Skinia/Services/Camera/CameraService.swift | 26 +++++- Skinia/Services/Storage/ExamRepository.swift | 62 ++++++++++++++ Skinia/Utilities/DependencyContainer.swift | 8 +- SkiniaTests/CameraServiceTests.swift | 85 ++++++++++++++++++++ SkiniaTests/ExamRepositoryTests.swift | 80 ++++++++++++++++++ 5 files changed, 258 insertions(+), 3 deletions(-) create mode 100644 Skinia/Services/Storage/ExamRepository.swift create mode 100644 SkiniaTests/CameraServiceTests.swift create mode 100644 SkiniaTests/ExamRepositoryTests.swift diff --git a/Skinia/Services/Camera/CameraService.swift b/Skinia/Services/Camera/CameraService.swift index cf0da66..41aba9b 100644 --- a/Skinia/Services/Camera/CameraService.swift +++ b/Skinia/Services/Camera/CameraService.swift @@ -5,11 +5,18 @@ final class CameraService: CameraServiceProtocol { private let photoRepository: any PhotoRepositoryProtocol private let analysisService: any AnalysisServiceProtocol private let notificationManager: NotificationManager + private let examRepository: any ExamRepositoryProtocol - init(photoRepository: any PhotoRepositoryProtocol, analysisService: any AnalysisServiceProtocol, notificationManager: NotificationManager) { + init( + photoRepository: any PhotoRepositoryProtocol, + analysisService: any AnalysisServiceProtocol, + notificationManager: NotificationManager, + examRepository: any ExamRepositoryProtocol + ) { self.photoRepository = photoRepository self.analysisService = analysisService self.notificationManager = notificationManager + self.examRepository = examRepository } func savePhoto(_ imageData: Data, bodyLocation: String?, userNotes: String?, patientName: String?, patientID: String?, metadata: PhotoMetadata) async throws -> SkinLesionPhoto { @@ -28,6 +35,7 @@ final class CameraService: CameraServiceProtocol { exam.addPhoto(photo) try photoRepository.save(photo) + try examRepository.update(exam) Task { @MainActor in do { @@ -51,6 +59,20 @@ final class CameraService: CameraServiceProtocol { } private func findOrCreateExam(patientName: String?, patientID: String?) async throws -> Exam { - return Exam(patientName: patientName, patientID: patientID) + let trimmedID = patientID?.trimmingCharacters(in: .whitespacesAndNewlines) + let trimmedName = patientName?.trimmingCharacters(in: .whitespacesAndNewlines) + + if let existingExam = try examRepository.fetch(patientID: trimmedID, patientName: trimmedName) { + if existingExam.patientID != trimmedID || existingExam.patientName != trimmedName { + existingExam.updatePatientInfo(name: trimmedName, id: trimmedID) + try examRepository.update(existingExam) + } + + return existingExam + } + + let newExam = Exam(patientName: trimmedName, patientID: trimmedID) + try examRepository.save(newExam) + return newExam } } diff --git a/Skinia/Services/Storage/ExamRepository.swift b/Skinia/Services/Storage/ExamRepository.swift new file mode 100644 index 0000000..ae30a90 --- /dev/null +++ b/Skinia/Services/Storage/ExamRepository.swift @@ -0,0 +1,62 @@ +import Foundation +import SwiftData + +@MainActor +protocol ExamRepositoryProtocol { + func fetch(patientID: String?, patientName: String?) throws -> Exam? + func save(_ exam: Exam) throws + func update(_ exam: Exam) throws +} + +@MainActor +final class ExamRepository: ExamRepositoryProtocol { + private let modelContainer: ModelContainer + private var modelContext: ModelContext { + modelContainer.mainContext + } + + init(modelContainer: ModelContainer) { + self.modelContainer = modelContainer + } + + func fetch(patientID: String?, patientName: String?) throws -> Exam? { + let trimmedID = patientID?.trimmingCharacters(in: .whitespacesAndNewlines) + let trimmedName = patientName?.trimmingCharacters(in: .whitespacesAndNewlines) + + if let id = trimmedID, !id.isEmpty { + let predicate = #Predicate { exam in + exam.patientID == id && !exam.isDeleted + } + let descriptor = FetchDescriptor( + predicate: predicate, + sortBy: [SortDescriptor(\.lastUpdated, order: .reverse)] + ) + if let exam = try modelContext.fetch(descriptor).first { + return exam + } + } + + if let name = trimmedName, !name.isEmpty { + let predicate = #Predicate { exam in + exam.patientName == name && !exam.isDeleted + } + let descriptor = FetchDescriptor( + predicate: predicate, + sortBy: [SortDescriptor(\.lastUpdated, order: .reverse)] + ) + return try modelContext.fetch(descriptor).first + } + + return nil + } + + func save(_ exam: Exam) throws { + modelContext.insert(exam) + try modelContext.save() + } + + func update(_ exam: Exam) throws { + exam.lastUpdated = Date() + try modelContext.save() + } +} diff --git a/Skinia/Utilities/DependencyContainer.swift b/Skinia/Utilities/DependencyContainer.swift index e88d532..00d3986 100644 --- a/Skinia/Utilities/DependencyContainer.swift +++ b/Skinia/Utilities/DependencyContainer.swift @@ -5,6 +5,7 @@ import SwiftData protocol DependencyContainerProtocol { // Services var photoRepository: any PhotoRepositoryProtocol { get } + var examRepository: any ExamRepositoryProtocol { get } var analysisService: any AnalysisServiceProtocol { get } var cameraService: any CameraServiceProtocol { get } var networkService: any NetworkServiceProtocol { get } @@ -49,6 +50,10 @@ final class DependencyContainer: DependencyContainerProtocol { modelContainer: modelContainer ) + lazy var examRepository: any ExamRepositoryProtocol = ExamRepository( + modelContainer: modelContainer + ) + lazy var analysisService: any AnalysisServiceProtocol = AnalysisService( networkService: networkService, photoRepository: photoRepository, @@ -58,7 +63,8 @@ final class DependencyContainer: DependencyContainerProtocol { lazy var cameraService: any CameraServiceProtocol = CameraService( photoRepository: photoRepository, analysisService: analysisService, - notificationManager: notificationManager + notificationManager: notificationManager, + examRepository: examRepository ) lazy var networkService: any NetworkServiceProtocol = RemoteAnalysisNetworkService() diff --git a/SkiniaTests/CameraServiceTests.swift b/SkiniaTests/CameraServiceTests.swift new file mode 100644 index 0000000..028369f --- /dev/null +++ b/SkiniaTests/CameraServiceTests.swift @@ -0,0 +1,85 @@ +import Foundation +import SwiftData +import Testing +@testable import Skinia + +@MainActor +struct CameraServiceTests { + + private func createInMemoryContainer() -> ModelContainer { + let schema = Schema([ + SkinLesionPhoto.self, + AnalysisResult.self, + PhotoMetadata.self, + Exam.self + ]) + let configuration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true) + + do { + return try ModelContainer(for: schema, configurations: [configuration]) + } catch { + fatalError("Failed to create in-memory container: \(error)") + } + } + + @Test + func savesPhotosIntoSharedExamForSamePatient() async throws { + let container = createInMemoryContainer() + let photoRepository = PhotoRepository(modelContainer: container) + let examRepository = ExamRepository(modelContainer: container) + let analysisService = MockAnalysisService() + let notificationManager = NotificationManager() + let cameraService = CameraService( + photoRepository: photoRepository, + analysisService: analysisService, + notificationManager: notificationManager, + examRepository: examRepository + ) + + let metadata1 = PhotoMetadata(bodyLocation: "Braço") + let firstPhoto = try await cameraService.savePhoto( + Data([0x01, 0x02, 0x03]), + bodyLocation: "Braço", + userNotes: "Primeira foto", + patientName: "Paciente Teste", + patientID: "ID123", + metadata: metadata1 + ) + await Task.yield() + + let metadata2 = PhotoMetadata(bodyLocation: "Braço") + let secondPhoto = try await cameraService.savePhoto( + Data([0x04, 0x05, 0x06]), + bodyLocation: "Braço", + userNotes: "Segunda foto", + patientName: "Paciente Teste", + patientID: "ID123", + metadata: metadata2 + ) + await Task.yield() + + #expect(firstPhoto.exam?.id == secondPhoto.exam?.id) + + let storedExam = try examRepository.fetch(patientID: "ID123", patientName: "Paciente Teste") + #expect(storedExam?.photoCount == 2) + #expect(analysisService.startedPhotoIDs.contains(firstPhoto.id)) + #expect(analysisService.startedPhotoIDs.contains(secondPhoto.id)) + } +} + +@MainActor +final class MockAnalysisService: AnalysisServiceProtocol { + private(set) var startedPhotoIDs: [UUID] = [] + + func startAnalysis(for photo: SkinLesionPhoto) async throws { + startedPhotoIDs.append(photo.id) + } + + func getAnalysisProgress(for photoId: UUID) -> AnalysisProgress? { + nil + } + + func cancelAnalysis(for photoId: UUID) async {} + + func retryAnalysis(for photo: SkinLesionPhoto) async throws {} +} diff --git a/SkiniaTests/ExamRepositoryTests.swift b/SkiniaTests/ExamRepositoryTests.swift new file mode 100644 index 0000000..7e7b091 --- /dev/null +++ b/SkiniaTests/ExamRepositoryTests.swift @@ -0,0 +1,80 @@ +import Foundation +import SwiftData +import Testing +@testable import Skinia + +@MainActor +struct ExamRepositoryTests { + + private func createInMemoryContainer() -> ModelContainer { + let schema = Schema([ + Exam.self, + SkinLesionPhoto.self, + PhotoMetadata.self, + AnalysisResult.self + ]) + let configuration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true) + + do { + return try ModelContainer(for: schema, configurations: [configuration]) + } catch { + fatalError("Failed to create in-memory container: \(error)") + } + } + + @Test + func saveAndFetchByPatientID() throws { + let container = createInMemoryContainer() + let repository = ExamRepository(modelContainer: container) + let exam = Exam(patientName: "Maria", patientID: "ABC123") + + try repository.save(exam) + + let fetched = try repository.fetch(patientID: "ABC123", patientName: nil) + #expect(fetched?.id == exam.id) + #expect(fetched?.patientName == "Maria") + } + + @Test + func fetchFallsBackToPatientName() throws { + let container = createInMemoryContainer() + let repository = ExamRepository(modelContainer: container) + let exam = Exam(patientName: "João", patientID: nil) + + try repository.save(exam) + + let fetched = try repository.fetch(patientID: nil, patientName: "João") + #expect(fetched?.id == exam.id) + } + + @Test + func fetchIgnoresDeletedExams() throws { + let container = createInMemoryContainer() + let repository = ExamRepository(modelContainer: container) + let exam = Exam(patientName: "Ana", patientID: "XYZ987") + + try repository.save(exam) + + exam.markAsDeleted() + try repository.update(exam) + + let fetched = try repository.fetch(patientID: "XYZ987", patientName: "Ana") + #expect(fetched == nil) + } + + @Test + func updatePersistsPatientInformationChanges() throws { + let container = createInMemoryContainer() + let repository = ExamRepository(modelContainer: container) + let exam = Exam(patientName: "Pedro", patientID: "111") + + try repository.save(exam) + + exam.updatePatientInfo(name: "Pedro Silva", id: "222") + try repository.update(exam) + + let fetched = try repository.fetch(patientID: "222", patientName: "Pedro Silva") + #expect(fetched?.patientName == "Pedro Silva") + #expect(fetched?.patientID == "222") + } +}