Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 24 additions & 2 deletions Skinia/Services/Camera/CameraService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -28,6 +35,7 @@ final class CameraService: CameraServiceProtocol {
exam.addPhoto(photo)

try photoRepository.save(photo)
try examRepository.update(exam)

Task { @MainActor in
do {
Expand All @@ -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
}
}
62 changes: 62 additions & 0 deletions Skinia/Services/Storage/ExamRepository.swift
Original file line number Diff line number Diff line change
@@ -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> { exam in
exam.patientID == id && !exam.isDeleted
}
let descriptor = FetchDescriptor<Exam>(
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> { exam in
exam.patientName == name && !exam.isDeleted
}
let descriptor = FetchDescriptor<Exam>(
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()
}
}
8 changes: 7 additions & 1 deletion Skinia/Utilities/DependencyContainer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down Expand Up @@ -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,
Expand All @@ -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()
Expand Down
85 changes: 85 additions & 0 deletions SkiniaTests/CameraServiceTests.swift
Original file line number Diff line number Diff line change
@@ -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 {}
}
80 changes: 80 additions & 0 deletions SkiniaTests/ExamRepositoryTests.swift
Original file line number Diff line number Diff line change
@@ -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")
}
}