Initial commit for first version of project

This commit is contained in:
Dennis Nemec
2026-09-02 20:54:36 +02:00
parent 3d9f1ab6bc
commit 992bb32025
56 changed files with 5139 additions and 31 deletions

View File

@ -0,0 +1,191 @@
//
// ARFlowStateMachineTests.swift
// GasTankARTests
//
// Created by Dennis Nemec on 26.08.26.
//
import Testing
@testable import GasTankAR
struct ARFlowStateMachineTests {
// MARK: - Start & Initialisierung
@Test func startsInWorldNotInitializedAndRunsConfiguration() {
var machine = ARFlowStateMachine(hasStoredTransform: false)
let commands = machine.reduce(.started)
#expect(machine.state == .worldNotInitialized)
#expect(commands.contains(.runConfiguration(useImages: false, useInitialMap: true, resetTracking: false)))
}
@Test func initializingTracking() {
var machine = ARFlowStateMachine(hasStoredTransform: false)
_ = machine.reduce(.started)
_ = machine.reduce(.trackingInitializing)
#expect(machine.state == .initializingWorld)
}
// MARK: - Scene erfolgreich initialisiert → Location wählen / wiederherstellen
@Test func normalTrackingWithoutStoredTransformGoesToSelectLocation() {
var machine = ARFlowStateMachine(hasStoredTransform: false)
_ = machine.reduce(.started)
_ = machine.reduce(.trackingNormal)
#expect(machine.state == .selectLocation)
#expect(machine.hasPlacedTank == false)
}
@Test func normalTrackingWithStoredTransformRestoresTank() {
var machine = ARFlowStateMachine(hasStoredTransform: true)
_ = machine.reduce(.started)
let commands = machine.reduce(.trackingNormal)
#expect(machine.state == .immersive)
#expect(machine.hasPlacedTank == true)
#expect(commands.contains(.placeTank(.storedTransform)))
}
// MARK: - Platzieren
@Test func captureFromSelectLocationPlacesTankAndPersists() {
var machine = ARFlowStateMachine(hasStoredTransform: false)
_ = machine.reduce(.started)
_ = machine.reduce(.trackingNormal) // → selectLocation
let commands = machine.reduce(.captureConfirmed)
#expect(machine.state == .immersive)
#expect(machine.hasPlacedTank == true)
#expect(commands.contains(.placeTank(.screenCenter)))
#expect(commands.contains(.persistWorldMap))
#expect(commands.contains(.persistTankTransform))
}
@Test func captureIsIgnoredOutsideSelectLocation() {
var machine = ARFlowStateMachine(hasStoredTransform: false)
_ = machine.reduce(.started) // worldNotInitialized
let commands = machine.reduce(.captureConfirmed)
#expect(commands.isEmpty)
#expect(machine.state == .worldNotInitialized)
}
// MARK: - Eskalation bei zu wenigen Merkmalen
@Test func insufficientFeaturesEscalatesToShowOptions() {
var machine = ARFlowStateMachine(hasStoredTransform: false)
_ = machine.reduce(.started)
let first = machine.reduce(.trackingInsufficientFeatures)
#expect(machine.state == .initializingNotPossible)
#expect(first.contains(.startMoveAroundTimer))
_ = machine.reduce(.moveAroundTimerFired)
#expect(machine.state == .userMovesAround)
_ = machine.reduce(.showOptionsTimerFired)
#expect(machine.state == .showOptions)
}
@Test func timersDoNothingInWrongState() {
var machine = ARFlowStateMachine(hasStoredTransform: false)
_ = machine.reduce(.started)
// Kein initializingNotPossible aktiv → moveAroundTimer ignoriert.
let commands = machine.reduce(.moveAroundTimerFired)
#expect(commands.isEmpty)
#expect(machine.state == .worldNotInitialized)
}
// MARK: - Anker-Erkennung (Marker)
@Test func placeAnchorMarkerStartsImageDetection() {
var machine = ARFlowStateMachine(hasStoredTransform: false)
_ = machine.reduce(.started)
_ = machine.reduce(.trackingNormal) // selectLocation
let commands = machine.reduce(.placeAnchorMarkerTapped)
#expect(machine.state == .anchorDetection)
#expect(commands.contains(.runConfiguration(useImages: true, useInitialMap: false, resetTracking: false)))
#expect(commands.contains(.startAnchorDetectionTimer))
}
@Test func anchorDetectionTimeoutAsksToCancel() {
var machine = ARFlowStateMachine(hasStoredTransform: false)
_ = machine.reduce(.started)
_ = machine.reduce(.trackingNormal)
_ = machine.reduce(.placeAnchorMarkerTapped)
_ = machine.reduce(.anchorDetectionTimerFired)
#expect(machine.state == .askToCancel)
}
@Test func imageAnchorDetectedPlacesTank() {
var machine = ARFlowStateMachine(hasStoredTransform: false)
_ = machine.reduce(.started)
_ = machine.reduce(.trackingNormal)
_ = machine.reduce(.placeAnchorMarkerTapped)
let commands = machine.reduce(.imageAnchorDetected)
#expect(machine.state == .immersive)
#expect(machine.hasPlacedTank == true)
#expect(commands.contains(.placeTank(.imageAnchor)))
}
@Test func imageAnchorIgnoredWhenNotDetecting() {
var machine = ARFlowStateMachine(hasStoredTransform: false)
_ = machine.reduce(.started)
_ = machine.reduce(.trackingNormal) // selectLocation
let commands = machine.reduce(.imageAnchorDetected)
#expect(commands.isEmpty)
#expect(machine.state == .selectLocation)
}
// MARK: - Relokalisierung
@Test func relocalizingStartsTimerAndTimeoutFails() {
var machine = ARFlowStateMachine(hasStoredTransform: true)
_ = machine.reduce(.started)
let relocating = machine.reduce(.trackingRelocalizing)
#expect(machine.state == .relocating)
#expect(relocating.contains(.startRelocationTimer))
_ = machine.reduce(.relocationTimerFired)
#expect(machine.state == .relocationFailed)
}
@Test func normalTrackingWhilePlacedGoesImmersive() {
var machine = ARFlowStateMachine(hasStoredTransform: false)
_ = machine.reduce(.started)
_ = machine.reduce(.trackingNormal)
_ = machine.reduce(.captureConfirmed) // placed → immersive
_ = machine.reduce(.trackingRelocalizing)
#expect(machine.state == .relocating)
_ = machine.reduce(.trackingNormal)
#expect(machine.state == .immersive)
}
// MARK: - Neu platzieren / Neustart
@Test func repositionClearsTankAndReturnsToSelectLocation() {
var machine = ARFlowStateMachine(hasStoredTransform: true)
_ = machine.reduce(.started)
_ = machine.reduce(.trackingNormal) // restored → immersive
let commands = machine.reduce(.repositionTapped)
#expect(machine.state == .selectLocation)
#expect(machine.hasPlacedTank == false)
#expect(machine.hasStoredTransform == false)
#expect(commands.contains(.removeTank))
#expect(commands.contains(.clearStoredTransform))
}
@Test func restartResetsTracking() {
var machine = ARFlowStateMachine(hasStoredTransform: true)
_ = machine.reduce(.started)
_ = machine.reduce(.trackingRelocalizing)
_ = machine.reduce(.relocationTimerFired) // relocationFailed
let commands = machine.reduce(.restartTapped)
#expect(machine.state == .worldNotInitialized)
#expect(machine.hasStoredTransform == false)
#expect(commands.contains(.runConfiguration(useImages: false, useInitialMap: false, resetTracking: true)))
}
}

View File

@ -0,0 +1,218 @@
//
// DocuframeTests.swift
// GasTankARTests
//
// Created by Dennis Nemec on 28.08.26.
//
import Testing
import Foundation
import SwiftData
@testable import GasTankAR
// MARK: - Test-Doubles
/// Konfigurierbarer Mock des ``DocuframeService`` (Actor → threadsicher & Sendable),
/// der Aufrufe zählt und wahlweise einen Fehler wirft.
private actor MockDocuframeService: DocuframeService {
enum Behavior: Sendable {
case success
case failure(DocuframeError)
}
let behavior: Behavior
private(set) var uploadCallCount = 0
private(set) var sendEmailCallCount = 0
private(set) var lastPhotoCount = 0
private(set) var lastLeadID: String?
init(behavior: Behavior = .success) {
self.behavior = behavior
}
func uploadPhotos(_ photos: [DocuframePhoto], projectLeadID: String) async throws {
uploadCallCount += 1
record(photos: photos, leadID: projectLeadID)
try applyBehavior()
}
func sendEmail(_ photos: [DocuframePhoto], projectLeadID: String) async throws {
sendEmailCallCount += 1
record(photos: photos, leadID: projectLeadID)
try applyBehavior()
}
private func record(photos: [DocuframePhoto], leadID: String) {
lastPhotoCount = photos.count
lastLeadID = leadID
}
private func applyBehavior() throws {
if case .failure(let error) = behavior { throw error }
}
}
/// Hält Container und Fotos für die Dauer eines Tests am Leben.
@MainActor
private struct TestEnvironment {
let container: ModelContainer
let photos: [ProjectPhoto]
}
@MainActor
private func makeEnvironment(photoCount: Int) throws -> TestEnvironment {
let container = try ModelContainer(
for: Project.self,
configurations: ModelConfiguration(isStoredInMemoryOnly: true)
)
let context = container.mainContext
let project = Project(leadID: "LEAD-1", customerName: "Test", creationDate: Date(timeIntervalSince1970: 0))
context.insert(project)
var photos: [ProjectPhoto] = []
for index in 0..<photoCount {
let photo = ProjectPhoto(
creationDate: Date(timeIntervalSince1970: TimeInterval(index)),
imageData: Data([UInt8(index)]),
project: project
)
context.insert(photo)
photos.append(photo)
}
return TestEnvironment(container: container, photos: photos)
}
// MARK: - Fehler-Mapping
struct DocuframeErrorTests {
@Test func mapsHTTPStatusCodesToErrors() {
#expect(DocuframeError.from(statusCode: 200) == nil)
#expect(DocuframeError.from(statusCode: 302) == nil)
#expect(DocuframeError.from(statusCode: 400) == .clientError(statusCode: 400))
#expect(DocuframeError.from(statusCode: 404) == .clientError(statusCode: 404))
#expect(DocuframeError.from(statusCode: 500) == .serverError(statusCode: 500))
#expect(DocuframeError.from(statusCode: 503) == .serverError(statusCode: 503))
}
@Test func httpResponseValidationThrowsForClientAndServerErrors() throws {
let url = URL(string: "https://docuframe.example.com")!
let success = try #require(HTTPURLResponse(url: url, statusCode: 204, httpVersion: nil, headerFields: nil))
#expect(throws: Never.self) { try success.validateDocuframeStatus() }
let clientError = try #require(HTTPURLResponse(url: url, statusCode: 400, httpVersion: nil, headerFields: nil))
#expect(throws: DocuframeError.clientError(statusCode: 400)) { try clientError.validateDocuframeStatus() }
let serverError = try #require(HTTPURLResponse(url: url, statusCode: 500, httpVersion: nil, headerFields: nil))
#expect(throws: DocuframeError.serverError(statusCode: 500)) { try serverError.validateDocuframeStatus() }
}
}
// MARK: - ViewModel
@MainActor
struct DocuframeViewModelTests {
// MARK: Auswahl
@Test func togglingSelectionTracksPhotos() throws {
let env = try makeEnvironment(photoCount: 2)
let viewModel = DocuframeViewModel(service: MockDocuframeService())
#expect(!viewModel.hasSelection)
viewModel.toggleSelection(env.photos[0])
#expect(viewModel.isSelected(env.photos[0]))
#expect(viewModel.selectionCount == 1)
viewModel.toggleSelection(env.photos[0])
#expect(!viewModel.isSelected(env.photos[0]))
#expect(!viewModel.hasSelection)
}
// MARK: Erfolg
@Test func uploadSuccessSetsSuccessResult() async throws {
let env = try makeEnvironment(photoCount: 2)
let mock = MockDocuframeService(behavior: .success)
let viewModel = DocuframeViewModel(service: mock)
env.photos.forEach(viewModel.toggleSelection)
await viewModel.upload(from: env.photos, leadID: "LEAD-1")
#expect(viewModel.isProcessing == false)
let result = try #require(viewModel.result)
#expect(!result.isError)
#expect(result.title == "Erledigt")
#expect(result.message == "2 Foto(s) in DOCUframe hochgeladen.")
let uploadCount = await mock.uploadCallCount
let photoCount = await mock.lastPhotoCount
let leadID = await mock.lastLeadID
#expect(uploadCount == 1)
#expect(photoCount == 2)
#expect(leadID == "LEAD-1")
}
@Test func sendEmailSuccessSetsSuccessResult() async throws {
let env = try makeEnvironment(photoCount: 1)
let mock = MockDocuframeService(behavior: .success)
let viewModel = DocuframeViewModel(service: mock)
viewModel.toggleSelection(env.photos[0])
await viewModel.sendEmail(from: env.photos, leadID: "LEAD-1")
let result = try #require(viewModel.result)
#expect(!result.isError)
#expect(result.message == "1 Foto(s) per E-Mail versendet.")
let sendCount = await mock.sendEmailCallCount
#expect(sendCount == 1)
}
// MARK: HTTP-Fehler
@Test func uploadServerErrorSetsFailureResult() async throws {
let env = try makeEnvironment(photoCount: 1)
let mock = MockDocuframeService(behavior: .failure(.serverError(statusCode: 500)))
let viewModel = DocuframeViewModel(service: mock)
viewModel.toggleSelection(env.photos[0])
await viewModel.upload(from: env.photos, leadID: "LEAD-1")
#expect(viewModel.isProcessing == false)
let result = try #require(viewModel.result)
#expect(result.isError)
#expect(result.title == "Fehler")
#expect(result.message == DocuframeError.serverError(statusCode: 500).localizedDescription)
}
@Test func sendEmailClientErrorSetsFailureResult() async throws {
let env = try makeEnvironment(photoCount: 1)
let mock = MockDocuframeService(behavior: .failure(.clientError(statusCode: 400)))
let viewModel = DocuframeViewModel(service: mock)
viewModel.toggleSelection(env.photos[0])
await viewModel.sendEmail(from: env.photos, leadID: "LEAD-1")
let result = try #require(viewModel.result)
#expect(result.isError)
#expect(result.message == DocuframeError.clientError(statusCode: 400).localizedDescription)
}
// MARK: Keine Auswahl
@Test func withoutSelectionReportsErrorAndSkipsService() async throws {
let env = try makeEnvironment(photoCount: 2)
let mock = MockDocuframeService()
let viewModel = DocuframeViewModel(service: mock)
await viewModel.upload(from: env.photos, leadID: "LEAD-1")
let result = try #require(viewModel.result)
#expect(result.isError)
#expect(result.message == DocuframeError.noPhotosSelected.localizedDescription)
let uploadCount = await mock.uploadCallCount
#expect(uploadCount == 0)
}
}