Compare commits

...

12 Commits

Author SHA1 Message Date
NOrtmann1 489c4fa070 Fehlerbehandlung
Robuste Behandlung aller bekannten Fehlerfälle.
- Speichermangel behandelt
- Bildfehler behandelt
- PDF-Fehler behandelt
- Datenverlust verhindert
2026-10-14 17:14:05 +02:00
niklas.ortmann e9403801d6 Merge pull request 'Kamera-Button + neue Inhalte' (#17) from dev-Inhalte3 into dev
Reviewed-on: #17
2026-10-12 01:58:20 +02:00
NOrtmann1 18a7af800f Kamera-Button + neue Inhalte 2026-10-12 01:57:56 +02:00
niklas.ortmann 51370a9172 Merge pull request 'QA-001' (#15) from dev-QA001 into dev
Reviewed-on: #15
2026-10-09 10:02:08 +02:00
niklas.ortmann 26cc81388e Update PatientRepositoryTest.kt 2026-10-09 10:01:32 +02:00
niklas.ortmann dcfa11bef9 QA-001
- [x] Dateinamen-Tests vorhanden
- [x] Fortsetzen-Tests vorhanden
- [x] Blockstatus-Tests vorhanden
- [x] Repository-Tests vorhanden
2026-10-08 12:15:31 +02:00
niklas.ortmann e6bc99d144 Update DocumentationEditScreen.kt 2026-10-08 00:22:06 +02:00
niklas.ortmann 2bc0d959e1 README.md aktualisiert 2026-10-07 11:49:41 +02:00
niklas.ortmann b77f7f0f20 README.md aktualisiert 2026-10-07 11:48:14 +02:00
niklas.ortmann 70bdc84cfa README.md aktualisiert 2026-10-07 11:47:54 +02:00
niklas.ortmann cefecb86dd README.md aktualisiert 2026-10-07 11:44:59 +02:00
niklas.ortmann 1fdcceb149 Merge pull request 'neue Boxinhalte nach Kundenwunsch' (#10) from dev-Inhalte2 into dev
Reviewed-on: #10
2026-10-03 00:35:27 +02:00
36 changed files with 5084 additions and 236 deletions
+37 -77
View File
@@ -7,7 +7,7 @@ Die App ermöglicht die Verwaltung von Patienten und Wunddokumentationen,
unterstützt Fotos und digitale Unterschriften und erzeugt aus unterstützt Fotos und digitale Unterschriften und erzeugt aus
abgeschlossenen Dokumentationen PDF-Dateien. abgeschlossenen Dokumentationen PDF-Dateien.
> **Projektstatus:** In Entwicklung > **Projektstatus:** beta1.1
------------------------------------------------------------------------ ------------------------------------------------------------------------
@@ -344,32 +344,6 @@ werden kann.
- JDK über Android Studio - JDK über Android Studio
- Android-Gerät oder Android Emulator - Android-Gerät oder Android Emulator
### Projekt öffnen
Repository klonen:
``` bash
git clone <repository-url>
cd VulneraDoc
```
Anschließend den Projektordner in Android Studio öffnen.
Android Studio führt beim ersten Öffnen einen Gradle-Sync durch und lädt
die benötigten Dependencies.
Danach:
``` text
Gradle Sync
↓
Build / Make Project
↓
Android-Gerät oder Emulator auswählen
↓
Run
```
------------------------------------------------------------------------ ------------------------------------------------------------------------
## Projektphasen ## Projektphasen
@@ -387,40 +361,40 @@ Run
### Phase 2 -- Projektgrundgerüst ### Phase 2 -- Projektgrundgerüst
- [x] Android-Projekt - [x] Android-Projekt
- [ ] Room-Datenbank - [x] Room-Datenbank
- [ ] Entities - [x] Entities
- [ ] DAOs - [x] DAOs
- [ ] Repositories - [x] Repositories
- [ ] Navigation - [x] Navigation
- [ ] ViewModels - [x] ViewModels
### Phase 3 -- Kernfunktionalität ### Phase 3 -- Kernfunktionalität
- [ ] Patientenverwaltung - [x] Patientenverwaltung
- [ ] Patientenliste - [x] Patientenliste
- [ ] Wunddokumentationsliste - [x] Wunddokumentationsliste
- [ ] Dokumentation erstellen - [x] Dokumentation erstellen
- [ ] Dokumentationsblöcke - [x] Dokumentationsblöcke
- [ ] Blocknavigation - [x] Blocknavigation
- [ ] Autosave - [x] Autosave
- [ ] Dokumentation bearbeiten - [x] Dokumentation bearbeiten
- [ ] Dokumentation fortsetzen - [x] Dokumentation fortsetzen
### Phase 4 -- Medien ### Phase 4 -- Medien
- [ ] Fotoimport - [x] Fotoimport
- [ ] Bildverwaltung - [x] Bildverwaltung
- [ ] Digitale Signatur - [x] Digitale Signatur
- [ ] Mehrere Signaturen - [x] Mehrere Signaturen
### Phase 5 -- PDF ### Phase 5 -- PDF
- [ ] PDF-Exporter - [x] PDF-Exporter
- [ ] Dokumentationsblöcke - [x] Dokumentationsblöcke
- [ ] Signaturen - [x] Signaturen
- [ ] Fotoanhang - [x] Fotoanhang
- [ ] PDF anzeigen - [x] PDF anzeigen
- [ ] PDF teilen - [x] PDF teilen
### Phase 6 -- Qualität und Stabilität ### Phase 6 -- Qualität und Stabilität
@@ -431,29 +405,15 @@ Run
- [ ] Tablet-Optimierung - [ ] Tablet-Optimierung
- [ ] Datenschutz-Review - [ ] Datenschutz-Review
------------------------------------------------------------------------ ### Phase 7 -- Multi-Device-Synchronisation
## Dokumentation - [ ] Geräteidentität und Pairing
- [ ] Automatische Geräteerkennung im WLAN
Die ausführliche technische Architektur befindet sich in der - [ ] Differenzabgleich / Sync-Protokoll
Projektdokumentation: - [ ] Patienten synchronisieren
- [ ] Wunddokumentationen synchronisieren
``` text - [ ] Fotos synchronisieren
docs/ - [ ] Konflikterkennung und Konfliktauflösung
└── Architekturplanung.md - [ ] Automatische Synchronisation
``` - [ ] Sync-Status und manueller Fallback
- [ ] Multi-Device- und Recovery-Tests
Dort werden unter anderem Datenmodell, Datenbankschema,
Navigationskonzept, Autosave, PDF-Erzeugung und Medienverwaltung
detailliert beschrieben.
------------------------------------------------------------------------
## Status
**VulneraDoc befindet sich aktuell in Entwicklung und ist nicht für den
produktiven Einsatz freigegeben.**
Insbesondere bei der Verarbeitung realer Patientendaten müssen vor einem
Produktiveinsatz die rechtlichen, datenschutzrechtlichen und
sicherheitstechnischen Anforderungen abschließend geprüft werden.
@@ -0,0 +1,378 @@
package de.medapp.wunddoku.data.repository
import android.net.Uri
import androidx.test.ext.junit.runners.AndroidJUnit4
import de.medapp.wunddoku.data.entity.BlockStateEntity
import de.medapp.wunddoku.data.entity.CheckItemStateEntity
import de.medapp.wunddoku.data.entity.DocumentationEntity
import de.medapp.wunddoku.domain.catalog.DocumentationCatalog
import de.medapp.wunddoku.domain.model.CheckItem
import de.medapp.wunddoku.domain.model.Documentation
import de.medapp.wunddoku.domain.model.DocumentationStatus
import de.medapp.wunddoku.testutil.DuplicateCall
import de.medapp.wunddoku.testutil.TestEnvironment
import de.medapp.wunddoku.testutil.expectThrows
import de.medapp.wunddoku.testutil.firstPlainItem
import de.medapp.wunddoku.testutil.runDbTest
import de.medapp.wunddoku.testutil.signatureBitmap
import kotlinx.coroutines.flow.first
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import java.io.IOException
import java.time.LocalDate
/**
* Fortsetzen-Tests: "Dokumentation fortsetzen" erzeugt aus dem Stand einer vorhandenen
* Dokumentation eine NEUE; die Quelle bleibt unverändert.
*/
@RunWith(AndroidJUnit4::class)
class ContinueDocumentationTest {
private lateinit var env: TestEnvironment
private val repo get() = env.documentationRepository
@Before
fun setUp() {
env = TestEnvironment()
}
@After
fun tearDown() {
env.close()
}
/** Alle Items eines Dokuments als Map definitionId (inkl. Blockbezug) -> Item. */
private fun Documentation.itemsByKey(): Map<String, CheckItem> =
blocks.flatMap { block -> block.items.map { "${block.definitionId}/${it.definitionId}" to it } }.toMap()
/** Bereitet eine Quelle mit einigen Eingaben vor und liefert sie frisch geladen zurück. */
private suspend fun prepareSourceWithInput(): Documentation {
env.insertPatient()
val created = repo.createEmpty("patient-1")
val firstBlock = created.blocks.first()
repo.setChecked(firstBlock.firstPlainItem().checkItemStateId, true)
val freeText = firstBlock.items.last()
repo.setChecked(freeText.checkItemStateId, true)
repo.updateFreeText(freeText.checkItemStateId, "Rötung am Wundrand")
return repo.getFullDocumentation(created.id)!!
}
// --- Inhalt wird übernommen ----------------------------------------------------------
@Test
fun fortsetzen_uebernimmt_bloecke_und_optionen_inklusive_haken_und_freitext() = runDbTest {
val source = prepareSourceWithInput()
val copy = repo.continueDocumentation(source.id)
assertEquals(source.blocks.size, copy.blocks.size)
for (sourceBlock in source.blocks) {
val copyBlock = copy.blocks.single { it.definitionId == sourceBlock.definitionId }
assertEquals(sourceBlock.title, copyBlock.title)
assertEquals(sourceBlock.order, copyBlock.order)
assertEquals(sourceBlock.isEdited, copyBlock.isEdited)
}
// Option für Option: gleiche Inhalte (Label, Reihenfolge, Haken, Freitext, Hinweis, strukturierte Eingabe).
val sourceItems = source.itemsByKey()
val copyItems = copy.itemsByKey()
assertEquals(sourceItems.keys, copyItems.keys)
for ((key, sourceItem) in sourceItems) {
val copyItem = copyItems.getValue(key)
assertEquals(key, sourceItem.label, copyItem.label)
assertEquals(key, sourceItem.order, copyItem.order)
assertEquals(key, sourceItem.checked, copyItem.checked)
assertEquals(key, sourceItem.isFreeText, copyItem.isFreeText)
assertEquals(key, sourceItem.freeTextValue, copyItem.freeTextValue)
assertEquals(key, sourceItem.isStaticHint, copyItem.isStaticHint)
assertEquals(key, sourceItem.inputSpec, copyItem.inputSpec)
}
assertEquals(1, copy.blocks.count { it.isEdited })
assertEquals("Rötung am Wundrand", copy.blocks.first().items.last().freeTextValue)
}
@Test
fun fortsetzen_erzeugt_ausschliesslich_neue_ids() = runDbTest {
val source = prepareSourceWithInput()
val copy = repo.continueDocumentation(source.id)
assertNotEquals(source.id, copy.id)
val sourceBlockIds = source.blocks.map { it.blockStateId }.toSet()
val sourceItemIds = source.blocks.flatMap { b -> b.items.map { it.checkItemStateId } }.toSet()
assertTrue(copy.blocks.none { it.blockStateId in sourceBlockIds })
assertTrue(copy.blocks.flatMap { b -> b.items.map { it.checkItemStateId } }.none { it in sourceItemIds })
}
@Test
fun fortsetzen_setzt_metadaten_der_neuen_dokumentation_korrekt() = runDbTest {
val source = prepareSourceWithInput()
env.setStatus(source.id, DocumentationStatus.COMPLETED)
val copy = repo.continueDocumentation(source.id)
assertEquals(DocumentationStatus.IN_PROGRESS, copy.status)
assertEquals(source.id, copy.continuedFromDocumentationId)
assertEquals(source.patientId, copy.patientId)
assertEquals(copy.createdAt, copy.updatedAt)
assertEquals(DocumentationStatus.COMPLETED, repo.getFullDocumentation(source.id)!!.status)
assertEquals(2, env.db.documentationDao().countForPatient("patient-1"))
assertTrue(env.patientRepository.hasDocumentations("patient-1"))
}
// --- Quelle bleibt unverändert / Unabhängigkeit --------------------------------------
@Test
fun fortsetzen_laesst_die_quelldokumentation_unveraendert() = runDbTest {
val source = prepareSourceWithInput()
repo.continueDocumentation(source.id)
val sourceAfter = repo.getFullDocumentation(source.id)!!
assertEquals(source.status, sourceAfter.status)
assertEquals(source.createdAt, sourceAfter.createdAt)
assertEquals(source.updatedAt, sourceAfter.updatedAt)
assertNull(sourceAfter.continuedFromDocumentationId)
assertEquals(source.blocks, sourceAfter.blocks)
assertEquals(source.photos, sourceAfter.photos)
assertEquals(source.signatures, sourceAfter.signatures)
}
@Test
fun aenderungen_an_der_kopie_veraendern_die_quelle_nicht_und_umgekehrt() = runDbTest {
val source = prepareSourceWithInput()
val copy = repo.continueDocumentation(source.id)
// Kopie: den in der Quelle gesetzten Haken entfernen und Freitext ändern.
val copyFirstBlock = copy.blocks.first()
repo.setChecked(copyFirstBlock.firstPlainItem().checkItemStateId, false)
repo.updateFreeText(copyFirstBlock.items.last().checkItemStateId, "Neuer Text")
val sourceAfter = repo.getFullDocumentation(source.id)!!
assertTrue(sourceAfter.blocks.first().firstPlainItem().checked)
assertEquals("Rötung am Wundrand", sourceAfter.blocks.first().items.last().freeTextValue)
// Quelle: weiteren Haken in einem anderen Block setzen.
val otherSourceBlock = source.blocks[1]
repo.setChecked(otherSourceBlock.firstPlainItem().checkItemStateId, true)
val copyAfter = repo.getFullDocumentation(copy.id)!!
assertFalse(copyAfter.blocks.single { it.definitionId == otherSourceBlock.definitionId }.isEdited)
assertEquals("Neuer Text", copyAfter.blocks.first().items.last().freeTextValue)
}
// --- Patienten-Snapshot --------------------------------------------------------------
@Test
fun fortsetzen_bildet_den_patienten_snapshot_frisch_aus_dem_aktuellen_patienten() = runDbTest {
val source = prepareSourceWithInput()
val patientDao = env.db.patientDao()
patientDao.update(
patientDao.getById("patient-1")!!.copy(
lastName = "Musterfrau", street = "Neue Straße 7", postalCode = "99999", city = "Neustadt",
)
)
val copy = repo.continueDocumentation(source.id)
assertEquals("Musterfrau", copy.patientSnapshot.lastName)
assertEquals("Neue Straße 7", copy.patientSnapshot.street)
assertEquals("99999", copy.patientSnapshot.postalCode)
assertEquals("Neustadt", copy.patientSnapshot.city)
assertEquals(LocalDate.of(1980, 3, 14), copy.patientSnapshot.birthDate)
// Die Quelle behält ihren damaligen Stand.
val sourceSnapshot = repo.getFullDocumentation(source.id)!!.patientSnapshot
assertEquals("Mustermann", sourceSnapshot.lastName)
assertEquals("Musterstraße 1", sourceSnapshot.street)
assertEquals("Musterstadt", sourceSnapshot.city)
}
// --- Historische Snapshots / Katalogversion ------------------------------------------
@Test
fun fortsetzen_bewahrt_historische_titel_labels_und_katalogversion() = runDbTest {
env.insertPatient()
val now = 5_000L
val historicDocId = "historic-doc"
env.db.documentationDao().insert(
DocumentationEntity(
id = historicDocId, patientId = "patient-1", status = DocumentationStatus.COMPLETED,
createdAt = now, updatedAt = now, continuedFromDocumentationId = null, pdfLastGeneratedAt = null,
catalogVersion = 1,
patientFirstNameSnapshot = "Max", patientLastNameSnapshot = "Mustermann",
patientBirthDateSnapshot = "1980-03-14", patientStreetSnapshot = "Musterstraße 1",
patientPostalCodeSnapshot = "12345", patientCitySnapshot = "Musterstadt",
)
)
env.db.blockStateDao().insertAll(
listOf(BlockStateEntity("hist-block", historicDocId, "gibt_es_nicht_mehr", "Alter Titel", 1, now))
)
env.db.checkItemStateDao().insertAll(
listOf(
CheckItemStateEntity("hist-item", "hist-block", "alter_eintrag", "Alter Eintrag", 1, checked = true),
)
)
assertNotEquals("Voraussetzung: aktuelle Katalogversion weicht ab", 1, DocumentationCatalog.CATALOG_VERSION)
val copy = repo.continueDocumentation(historicDocId)
assertEquals(1, copy.catalogVersion)
val block = copy.blocks.single()
assertEquals("gibt_es_nicht_mehr", block.definitionId)
assertEquals("Alter Titel", block.title)
assertEquals("Alter Eintrag", block.items.single().label)
assertTrue(block.items.single().checked)
}
// --- Unterschriften ------------------------------------------------------------------
@Test
fun fortsetzen_kopiert_keine_unterschriften() = runDbTest {
val source = prepareSourceWithInput()
repo.saveSignature(source.id, "Anna Schmidt", signatureBitmap())
val copy = repo.continueDocumentation(source.id)
assertTrue(copy.signatures.isEmpty())
assertEquals(1, repo.getFullDocumentation(source.id)!!.signatures.size)
assertEquals(1, env.signatureStorage.saved.size) // keine zusätzliche Datei angelegt
}
// --- Fotos ---------------------------------------------------------------------------
@Test
fun fortsetzen_kopiert_fotos_standardmaessig_als_eigenstaendige_dateien() = runDbTest {
val source = prepareSourceWithInput()
val p1 = repo.addPhoto(source.id, Uri.parse("content://test/1"))
val p2 = repo.addPhoto(source.id, Uri.parse("content://test/2"))
val copy = repo.continueDocumentation(source.id)
assertEquals(listOf(0, 1), copy.photos.map { it.orderIndex })
assertTrue(copy.photos.none { it.id == p1.id || it.id == p2.id })
assertTrue("Dateien müssen physisch eigenständig sein", copy.photos.none { it.fileName == p1.fileName || it.fileName == p2.fileName })
assertTrue(copy.photos.all { it.documentationId == copy.id })
assertEquals(
setOf(
DuplicateCall(source.id, p1.fileName, copy.id),
DuplicateCall(source.id, p2.fileName, copy.id),
),
env.photoStorage.duplicated.toSet(),
)
assertEquals(2, env.photoStorage.duplicated.size)
// Quelle behält ihre Fotos.
assertEquals(listOf(p1.fileName, p2.fileName), repo.getFullDocumentation(source.id)!!.photos.map { it.fileName })
}
@Test
fun fortsetzen_mit_copyPhotos_false_uebernimmt_keine_fotos() = runDbTest {
val source = prepareSourceWithInput()
repo.addPhoto(source.id, Uri.parse("content://test/1"))
val copy = repo.continueDocumentation(source.id, copyPhotos = false)
assertTrue(copy.photos.isEmpty())
assertTrue(env.photoStorage.duplicated.isEmpty())
}
@Test
fun foto_loeschen_in_der_kopie_betrifft_die_quelle_nicht() = runDbTest {
val source = prepareSourceWithInput()
repo.addPhoto(source.id, Uri.parse("content://test/1"))
val copy = repo.continueDocumentation(source.id)
repo.removePhoto(copy.photos.single().id)
assertTrue(repo.getFullDocumentation(copy.id)!!.photos.isEmpty())
assertEquals(1, repo.getFullDocumentation(source.id)!!.photos.size)
}
// --- Fehlerfälle / Atomarität --------------------------------------------------------
@Test
fun fortsetzen_einer_unbekannten_dokumentation_wirft_und_legt_nichts_an() = runDbTest {
env.insertPatient()
val error = expectThrows<IllegalStateException> { repo.continueDocumentation("unbekannt") }
assertTrue(error.message.orEmpty().contains("unbekannt"))
assertTrue(env.db.documentationDao().observeAll().first().isEmpty())
}
@Test
fun fortsetzen_ist_atomar_ein_fehler_beim_foto_kopieren_hinterlaesst_keine_halbe_dokumentation() = runDbTest {
val source = prepareSourceWithInput()
repo.addPhoto(source.id, Uri.parse("content://test/1"))
repo.addPhoto(source.id, Uri.parse("content://test/2"))
env.photoStorage.failOnDuplicateCall = 2 // zweites Foto schlägt fehl
expectThrows<IOException> { repo.continueDocumentation(source.id) }
val all = env.db.documentationDao().observeAll().first()
assertEquals(listOf(source.id), all.map { it.id })
assertEquals(1, env.db.documentationDao().countForPatient("patient-1"))
}
@Test
fun loeschen_der_quelle_laesst_die_fortgesetzte_dokumentation_bestehen() = runDbTest {
val source = prepareSourceWithInput()
repo.addPhoto(source.id, Uri.parse("content://test/1"))
val copy = repo.continueDocumentation(source.id)
repo.delete(source.id)
val copyAfter = repo.getFullDocumentation(copy.id)!!
assertNull("Verweis auf die gelöschte Quelle wird auf null gesetzt", copyAfter.continuedFromDocumentationId)
assertEquals(copy.blocks.size, copyAfter.blocks.size)
assertEquals(1, copyAfter.photos.size)
assertEquals(listOf(source.id), env.photoStorage.deletedAll) // nur der Ordner der Quelle
}
@Test
fun mehrfaches_fortsetzen_bildet_eine_kette() = runDbTest {
val first = prepareSourceWithInput()
val second = repo.continueDocumentation(first.id)
val third = repo.continueDocumentation(second.id)
assertEquals(first.id, second.continuedFromDocumentationId)
assertEquals(second.id, third.continuedFromDocumentationId)
assertEquals(1, third.blocks.count { it.isEdited })
assertEquals(3, env.db.documentationDao().countForPatient("patient-1"))
}
// --- continueLatestForPatient --------------------------------------------------------
@Test
fun continueLatestForPatient_liefert_null_wenn_der_patient_noch_keine_dokumentation_hat() = runDbTest {
env.insertPatient()
assertNull(repo.continueLatestForPatient("patient-1"))
assertTrue(env.db.documentationDao().observeAll().first().isEmpty())
}
@Test
fun continueLatestForPatient_setzt_die_zuletzt_erstellte_dokumentation_des_patienten_fort() = runDbTest {
env.insertPatient()
env.insertPatient(id = "patient-2", firstName = "Erika", lastName = "Musterfrau")
val older = repo.createEmpty("patient-1")
val newer = repo.createEmpty("patient-1")
val otherPatientsDoc = repo.createEmpty("patient-2")
env.setCreatedAt(older.id, 1_000L)
env.setCreatedAt(newer.id, 2_000L)
env.setCreatedAt(otherPatientsDoc.id, 3_000L) // neuer, aber anderer Patient
repo.setChecked(newer.blocks.first().firstPlainItem().checkItemStateId, true)
val copy = repo.continueLatestForPatient("patient-1")!!
assertEquals(newer.id, copy.continuedFromDocumentationId)
assertEquals("patient-1", copy.patientId)
assertEquals(1, copy.blocks.count { it.isEdited })
}
}
@@ -0,0 +1,274 @@
package de.medapp.wunddoku.data.repository
import android.database.sqlite.SQLiteFullException
import android.net.Uri
import androidx.test.ext.junit.runners.AndroidJUnit4
import de.medapp.wunddoku.pdf.PdfExportError
import de.medapp.wunddoku.pdf.PdfExportResult
import de.medapp.wunddoku.pdf.PdfToImagesResult
import de.medapp.wunddoku.testutil.FailingPhotoDao
import de.medapp.wunddoku.testutil.FailingPhotoStorage
import de.medapp.wunddoku.testutil.FailingSignatureDao
import de.medapp.wunddoku.testutil.FailingSignatureStorage
import de.medapp.wunddoku.testutil.TestEnvironment
import de.medapp.wunddoku.testutil.ThrowingPdfExporter
import de.medapp.wunddoku.testutil.ThrowingPdfToImagesConverter
import de.medapp.wunddoku.testutil.buildRepository
import de.medapp.wunddoku.testutil.expectThrowsSuspend
import de.medapp.wunddoku.testutil.runDbTest
import de.medapp.wunddoku.testutil.signatureBitmap
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.flow.first
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import java.io.File
import java.io.IOException
/**
* Fehler- und Datenverlust-Tests für [DocumentationRepository] gegen eine echte In-Memory-
* Room-Datenbank: Speichermangel (Datenbank/Dateisystem voll, Arbeitsspeicher), halbe
* Ergebnisse, verwaiste Dateien und das "wirft-nie"-Verhalten des PDF-Exports.
*/
@RunWith(AndroidJUnit4::class)
class DocumentationRepositoryErrorHandlingTest {
private lateinit var env: TestEnvironment
private val someUri: Uri = Uri.parse("content://test/bild/1")
@Before
fun setUp() {
env = TestEnvironment()
}
@After
fun tearDown() {
env.close()
}
private suspend fun newDocumentationId(): String {
env.insertPatient()
return env.documentationRepository.createEmpty("patient-1").id
}
// --- Foto-Import ---------------------------------------------------------------------
@Test
fun addPhoto_bei_fehlgeschlagenem_dateiimport_legt_keinen_db_eintrag_an() = runDbTest {
val documentationId = newDocumentationId()
val storage = FailingPhotoStorage().apply { importFailure = { IOException("Bild defekt (Test)") } }
val repo = env.buildRepository(photoStorage = storage)
expectThrowsSuspend<IOException> { repo.addPhoto(documentationId, someUri) }
assertTrue(env.db.photoDao().getForDocumentation(documentationId).isEmpty())
}
@Test
fun addPhoto_bei_speichermangel_im_arbeitsspeicher_legt_keinen_db_eintrag_an() = runDbTest {
val documentationId = newDocumentationId()
val storage = FailingPhotoStorage().apply { importFailure = { OutOfMemoryError("Test") } }
val repo = env.buildRepository(photoStorage = storage)
expectThrowsSuspend<OutOfMemoryError> { repo.addPhoto(documentationId, someUri) }
assertTrue(env.db.photoDao().getForDocumentation(documentationId).isEmpty())
}
@Test
fun addPhoto_bei_voller_datenbank_entfernt_die_bereits_importierte_datei() = runDbTest {
val documentationId = newDocumentationId()
val storage = FailingPhotoStorage()
val photoDao = FailingPhotoDao(env.db.photoDao()).apply {
insertFailure = SQLiteFullException("database or disk is full")
}
val repo = env.buildRepository(photoStorage = storage, photoDao = photoDao)
expectThrowsSuspend<SQLiteFullException> { repo.addPhoto(documentationId, someUri) }
assertEquals(1, storage.delegate.imported.size)
assertEquals("verwaiste Datei muss entfernt werden", 1, storage.delegate.deleted.size)
assertEquals(documentationId, storage.delegate.deleted.single().first)
assertTrue(env.db.photoDao().getForDocumentation(documentationId).isEmpty())
}
@Test
fun addPhoto_funktioniert_nach_einem_fehlgeschlagenen_versuch_wieder() = runDbTest {
val documentationId = newDocumentationId()
val storage = FailingPhotoStorage().apply { importFailure = { call -> if (call == 1) IOException("einmalig") else null } }
val repo = env.buildRepository(photoStorage = storage)
expectThrowsSuspend<IOException> { repo.addPhoto(documentationId, someUri) }
repo.addPhoto(documentationId, someUri)
assertEquals(1, env.db.photoDao().getForDocumentation(documentationId).size)
}
// --- Signaturen ----------------------------------------------------------------------
@Test
fun saveSignature_bei_dateifehler_legt_weder_db_eintrag_noch_registereintrag_an() = runDbTest {
val documentationId = newDocumentationId()
val storage = FailingSignatureStorage().apply { saveFailure = IOException("Speicher voll (Test)") }
val repo = env.buildRepository(signatureStorage = storage)
expectThrowsSuspend<IOException> { repo.saveSignature(documentationId, "Pfleger Paul", signatureBitmap()) }
assertTrue(env.db.signatureDao().getForDocumentation(documentationId).isEmpty())
assertNull(env.woundCarerRepository.getMostRecentlyUsed())
}
@Test
fun saveSignature_bei_voller_datenbank_entfernt_die_gespeicherte_datei() = runDbTest {
val documentationId = newDocumentationId()
val storage = FailingSignatureStorage()
val signatureDao = FailingSignatureDao(env.db.signatureDao()).apply {
insertFailure = SQLiteFullException("database or disk is full")
}
val repo = env.buildRepository(signatureStorage = storage, signatureDao = signatureDao)
expectThrowsSuspend<SQLiteFullException> { repo.saveSignature(documentationId, "Pfleger Paul", signatureBitmap()) }
assertEquals(1, storage.delegate.saved.size)
assertEquals("verwaiste PNG-Datei muss entfernt werden", 1, storage.delegate.deleted.size)
assertTrue(env.db.signatureDao().getForDocumentation(documentationId).isEmpty())
}
@Test
fun saveSignature_bleibt_erfolgreich_wenn_nur_das_wundversorger_register_scheitert() = runDbTest {
val documentationId = newDocumentationId()
val brokenRegisterStorage = FailingSignatureStorage().apply { saveFailure = IOException("Register-Speicher voll") }
val brokenRegister = WoundCarerRepository(env.db.woundCarerDao(), brokenRegisterStorage)
val repo = env.buildRepository(woundCarerRepository = brokenRegister)
val signature = repo.saveSignature(documentationId, "Pfleger Paul", signatureBitmap())
assertEquals("Pfleger Paul", signature.signerName)
assertEquals(
"Signatur der Dokumentation muss gespeichert sein",
1,
env.db.signatureDao().getForDocumentation(documentationId).size,
)
}
// --- Fortsetzen ----------------------------------------------------------------------
@Test
fun fortsetzen_raeumt_bereits_kopierte_fotos_der_gescheiterten_kopie_auf() = runDbTest {
val sourceId = newDocumentationId()
env.documentationRepository.addPhoto(sourceId, someUri)
env.documentationRepository.addPhoto(sourceId, someUri)
env.photoStorage.failOnDuplicateCall = 2
expectThrowsSuspend<IOException> { env.documentationRepository.continueDocumentation(sourceId) }
val attemptedTarget = env.photoStorage.duplicated.first().targetDocumentationId
assertTrue(
"Ordner der nie entstandenen Dokumentation muss gelöscht werden",
attemptedTarget in env.photoStorage.deletedAll,
)
assertEquals("keine halbe Dokumentation", 1, env.documentationRepository.observeAll().first().size)
assertTrue(env.db.photoDao().getForDocumentation(attemptedTarget).isEmpty())
}
@Test
fun fortsetzen_laesst_quelle_und_deren_fotos_bei_fehler_unberuehrt() = runDbTest {
val sourceId = newDocumentationId()
env.documentationRepository.addPhoto(sourceId, someUri)
env.photoStorage.failOnDuplicateCall = 1
expectThrowsSuspend<IOException> { env.documentationRepository.continueDocumentation(sourceId) }
assertEquals(1, env.db.photoDao().getForDocumentation(sourceId).size)
assertFalse(sourceId in env.photoStorage.deletedAll)
}
// --- PDF-Export wirft nie ------------------------------------------------------------
@Test
fun exportPdf_uebersetzt_out_of_memory_in_ein_fehlerergebnis() = runDbTest {
val documentationId = newDocumentationId()
val repo = env.buildRepository(pdfExporter = ThrowingPdfExporter(OutOfMemoryError("Test")))
val result = repo.exportPdf(documentationId)
assertEquals(PdfExportResult.Failure(PdfExportError.OutOfMemory), result)
}
@Test
fun exportPdf_uebersetzt_speicher_voll_in_ein_fehlerergebnis() = runDbTest {
val documentationId = newDocumentationId()
val repo = env.buildRepository(
pdfExporter = ThrowingPdfExporter(IOException("write failed: ENOSPC (No space left on device)")),
)
val result = repo.exportPdf(documentationId)
assertEquals(PdfExportResult.Failure(PdfExportError.InsufficientStorage), result)
}
@Test
fun exportPdf_gibt_bei_unerwarteten_fehlern_keine_technischen_details_weiter() = runDbTest {
val documentationId = newDocumentationId()
val repo = env.buildRepository(
pdfExporter = ThrowingPdfExporter(RuntimeException("/files/pdf/2026_Max_Mustermann.pdf kaputt")),
)
val result = repo.exportPdf(documentationId)
assertTrue(result is PdfExportResult.Failure)
val error = (result as PdfExportResult.Failure).error
assertTrue(error is PdfExportError.Unexpected)
assertFalse((error as PdfExportError.Unexpected).message.contains("Mustermann"))
}
@Test
fun exportPdf_reicht_coroutine_abbruch_weiter() = runDbTest {
val documentationId = newDocumentationId()
val repo = env.buildRepository(pdfExporter = ThrowingPdfExporter(CancellationException("abgebrochen")))
expectThrowsSuspend<CancellationException> { repo.exportPdf(documentationId) }
}
@Test
fun exportPdf_bei_geschlossener_datenbank_liefert_fehler_statt_exception() = runDbTest {
val documentationId = newDocumentationId()
env.db.close()
val result = env.documentationRepository.exportPdf(documentationId)
assertTrue("erwartet Failure, war $result", result is PdfExportResult.Failure)
}
@Test
fun convertPdfToImages_uebersetzt_exceptions_in_fehlerergebnisse() = runDbTest {
val file = File("/fake/x.pdf")
val oom = env.buildRepository(pdfToImagesConverter = ThrowingPdfToImagesConverter(OutOfMemoryError()))
.convertPdfToImages("doc", file)
val full = env.buildRepository(
pdfToImagesConverter = ThrowingPdfToImagesConverter(IOException("No space left on device")),
).convertPdfToImages("doc", file)
val other = env.buildRepository(pdfToImagesConverter = ThrowingPdfToImagesConverter(IllegalStateException("x")))
.convertPdfToImages("doc", file)
assertEquals(PdfToImagesResult.Failure.OutOfMemory, oom)
assertEquals(PdfToImagesResult.Failure.InsufficientStorage, full)
assertTrue(other is PdfToImagesResult.Failure.Unexpected)
}
// --- Sonstiges -----------------------------------------------------------------------
@Test
fun setChecked_mit_unbekannter_id_wirft_nicht() = runDbTest {
newDocumentationId()
env.documentationRepository.setChecked("gibt-es-nicht", true)
env.documentationRepository.updateFreeText("gibt-es-nicht", "Text")
}
}
@@ -0,0 +1,545 @@
package de.medapp.wunddoku.data.repository
import android.net.Uri
import androidx.test.ext.junit.runners.AndroidJUnit4
import de.medapp.wunddoku.domain.catalog.DocumentationCatalog
import de.medapp.wunddoku.domain.model.DocumentationStatus
import de.medapp.wunddoku.domain.model.InputSpecCodec
import de.medapp.wunddoku.pdf.PdfExportError
import de.medapp.wunddoku.pdf.PdfExportResult
import de.medapp.wunddoku.pdf.PdfToImagesResult
import de.medapp.wunddoku.testutil.TestEnvironment
import de.medapp.wunddoku.testutil.blankBitmap
import de.medapp.wunddoku.testutil.expectThrows
import de.medapp.wunddoku.testutil.firstPlainItem
import de.medapp.wunddoku.testutil.runDbTest
import de.medapp.wunddoku.testutil.signatureBitmap
import kotlinx.coroutines.flow.first
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import java.io.File
import java.time.LocalDate
import java.time.ZoneId
/**
* Repository-Tests für [DocumentationRepository] gegen eine echte In-Memory-Room-Datenbank.
* Dateispeicher und PDF-Erzeugung sind durch Fakes ersetzt (s. testutil/Fakes.kt).
* "Fortsetzen" wird separat in [ContinueDocumentationTest] getestet.
*/
@RunWith(AndroidJUnit4::class)
class DocumentationRepositoryTest {
private lateinit var env: TestEnvironment
private val repo get() = env.documentationRepository
@Before
fun setUp() {
env = TestEnvironment()
}
@After
fun tearDown() {
env.close()
}
// --- createEmpty ---------------------------------------------------------------------
@Test
fun createEmpty_legt_neue_dokumentation_im_status_in_bearbeitung_an() = runDbTest {
env.insertPatient()
val doc = repo.createEmpty("patient-1")
assertEquals("patient-1", doc.patientId)
assertEquals(DocumentationStatus.IN_PROGRESS, doc.status)
assertNull(doc.continuedFromDocumentationId)
assertEquals(DocumentationCatalog.CATALOG_VERSION, doc.catalogVersion)
assertEquals(doc.createdAt, doc.updatedAt)
assertTrue(doc.photos.isEmpty())
assertTrue(doc.signatures.isEmpty())
assertEquals(doc.id, repo.getFullDocumentation(doc.id)?.id)
}
@Test
fun createEmpty_uebernimmt_den_patienten_snapshot() = runDbTest {
env.insertPatient(
firstName = "Erika", lastName = "Musterfrau", birthDate = "1975-12-01",
street = "Hauptstraße 5", postalCode = "54321", city = "Beispielstadt",
)
val snapshot = repo.createEmpty("patient-1").patientSnapshot
assertEquals("Erika", snapshot.firstName)
assertEquals("Musterfrau", snapshot.lastName)
assertEquals(LocalDate.of(1975, 12, 1), snapshot.birthDate)
assertEquals("Hauptstraße 5", snapshot.street)
assertEquals("54321", snapshot.postalCode)
assertEquals("Beispielstadt", snapshot.city)
}
@Test
fun createEmpty_erzeugt_pro_katalogblock_einen_block_mit_titel_und_reihenfolge_snapshot() = runDbTest {
env.insertPatient()
val doc = repo.createEmpty("patient-1")
assertEquals(DocumentationCatalog.blocks.size, doc.blocks.size)
for (def in DocumentationCatalog.blocks) {
val block = doc.blocks.single { it.definitionId == def.id }
assertEquals(def.title, block.title)
assertEquals(def.order, block.order)
}
}
@Test
fun createEmpty_haengt_pro_block_die_generische_freitext_option_als_letztes_element_an() = runDbTest {
env.insertPatient()
val doc = repo.createEmpty("patient-1")
for (def in DocumentationCatalog.blocks) {
val block = doc.blocks.single { it.definitionId == def.id }
assertEquals(def.items.size + 1, block.items.size)
val generic = block.items.last()
assertEquals(def.freeTextDefinitionId, generic.definitionId)
assertEquals(DocumentationCatalog.FREE_TEXT_LABEL, generic.label)
assertEquals((def.items.maxOfOrNull { it.order } ?: 0) + 1, generic.order)
assertTrue(generic.isFreeText)
assertFalse(generic.isStaticHint)
assertFalse(generic.checked)
assertEquals("", generic.freeTextValue)
}
}
@Test
fun createEmpty_uebernimmt_katalogeintraege_1_zu_1_und_hinweistexte_starten_angehakt() = runDbTest {
env.insertPatient()
val doc = repo.createEmpty("patient-1")
for (def in DocumentationCatalog.blocks) {
val block = doc.blocks.single { it.definitionId == def.id }
for (itemDef in def.items) {
val state = block.items.single { it.definitionId == itemDef.id }
assertEquals(itemDef.label, state.label)
assertEquals(itemDef.order, state.order)
assertEquals(itemDef.isFreeText, state.isFreeText)
assertEquals(itemDef.isStaticHint, state.isStaticHint)
// Nur Hinweistexte starten angehakt, alles andere unangehakt/leer.
assertEquals(itemDef.isStaticHint, state.checked)
assertEquals("", state.freeTextValue)
val expectedSpec = itemDef.inputSpec?.let(InputSpecCodec::encode) ?: ""
assertEquals(expectedSpec, state.inputSpec)
}
}
}
@Test
fun createEmpty_ergibt_keinen_bearbeiteten_block_auch_nicht_durch_hinweistexte() = runDbTest {
env.insertPatient()
val doc = repo.createEmpty("patient-1")
assertTrue(doc.blocks.none { it.isEdited })
}
@Test
fun createEmpty_vergibt_fuer_jede_dokumentation_neue_ids() = runDbTest {
env.insertPatient()
val first = repo.createEmpty("patient-1")
val second = repo.createEmpty("patient-1")
assertTrue(first.id != second.id)
val allBlockIds = (first.blocks + second.blocks).map { it.blockStateId }
assertEquals(allBlockIds.size, allBlockIds.toSet().size)
assertEquals(2, env.db.documentationDao().countForPatient("patient-1"))
}
@Test
fun createEmpty_mit_unbekanntem_patienten_wirft_und_legt_nichts_an() = runDbTest {
val error = expectThrows<IllegalStateException> { repo.createEmpty("gibt-es-nicht") }
assertTrue(error.message.orEmpty().contains("gibt-es-nicht"))
assertTrue(env.db.documentationDao().observeAll().first().isEmpty())
}
// --- Lesen / Liste / Suche -----------------------------------------------------------
@Test
fun getFullDocumentation_liefert_null_fuer_unbekannte_id() = runDbTest {
assertNull(repo.getFullDocumentation("unbekannt"))
}
@Test
fun observeAll_liefert_neueste_dokumentation_zuerst_mit_snapshot_namen() = runDbTest {
env.insertPatient()
val older = repo.createEmpty("patient-1")
val newer = repo.createEmpty("patient-1")
env.setCreatedAt(older.id, 1_000L)
env.setCreatedAt(newer.id, 2_000L)
val list = repo.observeAll().first()
assertEquals(listOf(newer.id, older.id), list.map { it.id })
assertEquals("Max Mustermann", list.first().patientFullName)
assertEquals(DocumentationStatus.IN_PROGRESS, list.first().status)
}
@Test
fun search_sucht_gegen_den_aktuellen_patientennamen_zeigt_aber_den_snapshot_namen() = runDbTest {
env.insertPatient()
val doc = repo.createEmpty("patient-1")
val patientDao = env.db.patientDao()
patientDao.update(patientDao.getById("patient-1")!!.copy(lastName = "Musterfrau"))
val found = repo.search("Musterfrau").first()
assertEquals(listOf(doc.id), found.map { it.id })
assertEquals("Max Mustermann", found.single().patientFullName)
assertTrue(repo.search("Mustermann").first().isEmpty())
}
@Test
fun search_findet_auch_ueber_vornamen_und_geburtsdatum() = runDbTest {
env.insertPatient()
val doc = repo.createEmpty("patient-1")
assertEquals(listOf(doc.id), repo.search("Ma").first().map { it.id })
assertEquals(listOf(doc.id), repo.search("1980-03").first().map { it.id })
assertTrue(repo.search("Zzz").first().isEmpty())
}
// --- Autosave / Blockstatus ----------------------------------------------------------
@Test
fun setChecked_persistiert_und_macht_den_block_bearbeitet() = runDbTest {
env.insertPatient()
val doc = repo.createEmpty("patient-1")
val block = doc.blocks.first()
val item = block.firstPlainItem()
repo.setChecked(item.checkItemStateId, true)
val reloaded = repo.getFullDocumentation(doc.id)!!
val reloadedBlock = reloaded.blocks.single { it.blockStateId == block.blockStateId }
assertTrue(reloadedBlock.items.single { it.checkItemStateId == item.checkItemStateId }.checked)
assertTrue(reloadedBlock.isEdited)
assertEquals(1, reloaded.blocks.count { it.isEdited })
}
@Test
fun setChecked_false_nimmt_den_bearbeitet_status_wieder_zurueck() = runDbTest {
env.insertPatient()
val doc = repo.createEmpty("patient-1")
val block = doc.blocks.first()
val item = block.firstPlainItem()
repo.setChecked(item.checkItemStateId, true)
repo.setChecked(item.checkItemStateId, false)
val reloaded = repo.getFullDocumentation(doc.id)!!
assertTrue(reloaded.blocks.none { it.isEdited })
}
@Test
fun freitext_wird_gespeichert_und_zaehlt_erst_mit_haken_als_bearbeitung() = runDbTest {
env.insertPatient()
val doc = repo.createEmpty("patient-1")
val block = doc.blocks.first()
val freeText = block.items.last()
val text = "Größe 3,5 × 2 cm\nzweite Zeile – Rötung"
repo.updateFreeText(freeText.checkItemStateId, text)
val textOnly = repo.getFullDocumentation(doc.id)!!
assertEquals(text, textOnly.blocks.single { it.blockStateId == block.blockStateId }.items.last().freeTextValue)
assertTrue("Freitext ohne Haken darf den Block nicht bearbeitet machen", textOnly.blocks.none { it.isEdited })
repo.setChecked(freeText.checkItemStateId, true)
val checked = repo.getFullDocumentation(doc.id)!!
val reloadedBlock = checked.blocks.single { it.blockStateId == block.blockStateId }
assertTrue(reloadedBlock.items.last().checked)
assertEquals(text, reloadedBlock.items.last().freeTextValue)
assertTrue(reloadedBlock.isEdited)
}
@Test
fun aenderungen_in_einer_dokumentation_beruehren_eine_andere_nicht() = runDbTest {
env.insertPatient()
val first = repo.createEmpty("patient-1")
val second = repo.createEmpty("patient-1")
repo.setChecked(first.blocks.first().firstPlainItem().checkItemStateId, true)
assertTrue(repo.getFullDocumentation(second.id)!!.blocks.none { it.isEdited })
}
// --- Fotos ---------------------------------------------------------------------------
@Test
fun addPhoto_vergibt_fortlaufenden_orderIndex_und_speichert_den_storage_dateinamen() = runDbTest {
env.insertPatient()
val doc = repo.createEmpty("patient-1")
val uri1 = Uri.parse("content://test/1")
val uri2 = Uri.parse("content://test/2")
val p1 = repo.addPhoto(doc.id, uri1)
val p2 = repo.addPhoto(doc.id, uri2)
assertEquals(0, p1.orderIndex)
assertEquals(1, p2.orderIndex)
assertEquals(listOf(doc.id to uri1, doc.id to uri2), env.photoStorage.imported)
val photos = repo.getFullDocumentation(doc.id)!!.photos
assertEquals(listOf(p1.fileName, p2.fileName), photos.map { it.fileName })
assertEquals(listOf(0, 1), photos.map { it.orderIndex })
}
@Test
fun removePhoto_loescht_db_zeile_und_datei_und_vergibt_orderIndex_nicht_neu() = runDbTest {
env.insertPatient()
val doc = repo.createEmpty("patient-1")
val p1 = repo.addPhoto(doc.id, Uri.parse("content://test/1"))
val p2 = repo.addPhoto(doc.id, Uri.parse("content://test/2"))
repo.removePhoto(p1.id)
assertEquals(listOf(p2.id), repo.getFullDocumentation(doc.id)!!.photos.map { it.id })
assertEquals(listOf(doc.id to p1.fileName), env.photoStorage.deleted)
val p3 = repo.addPhoto(doc.id, Uri.parse("content://test/3"))
assertEquals(2, p3.orderIndex)
}
@Test
fun removePhoto_mit_unbekannter_id_tut_nichts() = runDbTest {
env.insertPatient()
repo.createEmpty("patient-1")
repo.removePhoto("unbekannt")
assertTrue(env.photoStorage.deleted.isEmpty())
}
@Test
fun removePhoto_entfernt_die_db_zeile_auch_wenn_die_datei_nicht_geloescht_werden_kann() = runDbTest {
env.insertPatient()
val doc = repo.createEmpty("patient-1")
val photo = repo.addPhoto(doc.id, Uri.parse("content://test/1"))
env.photoStorage.failOnDelete = true
repo.removePhoto(photo.id)
assertTrue(repo.getFullDocumentation(doc.id)!!.photos.isEmpty())
}
// --- Signaturen ----------------------------------------------------------------------
@Test
fun saveSignature_trimmt_den_namen_und_vergibt_fortlaufenden_orderIndex() = runDbTest {
env.insertPatient()
val doc = repo.createEmpty("patient-1")
val first = repo.saveSignature(doc.id, " Anna Schmidt ", signatureBitmap())
val second = repo.saveSignature(doc.id, "Bernd Beispiel", signatureBitmap())
assertEquals("Anna Schmidt", first.signerName)
assertEquals(0, first.orderIndex)
assertEquals(1, second.orderIndex)
val signatures = repo.getFullDocumentation(doc.id)!!.signatures
assertEquals(listOf("Anna Schmidt", "Bernd Beispiel"), signatures.map { it.signerName })
assertEquals(listOf(first.fileName, second.fileName), signatures.map { it.fileName })
assertEquals(listOf(doc.id to first.fileName, doc.id to second.fileName), env.signatureStorage.saved)
}
@Test
fun saveSignature_aktualisiert_das_wundversorger_register() = runDbTest {
env.insertPatient()
val doc = repo.createEmpty("patient-1")
repo.saveSignature(doc.id, "Anna Schmidt", signatureBitmap())
assertEquals("Anna Schmidt", env.woundCarerRepository.getMostRecentlyUsed()?.name)
assertEquals(1, env.woundCarerSignatureStorage.saved.size)
}
@Test
fun saveSignature_mit_leerem_namen_wirft_und_hinterlaesst_keine_datei() = runDbTest {
env.insertPatient()
val doc = repo.createEmpty("patient-1")
expectThrows<IllegalArgumentException> { repo.saveSignature(doc.id, " ", signatureBitmap()) }
assertTrue(env.signatureStorage.saved.isEmpty())
assertTrue(env.db.signatureDao().getForDocumentation(doc.id).isEmpty())
assertNull(env.woundCarerRepository.getMostRecentlyUsed())
}
@Test
fun saveSignature_mit_leerer_zeichnung_wirft_und_hinterlaesst_keine_datei() = runDbTest {
env.insertPatient()
val doc = repo.createEmpty("patient-1")
expectThrows<IllegalArgumentException> { repo.saveSignature(doc.id, "Anna Schmidt", blankBitmap()) }
assertTrue(env.signatureStorage.saved.isEmpty())
assertTrue(env.db.signatureDao().getForDocumentation(doc.id).isEmpty())
assertNull(env.woundCarerRepository.getMostRecentlyUsed())
}
@Test
fun removeSignature_loescht_db_zeile_und_datei() = runDbTest {
env.insertPatient()
val doc = repo.createEmpty("patient-1")
val signature = repo.saveSignature(doc.id, "Anna Schmidt", signatureBitmap())
repo.removeSignature(signature.id)
assertTrue(repo.getFullDocumentation(doc.id)!!.signatures.isEmpty())
assertEquals(listOf(doc.id to signature.fileName), env.signatureStorage.deleted)
}
@Test
fun removeSignature_mit_unbekannter_id_tut_nichts_und_toleriert_storage_fehler() = runDbTest {
env.insertPatient()
val doc = repo.createEmpty("patient-1")
repo.removeSignature("unbekannt")
assertTrue(env.signatureStorage.deleted.isEmpty())
val signature = repo.saveSignature(doc.id, "Anna Schmidt", signatureBitmap())
env.signatureStorage.failOnDelete = true
repo.removeSignature(signature.id)
assertTrue(repo.getFullDocumentation(doc.id)!!.signatures.isEmpty())
}
// --- Löschen -------------------------------------------------------------------------
@Test
fun delete_entfernt_dokumentation_samt_bloecken_fotos_und_signaturen_sowie_dateiordner() = runDbTest {
env.insertPatient()
val doc = repo.createEmpty("patient-1")
repo.addPhoto(doc.id, Uri.parse("content://test/1"))
repo.saveSignature(doc.id, "Anna Schmidt", signatureBitmap())
val blockStateIds = doc.blocks.map { it.blockStateId }
repo.delete(doc.id)
assertNull(repo.getFullDocumentation(doc.id))
assertTrue(env.db.blockStateDao().getForDocumentation(doc.id).isEmpty())
assertTrue(env.db.photoDao().getForDocumentation(doc.id).isEmpty())
assertTrue(env.db.signatureDao().getForDocumentation(doc.id).isEmpty())
for (blockStateId in blockStateIds) {
assertTrue(env.db.checkItemStateDao().getForBlockState(blockStateId).isEmpty())
}
assertEquals(listOf(doc.id), env.photoStorage.deletedAll)
assertEquals(listOf(doc.id), env.signatureStorage.deletedAll)
assertNotNull("Der Patient darf nicht mitgelöscht werden", env.patientRepository.getById("patient-1"))
}
@Test
fun delete_beruehrt_das_wundversorger_register_nicht() = runDbTest {
env.insertPatient()
val doc = repo.createEmpty("patient-1")
repo.saveSignature(doc.id, "Anna Schmidt", signatureBitmap())
repo.delete(doc.id)
assertEquals("Anna Schmidt", env.woundCarerRepository.getMostRecentlyUsed()?.name)
assertTrue(env.woundCarerSignatureStorage.deletedAll.isEmpty())
assertTrue(env.woundCarerSignatureStorage.deleted.isEmpty())
}
@Test
fun delete_mit_unbekannter_id_tut_nichts() = runDbTest {
repo.delete("unbekannt")
assertTrue(env.photoStorage.deletedAll.isEmpty())
assertTrue(env.signatureStorage.deletedAll.isEmpty())
}
@Test
fun delete_entfernt_die_dokumentation_auch_wenn_dateiordner_nicht_geloescht_werden_koennen() = runDbTest {
env.insertPatient()
val doc = repo.createEmpty("patient-1")
env.photoStorage.failOnDeleteAll = true
env.signatureStorage.failOnDeleteAll = true
repo.delete(doc.id)
assertNull(repo.getFullDocumentation(doc.id))
}
// --- PDF-Export ----------------------------------------------------------------------
@Test
fun exportPdf_fuer_unbekannte_dokumentation_liefert_fachlichen_fehler_ohne_exporter_aufruf() = runDbTest {
val result = repo.exportPdf("unbekannt")
assertEquals(PdfExportResult.Failure(PdfExportError.DocumentationNotFound), result)
assertTrue(env.pdfExporter.models.isEmpty())
}
@Test
fun exportPdf_uebergibt_nur_bearbeitete_bloecke_und_den_schemakonformen_dateinamen() = runDbTest {
env.insertPatient()
val doc = repo.createEmpty("patient-1")
val block = doc.blocks.first()
val item = block.firstPlainItem()
repo.setChecked(item.checkItemStateId, true)
val result = repo.exportPdf(doc.id)
assertTrue(result is PdfExportResult.Success)
val model = env.pdfExporter.models.single()
assertEquals(doc.id, model.documentationId)
assertEquals(listOf(block.title), model.blocks.map { it.title })
assertTrue(model.blocks.single().items.any { it.label == item.label })
val createdDate = doc.createdAt.atZone(ZoneId.systemDefault()).toLocalDate()
assertEquals("${createdDate}_Max_Mustermann_1980-03-14.pdf", model.fileName)
}
@Test
fun exportPdf_verwendet_den_snapshot_und_nicht_den_aktuellen_patientendatensatz() = runDbTest {
env.insertPatient()
val doc = repo.createEmpty("patient-1")
val patientDao = env.db.patientDao()
patientDao.update(patientDao.getById("patient-1")!!.copy(lastName = "Musterfrau", city = "Neustadt"))
repo.exportPdf(doc.id)
val model = env.pdfExporter.models.single()
assertEquals("Max Mustermann", model.patientFullName)
assertEquals("Musterstraße 1, 12345 Musterstadt", model.patientDisplayAddress)
}
@Test
fun exportPdf_reicht_das_exporter_ergebnis_unveraendert_durch() = runDbTest {
env.insertPatient()
val doc = repo.createEmpty("patient-1")
val failure = PdfExportResult.Failure(PdfExportError.WriteFailed("Speicher voll"))
env.pdfExporter.result = { failure }
assertEquals(failure, repo.exportPdf(doc.id))
}
@Test
fun convertPdfToImages_delegiert_an_den_converter() = runDbTest {
val pdf = File("/fake/pdfs/test.pdf")
env.pdfToImagesConverter.result = PdfToImagesResult.Failure.FileNotFound
val result = repo.convertPdfToImages("doc-1", pdf)
assertEquals(PdfToImagesResult.Failure.FileNotFound, result)
assertEquals(listOf("doc-1" to pdf), env.pdfToImagesConverter.calls)
}
}
@@ -0,0 +1,223 @@
package de.medapp.wunddoku.data.repository
import android.database.sqlite.SQLiteConstraintException
import androidx.test.ext.junit.runners.AndroidJUnit4
import de.medapp.wunddoku.domain.model.Patient
import de.medapp.wunddoku.testutil.TestEnvironment
import de.medapp.wunddoku.testutil.expectThrows
import de.medapp.wunddoku.testutil.runDbTest
import kotlinx.coroutines.flow.first
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import java.time.Instant
import java.time.LocalDate
import java.util.UUID
/** Repository-Tests für [PatientRepository] gegen eine echte In-Memory-Room-Datenbank. */
@RunWith(AndroidJUnit4::class)
class PatientRepositoryTest {
private lateinit var env: TestEnvironment
private val repo get() = env.patientRepository
@Before
fun setUp() {
env = TestEnvironment()
}
@After
fun tearDown() {
env.close()
}
private fun patient(
id: String = "p-1",
firstName: String = "Max",
lastName: String = "Mustermann",
birthDate: LocalDate = LocalDate.of(1980, 3, 14),
) = Patient(
id = id,
firstName = firstName,
lastName = lastName,
birthDate = birthDate,
street = "Musterstraße 1",
postalCode = "12345",
city = "Musterstadt",
phone1 = "0123 456789",
phone2 = "0987 654321",
phone3 = null,
createdAt = Instant.EPOCH,
updatedAt = Instant.EPOCH,
)
// --- upsert / getById ----------------------------------------------------------------
@Test
fun upsert_legt_neuen_patienten_an_und_setzt_zeitstempel_zentral() = runDbTest {
val before = Instant.now().toEpochMilli()
val saved = repo.upsert(patient())
assertEquals(saved.createdAt, saved.updatedAt)
assertTrue(saved.createdAt.toEpochMilli() >= before)
val loaded = repo.getById("p-1")!!
assertEquals("Max", loaded.firstName)
assertEquals("Mustermann", loaded.lastName)
assertEquals(LocalDate.of(1980, 3, 14), loaded.birthDate)
assertEquals("Musterstraße 1", loaded.street)
assertEquals("12345", loaded.postalCode)
assertEquals("Musterstadt", loaded.city)
assertEquals("0123 456789", loaded.phone1)
assertEquals("0987 654321", loaded.phone2)
assertNull(loaded.phone3)
// Die Datenbank speichert Millisekunden, upsert() liefert Instant.now() mit höherer Genauigkeit.
assertEquals(saved.createdAt.toEpochMilli(), loaded.createdAt.toEpochMilli())
}
@Test
fun upsert_aktualisiert_bestehenden_patienten_und_behaelt_createdAt() = runDbTest {
val first = repo.upsert(patient())
// createdAt im übergebenen Objekt wird bewusst ignoriert (Instant.EPOCH aus dem Helper).
val second = repo.upsert(patient(lastName = "Musterfrau"))
assertEquals(first.createdAt.toEpochMilli(), second.createdAt.toEpochMilli())
assertTrue(second.updatedAt.toEpochMilli() >= first.updatedAt.toEpochMilli())
val loaded = repo.getById("p-1")!!
assertEquals("Musterfrau", loaded.lastName)
assertEquals(first.createdAt.toEpochMilli(), loaded.createdAt.toEpochMilli())
}
@Test
fun upsert_erzeugt_bei_unterschiedlichen_ids_getrennte_datensaetze() = runDbTest {
repo.upsert(patient(id = "p-1"))
repo.upsert(patient(id = "p-2", firstName = "Erika", lastName = "Musterfrau"))
assertEquals("Max", repo.getById("p-1")!!.firstName)
assertEquals("Erika", repo.getById("p-2")!!.firstName)
}
@Test
fun getById_liefert_null_fuer_unbekannte_id() = runDbTest {
assertNull(repo.getById("unbekannt"))
}
@Test
fun newId_liefert_gueltige_und_eindeutige_uuids() {
val a = repo.newId()
val b = repo.newId()
assertEquals(a, UUID.fromString(a).toString())
assertTrue(a != b)
}
// --- findExisting --------------------------------------------------------------------
@Test
fun findExisting_trifft_nur_bei_gleichem_namen_und_geburtsdatum() = runDbTest {
repo.upsert(patient())
assertEquals("p-1", repo.findExisting("Max", "Mustermann", LocalDate.of(1980, 3, 14))?.id)
assertNull(repo.findExisting("Max", "Mustermann", LocalDate.of(1980, 3, 15)))
assertNull(repo.findExisting("Moritz", "Mustermann", LocalDate.of(1980, 3, 14)))
assertNull(repo.findExisting("Max", "Musterfrau", LocalDate.of(1980, 3, 14)))
}
// --- Dokumentationen / Löschen -------------------------------------------------------
@Test
fun hasDocumentations_ist_erst_nach_dem_anlegen_einer_dokumentation_true() = runDbTest {
repo.upsert(patient())
assertFalse(repo.hasDocumentations("p-1"))
env.documentationRepository.createEmpty("p-1")
assertTrue(repo.hasDocumentations("p-1"))
}
@Test
fun delete_entfernt_patienten_ohne_dokumentation() = runDbTest {
repo.upsert(patient())
repo.delete("p-1")
assertNull(repo.getById("p-1"))
}
@Test
fun delete_wird_bei_vorhandener_dokumentation_von_der_datenbank_verhindert() = runDbTest {
repo.upsert(patient())
val doc = env.documentationRepository.createEmpty("p-1")
expectThrows<SQLiteConstraintException> { repo.delete("p-1") }
assertNotNull(repo.getById("p-1"))
assertNotNull(env.documentationRepository.getFullDocumentation(doc.id))
}
@Test
fun delete_mit_unbekannter_id_tut_nichts() = runDbTest {
repo.upsert(patient())
repo.delete("unbekannt")
assertNotNull(repo.getById("p-1"))
}
// --- Liste / Suche -------------------------------------------------------------------
@Test
fun observeAll_sortiert_nach_nachname_dann_vorname() = runDbTest {
repo.upsert(patient(id = "p-1", firstName = "Max", lastName = "Müller"))
repo.upsert(patient(id = "p-2", firstName = "Anna", lastName = "Müller"))
repo.upsert(patient(id = "p-3", firstName = "Zoe", lastName = "Adler"))
val list = repo.observeAll().first()
assertEquals(listOf("p-3", "p-2", "p-1"), list.map { it.id })
assertEquals("Zoe Adler", list.first().fullName)
}
@Test
fun observeAll_zeigt_datum_der_letzten_dokumentation_oder_null() = runDbTest {
repo.upsert(patient(id = "p-1"))
repo.upsert(patient(id = "p-2", firstName = "Erika", lastName = "Musterfrau"))
val older = env.documentationRepository.createEmpty("p-1")
val newer = env.documentationRepository.createEmpty("p-1")
env.setCreatedAt(older.id, 1_000L)
env.setCreatedAt(newer.id, 2_000L)
val byId = repo.observeAll().first().associateBy { it.id }
assertEquals(Instant.ofEpochMilli(2_000L), byId.getValue("p-1").lastDocumentationAt)
assertNull(byId.getValue("p-2").lastDocumentationAt)
}
@Test
fun search_findet_ueber_nachname_vorname_und_geburtsdatum() = runDbTest {
repo.upsert(patient(id = "p-1", firstName = "Max", lastName = "Mustermann"))
repo.upsert(patient(id = "p-2", firstName = "Erika", lastName = "Musterfrau", birthDate = LocalDate.of(1975, 12, 1)))
// Beide Nachnamen beginnen mit "Muster": sortiert nach Nachname -> Musterfrau vor Mustermann.
assertEquals(listOf("p-2", "p-1"), repo.search("Muster").first().map { it.id })
assertEquals(listOf("p-1"), repo.search("Max").first().map { it.id })
assertEquals(listOf("p-2"), repo.search("Erika").first().map { it.id })
assertEquals(listOf("p-2"), repo.search("1975-12").first().map { it.id })
assertTrue(repo.search("Zzz").first().isEmpty())
}
@Test
fun search_ist_unabhaengig_von_gross_und_kleinschreibung() = runDbTest {
repo.upsert(patient())
assertEquals(listOf("p-1"), repo.search("mustermann").first().map { it.id })
assertEquals(listOf("p-1"), repo.search("MAX").first().map { it.id })
}
}
@@ -0,0 +1,156 @@
package de.medapp.wunddoku.data.repository
import androidx.test.ext.junit.runners.AndroidJUnit4
import de.medapp.wunddoku.testutil.TestEnvironment
import de.medapp.wunddoku.testutil.runDbTest
import de.medapp.wunddoku.testutil.signatureBitmap
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
/** Repository-Tests für das Wundversorger-Register ([WoundCarerRepository]). */
@RunWith(AndroidJUnit4::class)
class WoundCarerRepositoryTest {
private lateinit var env: TestEnvironment
private val repo get() = env.woundCarerRepository
private val storage get() = env.woundCarerSignatureStorage
@Before
fun setUp() {
env = TestEnvironment()
}
@After
fun tearDown() {
env.close()
}
/** updatedAt basiert auf System.currentTimeMillis() - kurze Pause für eindeutige Reihenfolge. */
private fun tick() = Thread.sleep(5)
@Test
fun getMostRecentlyUsed_ist_null_solange_nie_eine_signatur_gespeichert_wurde() = runDbTest {
assertNull(repo.getMostRecentlyUsed())
}
@Test
fun recordSignatureUsage_legt_neuen_eintrag_mit_eigener_signaturdatei_an() = runDbTest {
repo.recordSignatureUsage("Anna Schmidt", signatureBitmap())
val carer = repo.getMostRecentlyUsed()!!
assertEquals("Anna Schmidt", carer.name)
assertEquals(1, storage.saved.size)
assertEquals(carer.id to carer.signatureFileName, storage.saved.single())
}
@Test
fun recordSignatureUsage_aktualisiert_bekannten_namen_case_insensitiv_und_ersetzt_die_signatur() = runDbTest {
repo.recordSignatureUsage("Anna Schmidt", signatureBitmap())
val first = repo.getMostRecentlyUsed()!!
tick()
repo.recordSignatureUsage("anna schmidt", signatureBitmap())
val second = repo.getMostRecentlyUsed()!!
assertEquals(first.id, second.id)
assertEquals("anna schmidt", second.name) // zuletzt getippte Schreibweise
assertNotEquals(first.signatureFileName, second.signatureFileName)
assertTrue(second.updatedAt >= first.updatedAt)
assertEquals(1, repo.search("a").size)
// Alte Signaturdatei wurde entfernt, genau eine Datei bleibt zugeordnet.
assertEquals(listOf(first.id to first.signatureFileName), storage.deleted)
}
@Test
fun recordSignatureUsage_ignoriert_fehler_beim_loeschen_der_alten_signaturdatei() = runDbTest {
repo.recordSignatureUsage("Anna Schmidt", signatureBitmap())
storage.failOnDelete = true
tick()
repo.recordSignatureUsage("Anna Schmidt", signatureBitmap())
assertEquals(1, repo.search("Anna").size)
}
@Test
fun getMostRecentlyUsed_liefert_den_zuletzt_aktualisierten_wundversorger() = runDbTest {
repo.recordSignatureUsage("Anna Schmidt", signatureBitmap())
tick()
repo.recordSignatureUsage("Bernd Beispiel", signatureBitmap())
assertEquals("Bernd Beispiel", repo.getMostRecentlyUsed()?.name)
tick()
repo.recordSignatureUsage("Anna Schmidt", signatureBitmap())
assertEquals("Anna Schmidt", repo.getMostRecentlyUsed()?.name)
}
// --- search --------------------------------------------------------------------------
@Test
fun search_liefert_praefix_treffer_case_insensitiv_neueste_zuerst() = runDbTest {
repo.recordSignatureUsage("Anna Schmidt", signatureBitmap())
tick()
repo.recordSignatureUsage("Andreas Müller", signatureBitmap())
tick()
repo.recordSignatureUsage("Bernd Beispiel", signatureBitmap())
val result = repo.search("an")
assertEquals(listOf("Andreas Müller", "Anna Schmidt"), result.map { it.name })
}
@Test
fun search_trimmt_die_eingabe_und_liefert_bei_leerer_eingabe_nichts() = runDbTest {
repo.recordSignatureUsage("Anna Schmidt", signatureBitmap())
assertEquals(listOf("Anna Schmidt"), repo.search(" Ann ").map { it.name })
assertTrue(repo.search("").isEmpty())
assertTrue(repo.search(" ").isEmpty())
}
@Test
fun search_ist_ein_praefix_und_kein_teilstring_abgleich() = runDbTest {
repo.recordSignatureUsage("Anna Schmidt", signatureBitmap())
assertTrue(repo.search("Schmidt").isEmpty())
}
@Test
fun search_beachtet_das_limit() = runDbTest {
for (name in listOf("Anna A", "Anna B", "Anna C", "Anna D")) {
repo.recordSignatureUsage(name, signatureBitmap())
tick()
}
assertEquals(2, repo.search("Anna", limit = 2).size)
assertEquals(4, repo.search("Anna").size) // Standardlimit 5 deckt alle vier ab
assertEquals(listOf("Anna D", "Anna C"), repo.search("Anna", limit = 2).map { it.name })
}
// --- loadSignatureBitmap -------------------------------------------------------------
@Test
fun loadSignatureBitmap_delegiert_an_den_wundversorger_speicher() = runDbTest {
val bitmap = signatureBitmap()
storage.decodeResult = bitmap
val result = repo.loadSignatureBitmap("carer-1", "sig.png")
assertEquals(bitmap, result)
assertEquals(listOf("carer-1" to "sig.png"), storage.decodeCalls)
}
@Test
fun loadSignatureBitmap_liefert_null_wenn_die_datei_fehlt() = runDbTest {
storage.decodeResult = null
assertNull(repo.loadSignatureBitmap("carer-1", "fehlt.png"))
}
}
@@ -0,0 +1,222 @@
package de.medapp.wunddoku.media
import android.graphics.Bitmap
import android.graphics.Color
import android.net.Uri
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import de.medapp.wunddoku.testutil.expectThrowsSuspend
import de.medapp.wunddoku.testutil.runDbTest
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import java.io.File
import java.util.UUID
/**
* Fehlerbehandlung von [InternalPhotoStorage] mit ECHTEN Dateien im App-Speicher: defekte,
* leere oder nicht vorhandene Bilder, große Bilder (Speichermangel) sowie "kein halbes
* Ergebnis zurücklassen".
*/
@RunWith(AndroidJUnit4::class)
class PhotoStorageErrorHandlingTest {
private val context = InstrumentationRegistry.getInstrumentation().targetContext
private lateinit var storage: InternalPhotoStorage
private lateinit var documentationId: String
private lateinit var workDir: File
@Before
fun setUp() {
storage = InternalPhotoStorage(context)
// Eigene, zufällige Dokumentations-ID je Test: keine Berührung echter App-Daten.
documentationId = "test-${UUID.randomUUID()}"
workDir = File(context.cacheDir, "errtest-${UUID.randomUUID()}").apply { mkdirs() }
}
@After
fun tearDown() {
File(File(context.filesDir, "photos"), documentationId).deleteRecursively()
workDir.deleteRecursively()
}
private fun docDir() = File(File(context.filesDir, "photos"), documentationId)
private fun filesInDocDir(): List<File> = docDir().listFiles()?.toList().orEmpty()
private fun writeJpeg(name: String, width: Int, height: Int): File {
val file = File(workDir, name)
val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888).apply { eraseColor(Color.RED) }
file.outputStream().use { bitmap.compress(Bitmap.CompressFormat.JPEG, 90, it) }
bitmap.recycle()
return file
}
private fun uriOf(file: File): Uri = Uri.fromFile(file)
// --- Bildfehler beim Import ----------------------------------------------------------
@Test
fun import_einer_textdatei_statt_bild_wirft_und_hinterlaesst_keine_datei() = runDbTest {
val notAnImage = File(workDir, "kein_bild.jpg").apply { writeText("Das ist kein Bild.") }
expectThrowsSuspend<Exception> { storage.importFrom(documentationId, uriOf(notAnImage)) }
assertTrue(filesInDocDir().isEmpty())
}
@Test
fun import_einer_leeren_datei_wirft_und_hinterlaesst_keine_datei() = runDbTest {
val empty = File(workDir, "leer.jpg").apply { createNewFile() }
expectThrowsSuspend<Exception> { storage.importFrom(documentationId, uriOf(empty)) }
assertTrue(filesInDocDir().isEmpty())
}
@Test
fun import_einer_abgeschnittenen_zufallsdatei_wirft_statt_abzustuerzen() = runDbTest {
val garbage = File(workDir, "muell.jpg").apply { writeBytes(ByteArray(2048) { (it * 31).toByte() }) }
expectThrowsSuspend<Exception> { storage.importFrom(documentationId, uriOf(garbage)) }
assertTrue(filesInDocDir().isEmpty())
}
@Test
fun import_einer_nicht_mehr_vorhandenen_datei_wirft() = runDbTest {
val missing = File(workDir, "gibt_es_nicht.jpg")
expectThrowsSuspend<Exception> { storage.importFrom(documentationId, uriOf(missing)) }
assertTrue(filesInDocDir().isEmpty())
}
@Test
fun import_mit_ungueltiger_content_uri_wirft() = runDbTest {
val invalid = Uri.parse("content://de.medapp.gibt.es.nicht/bild/1")
expectThrowsSuspend<Exception> { storage.importFrom(documentationId, invalid) }
assertTrue(filesInDocDir().isEmpty())
}
@Test
fun nach_einem_fehlgeschlagenen_import_funktioniert_der_naechste_import_normal() = runDbTest {
val broken = File(workDir, "kaputt.jpg").apply { writeText("x") }
val good = writeJpeg("gut.jpg", 200, 100)
expectThrowsSuspend<Exception> { storage.importFrom(documentationId, uriOf(broken)) }
val fileName = storage.importFrom(documentationId, uriOf(good))
assertTrue(storage.resolve(documentationId, fileName).exists())
assertEquals(1, filesInDocDir().size)
}
// --- Speichermangel: große Bilder ----------------------------------------------------
@Test
fun sehr_grosses_bild_wird_speicherschonend_heruntergerechnet_importiert() = runDbTest {
// 8000 x 300: viele Megapixel entlang der langen Kante, aber als Testbild selbst günstig.
val big = writeJpeg("gross.jpg", 8000, 300)
val fileName = storage.importFrom(documentationId, uriOf(big))
val bounds = android.graphics.BitmapFactory.Options().apply { inJustDecodeBounds = true }
android.graphics.BitmapFactory.decodeFile(storage.resolve(documentationId, fileName).absolutePath, bounds)
val longest = maxOf(bounds.outWidth, bounds.outHeight)
assertTrue("gespeichert wurde ${bounds.outWidth}x${bounds.outHeight}", longest in 1 until 2 * ImageDownsampler.MAX_STORED_DIMENSION_PX)
}
// --- Datei fehlt/defekt nach dem Import (Vorschau, PDF) ------------------------------
@Test
fun thumbnail_einer_fehlenden_datei_liefert_null() = runDbTest {
assertNull(storage.decodeThumbnail(documentationId, "fehlt.jpg", 300))
}
@Test
fun thumbnail_einer_beschaedigten_datei_liefert_null_statt_abzustuerzen() = runDbTest {
val target = storage.resolve(documentationId, "defekt.jpg")
target.parentFile!!.mkdirs()
target.writeBytes(ByteArray(512) { 7 })
assertNull(storage.decodeThumbnail(documentationId, "defekt.jpg", 300))
}
@Test
fun decodeFitted_einer_fehlenden_oder_beschaedigten_datei_liefert_null() = runDbTest {
assertNull(storage.decodeFitted(documentationId, "fehlt.jpg", 500, 800))
val target = storage.resolve(documentationId, "defekt.jpg")
target.parentFile!!.mkdirs()
target.writeBytes(ByteArray(512) { 7 })
assertNull(storage.decodeFitted(documentationId, "defekt.jpg", 500, 800))
}
@Test
fun decodeFitted_mit_null_zielgroesse_stuerzt_nicht_ab() = runDbTest {
val good = writeJpeg("gut.jpg", 200, 100)
val fileName = storage.importFrom(documentationId, uriOf(good))
// Ungültige Zielgröße (0) darf höchstens null liefern, aber keine Exception werfen.
storage.decodeFitted(documentationId, fileName, 0, 0)
}
@Test
fun decodeFitted_vergroessert_nie_ueber_die_native_aufloesung() = runDbTest {
val small = writeJpeg("klein.jpg", 100, 50)
val fileName = storage.importFrom(documentationId, uriOf(small))
val bitmap = storage.decodeFitted(documentationId, fileName, 4000, 4000)
assertNotNull(bitmap)
assertTrue(bitmap!!.width <= 100 && bitmap.height <= 50)
bitmap.recycle()
}
// --- Kopieren (Dokumentation fortsetzen) ---------------------------------------------
@Test
fun duplicate_einer_fehlenden_quelldatei_wirft_und_hinterlaesst_keine_zieldatei() = runDbTest {
val targetId = "test-${UUID.randomUUID()}"
try {
expectThrowsSuspend<Exception> { storage.duplicate(documentationId, "fehlt.jpg", targetId) }
val targetDir = File(File(context.filesDir, "photos"), targetId)
assertTrue(targetDir.listFiles().orEmpty().isEmpty())
} finally {
File(File(context.filesDir, "photos"), targetId).deleteRecursively()
}
}
@Test
fun duplicate_erzeugt_eine_eigenstaendige_kopie() = runDbTest {
val good = writeJpeg("gut.jpg", 200, 100)
val fileName = storage.importFrom(documentationId, uriOf(good))
val targetId = "test-${UUID.randomUUID()}"
try {
val copy = storage.duplicate(documentationId, fileName, targetId)
storage.delete(documentationId, fileName)
assertTrue("Kopie muss das Löschen des Originals überleben", storage.resolve(targetId, copy).exists())
} finally {
File(File(context.filesDir, "photos"), targetId).deleteRecursively()
}
}
// --- Löschen -------------------------------------------------------------------------
@Test
fun loeschen_nicht_vorhandener_dateien_und_ordner_wirft_nicht() = runDbTest {
storage.delete(documentationId, "gibt_es_nicht.jpg")
storage.deleteAll(documentationId)
assertFalse(docDir().exists())
}
}
@@ -0,0 +1,228 @@
package de.medapp.wunddoku.pdf
import android.graphics.Bitmap
import android.graphics.pdf.PdfRenderer
import android.net.Uri
import android.os.ParcelFileDescriptor
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import de.medapp.wunddoku.media.PhotoStorage
import de.medapp.wunddoku.signature.SignatureStorage
import de.medapp.wunddoku.testutil.FakePhotoStorage
import de.medapp.wunddoku.testutil.FakeSignatureStorage
import de.medapp.wunddoku.testutil.runDbTest
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import java.io.File
import java.time.LocalDate
import java.util.UUID
/**
* Fehlerbehandlung des echten [AndroidPdfDocumentExporter] (echte Dateien im App-Speicher):
* Schreibfehler, kein Datenverlust bereits vorhandener PDFs, keine halbfertigen Dateien sowie
* graceful degradation bei fehlenden/defekten Fotos und Signaturen.
*/
@RunWith(AndroidJUnit4::class)
class PdfExportErrorHandlingTest {
private val context = InstrumentationRegistry.getInstrumentation().targetContext
private val documentationId = "test-${UUID.randomUUID()}"
private val fileName = "2026-08-19_Max_Mustermann_1980-03-14.pdf"
private val pdfRoot get() = File(context.filesDir, "pdf")
private val targetDir get() = File(pdfRoot, documentationId)
private val targetFile get() = File(targetDir, fileName)
@Before
fun setUp() {
pdfRoot.mkdirs()
}
@After
fun tearDown() {
targetDir.deleteRecursively()
}
private fun exporter(
photoStorage: PhotoStorage = FakePhotoStorage(),
signatureStorage: SignatureStorage = FakeSignatureStorage(),
) = AndroidPdfDocumentExporter(context, photoStorage, signatureStorage)
private fun model(photoCount: Int = 0, signatureCount: Int = 0) = PdfExportModel(
documentationId = documentationId,
fileName = fileName,
patientFullName = "Max Mustermann",
patientBirthDate = LocalDate.of(1980, 3, 14),
patientDisplayAddress = "Musterstraße 1, 12345 Musterstadt",
createdAt = LocalDate.of(2026, 8, 19),
blocks = listOf(
PdfExportBlock(
title = "Wundgrund",
items = listOf(PdfExportItem(label = "Granulation", isFreeText = false, freeTextValue = "")),
),
),
signatures = (1..signatureCount).map { PdfExportSignature("Pfleger $it", "sig_$it.png") },
photos = (1..photoCount).map { PdfExportPhoto("foto_$it.jpg") },
)
private fun pageCount(file: File): Int =
ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY).use { fd ->
PdfRenderer(fd).use { it.pageCount }
}
private fun tmpFiles(): List<File> = targetDir.listFiles().orEmpty().filter { it.name.endsWith(".tmp") }
// --- Normalfall als Referenz ---------------------------------------------------------
@Test
fun erfolgreicher_export_erzeugt_ein_lesbares_pdf_ohne_temporaere_datei() = runDbTest {
val result = exporter().export(model())
assertTrue("erwartet Success, war $result", result is PdfExportResult.Success)
assertTrue(targetFile.exists())
assertTrue(pageCount(targetFile) >= 1)
assertTrue(tmpFiles().isEmpty())
}
// --- Schreibfehler -------------------------------------------------------------------
@Test
fun zielordner_ist_eine_datei_liefert_fehler_statt_absturz() = runDbTest {
// Unter dem PDF-Wurzelordner liegt statt des Dokumentationsordners eine normale Datei.
targetDir.deleteRecursively()
targetDir.writeText("blockiert")
val result = exporter().export(model())
assertTrue("erwartet Failure, war $result", result is PdfExportResult.Failure)
assertTrue((result as PdfExportResult.Failure).error is PdfExportError.WriteFailed)
}
@Test
fun fehler_beim_schreiben_laesst_ein_vorhandenes_pdf_unangetastet() = runDbTest {
val first = exporter().export(model())
assertTrue(first is PdfExportResult.Success)
val sizeBefore = targetFile.length()
assertTrue(sizeBefore > 0)
// Zweiter Export scheitert: am Pfad der temporären Datei steht ein Ordner.
File(targetDir, "$fileName.tmp").mkdirs()
val second = exporter().export(model(signatureCount = 1))
assertTrue("erwartet Failure, war $second", second is PdfExportResult.Failure)
assertTrue("bisheriges PDF darf nicht verloren gehen", targetFile.exists())
assertEquals(sizeBefore, targetFile.length())
}
@Test
fun scheitert_das_umbenennen_bleibt_keine_halbfertige_tmp_datei_zurueck() = runDbTest {
// Am endgültigen Pfad steht ein nicht-leerer Ordner: Löschen/Umbenennen schlagen fehl.
targetFile.mkdirs()
File(targetFile, "inhalt.txt").writeText("x")
val result = exporter().export(model())
assertTrue("erwartet Failure, war $result", result is PdfExportResult.Failure)
assertTrue(tmpFiles().isEmpty())
}
@Test
fun erneuter_export_nach_einem_fehler_funktioniert() = runDbTest {
File(targetDir, "$fileName.tmp").mkdirs()
assertTrue(exporter().export(model()) is PdfExportResult.Failure)
File(targetDir, "$fileName.tmp").deleteRecursively()
val retry = exporter().export(model())
assertTrue("erwartet Success, war $retry", retry is PdfExportResult.Success)
}
@Test
fun wiederholter_export_ersetzt_die_vorhandene_datei_statt_weitere_anzulegen() = runDbTest {
exporter().export(model())
exporter().export(model(signatureCount = 1))
assertEquals(listOf(fileName), targetDir.listFiles().orEmpty().map { it.name })
}
// --- Fotos / Signaturen fehlen oder sind defekt --------------------------------------
@Test
fun fehlende_fotodateien_fuehren_zu_hinweisseiten_statt_zum_abbruch() = runDbTest {
// FakePhotoStorage.decodeFitted liefert null (= Datei fehlt/defekt).
val result = exporter().export(model(photoCount = 3))
assertTrue("erwartet Success, war $result", result is PdfExportResult.Success)
// Kopfseite + je eine Seite pro (nicht ladbarem) Foto
assertTrue("mindestens Kopfseite + 3 Fotoseiten", pageCount(targetFile) >= 4)
}
@Test
fun foto_dekodierung_mit_out_of_memory_fuehrt_nicht_zum_absturz() = runDbTest {
val oomStorage = object : PhotoStorage by FakePhotoStorage() {
override suspend fun decodeFitted(
documentationId: String,
fileName: String,
maxWidthPx: Int,
maxHeightPx: Int,
): Bitmap? = throw OutOfMemoryError("Test: zu wenig Speicher")
}
val result = exporter(photoStorage = oomStorage).export(model(photoCount = 2))
assertTrue("erwartet Success, war $result", result is PdfExportResult.Success)
assertTrue("mindestens Kopfseite + 2 Fotoseiten", pageCount(targetFile) >= 3)
assertTrue(tmpFiles().isEmpty())
}
@Test
fun foto_dekodierung_mit_io_fehler_fuehrt_nicht_zum_abbruch() = runDbTest {
val failing = object : PhotoStorage by FakePhotoStorage() {
override suspend fun decodeFitted(
documentationId: String,
fileName: String,
maxWidthPx: Int,
maxHeightPx: Int,
): Bitmap? = throw java.io.IOException("Test: Lesefehler")
}
val result = exporter(photoStorage = failing).export(model(photoCount = 1))
assertTrue(result is PdfExportResult.Success)
}
@Test
fun fehlende_signaturdatei_fuehrt_zu_platzhalter_statt_zum_abbruch() = runDbTest {
// FakeSignatureStorage.decode liefert standardmäßig null.
val result = exporter().export(model(signatureCount = 2))
assertTrue("erwartet Success, war $result", result is PdfExportResult.Success)
assertTrue(pageCount(targetFile) >= 1)
}
@Test
fun signatur_dekodierung_mit_exception_fuehrt_nicht_zum_abbruch() = runDbTest {
val failing = object : SignatureStorage by FakeSignatureStorage() {
override suspend fun decode(documentationId: String, fileName: String): Bitmap? =
throw IllegalStateException("Test: defekte Signatur")
}
val result = exporter(signatureStorage = failing).export(model(signatureCount = 1))
assertTrue("erwartet Success, war $result", result is PdfExportResult.Success)
}
@Test
fun grosser_export_mit_vielen_nicht_ladbaren_fotos_bleibt_stabil() = runDbTest {
val result = exporter().export(model(photoCount = 40, signatureCount = 3))
assertTrue("erwartet Success, war $result", result is PdfExportResult.Success)
assertFalse(tmpFiles().isNotEmpty())
assertTrue("mindestens Kopfseite + 40 Fotoseiten", pageCount(targetFile) >= 41)
}
}
@@ -0,0 +1,210 @@
package de.medapp.wunddoku.pdf
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.ContextWrapper
import android.content.Intent
import android.os.Bundle
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import java.io.File
import java.util.UUID
/**
* Fehlerpfade von [AndroidPdfOpener], [AndroidPdfSharer] und [AndroidPdfImageSharer]:
* fehlende Datei, Datei außerhalb der FileProvider-Freigabe, keine passende App, verweigerter
* Zugriff. Der Activity-Start wird über einen [ContextWrapper] abgefangen - es öffnet sich
* dabei nie eine echte App.
*/
@RunWith(AndroidJUnit4::class)
class PdfIntentErrorHandlingTest {
private val baseContext: Context = InstrumentationRegistry.getInstrumentation().targetContext
private val id = "test-${UUID.randomUUID()}"
private lateinit var sharedPdf: File
private lateinit var sharedImage: File
private lateinit var unsharedFile: File
/** Fängt startActivity ab und wirft optional einen vorgegebenen Fehler. */
private class InterceptingContext(base: Context, private val failure: Throwable?) : ContextWrapper(base) {
val started = mutableListOf<Intent>()
override fun startActivity(intent: Intent) {
started += intent
failure?.let { throw it }
}
override fun startActivity(intent: Intent, options: Bundle?) {
started += intent
failure?.let { throw it }
}
}
@Before
fun setUp() {
// Liegt in einem per file_paths.xml freigegebenen Ordner ("pdf/" bzw. "pdf_images/").
sharedPdf = File(File(File(baseContext.filesDir, "pdf"), id), "test.pdf").apply {
parentFile!!.mkdirs()
writeBytes(byteArrayOf(1, 2, 3))
}
sharedImage = File(File(File(baseContext.filesDir, "pdf_images"), id), "seite_01.jpg").apply {
parentFile!!.mkdirs()
writeBytes(byteArrayOf(1, 2, 3))
}
// Liegt bewusst AUSSERHALB aller freigegebenen Ordner.
unsharedFile = File(baseContext.cacheDir, "nicht_freigegeben_$id.pdf").apply { writeBytes(byteArrayOf(1)) }
}
@After
fun tearDown() {
File(File(baseContext.filesDir, "pdf"), id).deleteRecursively()
File(File(baseContext.filesDir, "pdf_images"), id).deleteRecursively()
unsharedFile.delete()
}
// --- PdfOpener -----------------------------------------------------------------------
@Test
fun opener_meldet_fehlende_datei() {
val context = InterceptingContext(baseContext, null)
val result = AndroidPdfOpener(context).open(File(baseContext.filesDir, "pdf/$id/weg.pdf"))
assertEquals(PdfOpenResult.Failure.FileNotFound, result)
assertTrue(context.started.isEmpty())
}
@Test
fun opener_meldet_datei_ausserhalb_der_freigegebenen_ordner() {
val context = InterceptingContext(baseContext, null)
val result = AndroidPdfOpener(context).open(unsharedFile)
assertTrue("erwartet UriResolutionFailed, war $result", result is PdfOpenResult.Failure.UriResolutionFailed)
assertTrue(context.started.isEmpty())
}
@Test
fun opener_meldet_fehlenden_pdf_reader() {
val context = InterceptingContext(baseContext, ActivityNotFoundException("kein Reader"))
val result = AndroidPdfOpener(context).open(sharedPdf)
assertEquals(PdfOpenResult.Failure.NoViewerAvailable, result)
}
@Test
fun opener_meldet_verweigerten_zugriff_als_unerwarteten_fehler() {
val context = InterceptingContext(baseContext, SecurityException("verweigert"))
val result = AndroidPdfOpener(context).open(sharedPdf)
assertTrue("erwartet Unexpected, war $result", result is PdfOpenResult.Failure.Unexpected)
}
@Test
fun opener_faengt_beliebige_laufzeitfehler_beim_start_ab() {
val context = InterceptingContext(baseContext, IllegalStateException("kaputt"))
val result = AndroidPdfOpener(context).open(sharedPdf)
assertTrue("erwartet Unexpected, war $result", result is PdfOpenResult.Failure.Unexpected)
}
@Test
fun opener_gibt_nur_leserechte_frei() {
val context = InterceptingContext(baseContext, null)
val result = AndroidPdfOpener(context).open(sharedPdf)
assertEquals(PdfOpenResult.Success, result)
val intent = context.started.single()
assertEquals(Intent.ACTION_VIEW, intent.action)
assertEquals("application/pdf", intent.type)
assertNotEquals(0, intent.flags and Intent.FLAG_GRANT_READ_URI_PERMISSION)
assertEquals(0, intent.flags and Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
}
// --- PdfSharer -----------------------------------------------------------------------
@Test
fun sharer_meldet_datei_ausserhalb_der_freigegebenen_ordner() {
val context = InterceptingContext(baseContext, null)
val result = AndroidPdfSharer(context).share(unsharedFile)
assertTrue("erwartet UriResolutionFailed, war $result", result is PdfShareResult.Failure.UriResolutionFailed)
assertTrue(context.started.isEmpty())
}
@Test
fun sharer_meldet_fehlende_app_zum_versenden() {
val context = InterceptingContext(baseContext, ActivityNotFoundException("keine App"))
assertEquals(PdfShareResult.Failure.NoAppAvailable, AndroidPdfSharer(context).share(sharedPdf))
}
@Test
fun sharer_meldet_verweigerten_zugriff_und_sonstige_fehler() {
assertTrue(
AndroidPdfSharer(InterceptingContext(baseContext, SecurityException("x"))).share(sharedPdf)
is PdfShareResult.Failure.Unexpected
)
assertTrue(
AndroidPdfSharer(InterceptingContext(baseContext, RuntimeException("x"))).share(sharedPdf)
is PdfShareResult.Failure.Unexpected
)
}
@Test
fun sharer_gibt_nur_leserechte_frei() {
val context = InterceptingContext(baseContext, null)
assertEquals(PdfShareResult.Success, AndroidPdfSharer(context).share(sharedPdf))
@Suppress("DEPRECATION")
val inner = context.started.single().getParcelableExtra<Intent>(Intent.EXTRA_INTENT)!!
assertEquals(Intent.ACTION_SEND, inner.action)
assertEquals("application/pdf", inner.type)
assertEquals(0, inner.flags and Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
}
// --- PdfImageSharer ------------------------------------------------------------------
@Test
fun image_sharer_meldet_leere_liste() {
val context = InterceptingContext(baseContext, null)
assertEquals(PdfImageShareResult.Failure.NoImages, AndroidPdfImageSharer(context).share(emptyList()))
assertTrue(context.started.isEmpty())
}
@Test
fun image_sharer_meldet_unfreigegebene_datei_auch_wenn_nur_eine_von_mehreren_betroffen_ist() {
val context = InterceptingContext(baseContext, null)
val result = AndroidPdfImageSharer(context).share(listOf(sharedImage, unsharedFile))
assertTrue("erwartet UriResolutionFailed, war $result", result is PdfImageShareResult.Failure.UriResolutionFailed)
assertTrue(context.started.isEmpty())
}
@Test
fun image_sharer_meldet_fehlende_app_und_verweigerten_zugriff() {
assertEquals(
PdfImageShareResult.Failure.NoAppAvailable,
AndroidPdfImageSharer(InterceptingContext(baseContext, ActivityNotFoundException("x"))).share(listOf(sharedImage)),
)
assertTrue(
AndroidPdfImageSharer(InterceptingContext(baseContext, SecurityException("x"))).share(listOf(sharedImage))
is PdfImageShareResult.Failure.Unexpected
)
}
}
@@ -0,0 +1,110 @@
package de.medapp.wunddoku.pdf
import android.graphics.Paint
import android.graphics.pdf.PdfDocument
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import de.medapp.wunddoku.testutil.runDbTest
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import java.io.File
import java.io.FileOutputStream
import java.util.UUID
/** Fehlerbehandlung von [AndroidPdfToImagesConverter] mit echten PDF-Dateien. */
@RunWith(AndroidJUnit4::class)
class PdfToImagesErrorHandlingTest {
private val context = InstrumentationRegistry.getInstrumentation().targetContext
private val converter = AndroidPdfToImagesConverter(context)
private val documentationId = "test-${UUID.randomUUID()}"
private lateinit var workDir: File
private val imagesDir get() = File(File(context.filesDir, "pdf_images"), documentationId)
@Before
fun setUp() {
workDir = File(context.cacheDir, "errtest-${UUID.randomUUID()}").apply { mkdirs() }
}
@After
fun tearDown() {
imagesDir.deleteRecursively()
workDir.deleteRecursively()
}
private fun validPdf(pages: Int = 1): File {
val file = File(workDir, "gueltig.pdf")
val document = PdfDocument()
repeat(pages) { index ->
val page = document.startPage(PdfDocument.PageInfo.Builder(595, 842, index + 1).create())
page.canvas.drawText("Seite ${index + 1}", 50f, 50f, Paint().apply { textSize = 20f })
document.finishPage(page)
}
FileOutputStream(file).use { document.writeTo(it) }
document.close()
return file
}
@Test
fun fehlende_pdf_liefert_file_not_found() = runDbTest {
val result = converter.convert(documentationId, File(workDir, "gibt_es_nicht.pdf"))
assertEquals(PdfToImagesResult.Failure.FileNotFound, result)
}
@Test
fun beschaedigte_pdf_liefert_fehler_statt_absturz_und_hinterlaesst_keine_bilder() = runDbTest {
val corrupt = File(workDir, "defekt.pdf").apply { writeBytes(ByteArray(1024) { (it % 251).toByte() }) }
val result = converter.convert(documentationId, corrupt)
assertTrue("erwartet Failure, war $result", result is PdfToImagesResult.Failure)
assertTrue(imagesDir.listFiles().orEmpty().isEmpty())
}
@Test
fun leere_pdf_datei_liefert_fehler_statt_absturz() = runDbTest {
val empty = File(workDir, "leer.pdf").apply { createNewFile() }
val result = converter.convert(documentationId, empty)
assertTrue("erwartet Failure, war $result", result is PdfToImagesResult.Failure)
}
@Test
fun gueltige_pdf_wird_seitenweise_in_bilder_umgewandelt() = runDbTest {
val result = converter.convert(documentationId, validPdf(pages = 2))
assertTrue("erwartet Success, war $result", result is PdfToImagesResult.Success)
val files = (result as PdfToImagesResult.Success).imageFiles
assertEquals(2, files.size)
assertTrue(files.all { it.exists() && it.length() > 0 })
}
@Test
fun veraltete_bilder_eines_frueheren_exports_werden_entfernt() = runDbTest {
imagesDir.mkdirs()
val stale = File(imagesDir, "seite_09.jpg").apply { writeText("alt") }
val result = converter.convert(documentationId, validPdf(pages = 1))
assertTrue(result is PdfToImagesResult.Success)
assertFalse("veraltete Seite darf nicht mitgeteilt werden", stale.exists())
}
@Test
fun nach_einem_fehler_funktioniert_die_naechste_umwandlung() = runDbTest {
val corrupt = File(workDir, "defekt.pdf").apply { writeBytes(ByteArray(64) { 1 }) }
assertTrue(converter.convert(documentationId, corrupt) is PdfToImagesResult.Failure)
val result = converter.convert(documentationId, validPdf())
assertTrue(result is PdfToImagesResult.Success)
}
}
@@ -0,0 +1,114 @@
package de.medapp.wunddoku.signature
import android.graphics.Bitmap
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import de.medapp.wunddoku.testutil.blankBitmap
import de.medapp.wunddoku.testutil.expectThrowsSuspend
import de.medapp.wunddoku.testutil.runDbTest
import de.medapp.wunddoku.testutil.signatureBitmap
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import java.io.File
import java.util.UUID
/**
* Fehlerbehandlung von [InternalSignatureStorage] mit echten Dateien (eigenes Test-
* Wurzelverzeichnis, berührt keine echten Signaturen).
*/
@RunWith(AndroidJUnit4::class)
class SignatureStorageErrorHandlingTest {
private val context = InstrumentationRegistry.getInstrumentation().targetContext
private val rootDirName = "test_signatures_${UUID.randomUUID()}"
private lateinit var storage: InternalSignatureStorage
private val documentationId = "doc-1"
@Before
fun setUp() {
storage = InternalSignatureStorage(context, rootDirName)
}
@After
fun tearDown() {
File(context.filesDir, rootDirName).deleteRecursively()
}
private fun filesInDocDir(): List<File> =
File(File(context.filesDir, rootDirName), documentationId).listFiles()?.toList().orEmpty()
@Test
fun speichern_einer_recycelten_bitmap_wirft_und_hinterlaesst_keine_datei() = runDbTest {
val recycled = signatureBitmap().also { it.recycle() }
expectThrowsSuspend<Exception> { storage.save(documentationId, recycled) }
assertTrue("keine leere PNG-Datei als scheinbar gültige Signatur", filesInDocDir().isEmpty())
}
@Test
fun nach_fehlgeschlagenem_speichern_funktioniert_das_naechste_speichern() = runDbTest {
val recycled = signatureBitmap().also { it.recycle() }
expectThrowsSuspend<Exception> { storage.save(documentationId, recycled) }
val fileName = storage.save(documentationId, signatureBitmap())
assertTrue(storage.resolve(documentationId, fileName).exists())
assertEquals(1, filesInDocDir().size)
}
@Test
fun gespeicherte_signatur_laesst_sich_wieder_dekodieren() = runDbTest {
val fileName = storage.save(documentationId, signatureBitmap())
val decoded = storage.decode(documentationId, fileName)
assertNotNull(decoded)
assertEquals(40, decoded!!.width)
decoded.recycle()
}
@Test
fun decode_einer_fehlenden_datei_liefert_null() = runDbTest {
assertNull(storage.decode(documentationId, "fehlt.png"))
}
@Test
fun decode_einer_beschaedigten_datei_liefert_null_statt_abzustuerzen() = runDbTest {
val target = storage.resolve(documentationId, "defekt.png")
target.parentFile!!.mkdirs()
target.writeBytes(ByteArray(256) { 3 })
assertNull(storage.decode(documentationId, "defekt.png"))
}
@Test
fun decode_einer_leeren_datei_liefert_null() = runDbTest {
val target = storage.resolve(documentationId, "leer.png")
target.parentFile!!.mkdirs()
target.createNewFile()
assertNull(storage.decode(documentationId, "leer.png"))
}
@Test
fun loeschen_nicht_vorhandener_dateien_wirft_nicht() = runDbTest {
storage.delete(documentationId, "gibt_es_nicht.png")
storage.deleteAll(documentationId)
assertFalse(File(File(context.filesDir, rootDirName), documentationId).exists())
}
@Test
fun leere_zeichnung_wird_von_der_validierung_erkannt() {
val blank: Bitmap = blankBitmap()
assertTrue(SignatureBitmapValidation.isBlank(blank))
assertFalse(SignatureBitmapValidation.isBlank(signatureBitmap()))
}
}
@@ -0,0 +1,196 @@
package de.medapp.wunddoku.testutil
import android.graphics.Bitmap
import android.net.Uri
import de.medapp.wunddoku.data.dao.CheckItemStateDao
import de.medapp.wunddoku.data.dao.DocumentationDao
import de.medapp.wunddoku.data.dao.PatientDao
import de.medapp.wunddoku.data.entity.PatientEntity
import de.medapp.wunddoku.data.entity.relations.DocumentationWithDetails
import de.medapp.wunddoku.data.dao.PhotoDao
import de.medapp.wunddoku.data.dao.SignatureDao
import de.medapp.wunddoku.data.repository.DocumentationRepository
import de.medapp.wunddoku.data.repository.WoundCarerRepository
import de.medapp.wunddoku.media.PhotoStorage
import de.medapp.wunddoku.pdf.DocumentationPdfExporter
import de.medapp.wunddoku.pdf.PdfToImagesConverter
import de.medapp.wunddoku.signature.SignatureStorage
import kotlinx.coroutines.withTimeout
import kotlinx.coroutines.delay
import java.io.File
/**
* Hilfsklassen für die Fehlerbehandlungs-Tests. Die bestehenden Fakes (Fakes.kt) bleiben
* unverändert; hier werden sie per Delegation um gezielt auslösbare Fehler ergänzt.
*/
/** [PhotoStorage] mit konfigurierbaren Fehlern pro Aufruf (1-basierte Aufrufnummer). */
class FailingPhotoStorage(
val delegate: FakePhotoStorage = FakePhotoStorage(),
) : PhotoStorage by delegate {
/** Liefert für die n-te [importFrom]-Anweisung einen Fehler (oder `null` = Erfolg). */
var importFailure: (Int) -> Throwable? = { null }
private var importCalls = 0
override suspend fun importFrom(documentationId: String, sourceUri: Uri): String {
importCalls++
importFailure(importCalls)?.let { throw it }
return delegate.importFrom(documentationId, sourceUri)
}
}
/** [SignatureStorage] mit gezielt auslösbarem Fehler beim Speichern. */
class FailingSignatureStorage(
val delegate: FakeSignatureStorage = FakeSignatureStorage(),
) : SignatureStorage by delegate {
var saveFailure: Throwable? = null
override suspend fun save(documentationId: String, bitmap: Bitmap): String {
saveFailure?.let { throw it }
return delegate.save(documentationId, bitmap)
}
}
/** Exporter, der eine beliebige Exception/Error wirft (statt ein Failure-Ergebnis zu liefern). */
class ThrowingPdfExporter(private val error: Throwable) : DocumentationPdfExporter {
override suspend fun export(model: de.medapp.wunddoku.pdf.PdfExportModel): de.medapp.wunddoku.pdf.PdfExportResult =
throw error
}
/** Converter, der eine beliebige Exception/Error wirft. */
class ThrowingPdfToImagesConverter(private val error: Throwable) : PdfToImagesConverter {
override suspend fun convert(documentationId: String, pdfFile: File): de.medapp.wunddoku.pdf.PdfToImagesResult =
throw error
}
/** [CheckItemStateDao] mit abschaltbaren Schreibfehlern (z. B. "database or disk is full"). */
class FailingCheckItemStateDao(
private val delegate: CheckItemStateDao,
) : CheckItemStateDao by delegate {
@Volatile var failure: Throwable? = null
override suspend fun setChecked(id: String, checked: Boolean) {
failure?.let { throw it }
delegate.setChecked(id, checked)
}
override suspend fun updateFreeTextValue(id: String, value: String) {
failure?.let { throw it }
delegate.updateFreeTextValue(id, value)
}
}
/** [DocumentationDao] mit abschaltbarem Fehler beim Laden einer kompletten Dokumentation. */
class FailingDocumentationDao(
private val delegate: DocumentationDao,
) : DocumentationDao by delegate {
@Volatile var loadFailure: Throwable? = null
override suspend fun getFullDocumentation(id: String): DocumentationWithDetails? {
loadFailure?.let { throw it }
return delegate.getFullDocumentation(id)
}
}
/** [PatientDao] mit abschaltbarem Fehler beim Einfügen. */
class FailingPatientDao(
private val delegate: PatientDao,
) : PatientDao by delegate {
@Volatile var insertFailure: Throwable? = null
override suspend fun insert(patient: PatientEntity) {
insertFailure?.let { throw it }
delegate.insert(patient)
}
}
/** [PhotoDao] mit abschaltbarem Fehler beim Einfügen. */
class FailingPhotoDao(
private val delegate: PhotoDao,
) : PhotoDao by delegate {
@Volatile var insertFailure: Throwable? = null
override suspend fun insert(photo: de.medapp.wunddoku.data.entity.PhotoEntity) {
insertFailure?.let { throw it }
delegate.insert(photo)
}
}
/** [SignatureDao] mit abschaltbarem Fehler beim Einfügen. */
class FailingSignatureDao(
private val delegate: SignatureDao,
) : SignatureDao by delegate {
@Volatile var insertFailure: Throwable? = null
override suspend fun insert(signature: de.medapp.wunddoku.data.entity.SignatureEntity) {
insertFailure?.let { throw it }
delegate.insert(signature)
}
}
/** Baut ein [DocumentationRepository] auf der echten In-Memory-DB mit austauschbaren Bausteinen. */
fun TestEnvironment.buildRepository(
photoStorage: PhotoStorage = this.photoStorage,
signatureStorage: SignatureStorage = this.signatureStorage,
woundCarerRepository: WoundCarerRepository = this.woundCarerRepository,
pdfExporter: DocumentationPdfExporter = this.pdfExporter,
pdfToImagesConverter: PdfToImagesConverter = this.pdfToImagesConverter,
checkItemStateDao: CheckItemStateDao = db.checkItemStateDao(),
photoDao: PhotoDao = db.photoDao(),
signatureDao: SignatureDao = db.signatureDao(),
documentationDao: DocumentationDao = db.documentationDao(),
): DocumentationRepository = DocumentationRepository(
documentationDao = documentationDao,
blockStateDao = db.blockStateDao(),
checkItemStateDao = checkItemStateDao,
photoDao = photoDao,
signatureDao = signatureDao,
patientDao = db.patientDao(),
photoStorage = photoStorage,
signatureStorage = signatureStorage,
woundCarerRepository = woundCarerRepository,
pdfExporter = pdfExporter,
pdfToImagesConverter = pdfToImagesConverter,
db = db,
)
/** Erwartet, dass der Suspend-Block eine Exception vom Typ [T] wirft, und liefert sie zurück. */
suspend inline fun <reified T : Throwable> expectThrowsSuspend(block: () -> Unit): T {
try {
block()
} catch (e: Throwable) {
if (e is T) return e
throw e
}
throw AssertionError("Erwartet: ${T::class.java.simpleName}, es wurde aber keine Exception geworfen")
}
/** Wartet (max. [timeoutMs]) bis [condition] wahr ist - für asynchrone ViewModel-Zustände. */
suspend fun awaitCondition(timeoutMs: Long = 5_000, condition: () -> Boolean) {
withTimeout(timeoutMs) {
while (!condition()) delay(20)
}
}
/** Wie [awaitCondition], aber mit Suspend-Bedingung (z. B. Abfrage der Datenbank). */
suspend fun awaitUntil(timeoutMs: Long = 5_000, condition: suspend () -> Boolean) {
withTimeout(timeoutMs) {
while (!condition()) delay(20)
}
}
/** Führt [block] auf dem Main-Thread aus (ViewModels greifen intern auf Main-State zu). */
fun <T> onMain(block: () -> T): T {
var result: Result<T>? = null
androidx.test.platform.app.InstrumentationRegistry.getInstrumentation().runOnMainSync {
result = runCatching(block)
}
return result!!.getOrThrow()
}
@@ -0,0 +1,138 @@
package de.medapp.wunddoku.testutil
import android.graphics.Bitmap
import android.net.Uri
import de.medapp.wunddoku.media.PhotoStorage
import de.medapp.wunddoku.pdf.DocumentationPdfExporter
import de.medapp.wunddoku.pdf.PdfExportModel
import de.medapp.wunddoku.pdf.PdfExportResult
import de.medapp.wunddoku.pdf.PdfToImagesConverter
import de.medapp.wunddoku.pdf.PdfToImagesResult
import de.medapp.wunddoku.signature.SignatureStorage
import java.io.File
import java.io.IOException
/** Aufgezeichneter Aufruf von [PhotoStorage.duplicate]. */
data class DuplicateCall(
val sourceDocumentationId: String,
val sourceFileName: String,
val targetDocumentationId: String,
)
/**
* In-Memory-Fake für [PhotoStorage]: schreibt keine Dateien, zeichnet aber alle Aufrufe auf und
* vergibt eindeutige Dateinamen. Fehler lassen sich gezielt auslösen.
*/
class FakePhotoStorage : PhotoStorage {
val imported = mutableListOf<Pair<String, Uri>>()
val duplicated = mutableListOf<DuplicateCall>()
val deleted = mutableListOf<Pair<String, String>>()
val deletedAll = mutableListOf<String>()
var failOnDelete = false
var failOnDeleteAll = false
/** 1-basierte Nummer des [duplicate]-Aufrufs, der mit einer [IOException] fehlschlagen soll. */
var failOnDuplicateCall: Int? = null
private var counter = 0
override suspend fun importFrom(documentationId: String, sourceUri: Uri): String {
imported += documentationId to sourceUri
return "imported_${++counter}.jpg"
}
override suspend fun duplicate(
sourceDocumentationId: String,
sourceFileName: String,
targetDocumentationId: String,
): String {
duplicated += DuplicateCall(sourceDocumentationId, sourceFileName, targetDocumentationId)
if (failOnDuplicateCall == duplicated.size) throw IOException("Kopieren fehlgeschlagen (Test)")
return "copy_${++counter}.jpg"
}
override suspend fun delete(documentationId: String, fileName: String) {
if (failOnDelete) throw IOException("Löschen fehlgeschlagen (Test)")
deleted += documentationId to fileName
}
override suspend fun deleteAll(documentationId: String) {
if (failOnDeleteAll) throw IOException("Ordner löschen fehlgeschlagen (Test)")
deletedAll += documentationId
}
override fun resolve(documentationId: String, fileName: String): File =
File("/fake/photos/$documentationId/$fileName")
override suspend fun decodeThumbnail(documentationId: String, fileName: String, maxDimensionPx: Int): Bitmap? = null
override suspend fun decodeFitted(
documentationId: String,
fileName: String,
maxWidthPx: Int,
maxHeightPx: Int,
): Bitmap? = null
}
/** In-Memory-Fake für [SignatureStorage] (analog [FakePhotoStorage]). */
class FakeSignatureStorage : SignatureStorage {
val saved = mutableListOf<Pair<String, String>>() // key (documentationId bzw. woundCarerId) -> fileName
val deleted = mutableListOf<Pair<String, String>>()
val deletedAll = mutableListOf<String>()
val decodeCalls = mutableListOf<Pair<String, String>>()
var failOnDelete = false
var failOnDeleteAll = false
var decodeResult: Bitmap? = null
private var counter = 0
override suspend fun save(documentationId: String, bitmap: Bitmap): String {
val fileName = "sig_${++counter}.png"
saved += documentationId to fileName
return fileName
}
override suspend fun delete(documentationId: String, fileName: String) {
if (failOnDelete) throw IOException("Löschen fehlgeschlagen (Test)")
deleted += documentationId to fileName
}
override suspend fun deleteAll(documentationId: String) {
if (failOnDeleteAll) throw IOException("Ordner löschen fehlgeschlagen (Test)")
deletedAll += documentationId
}
override fun resolve(documentationId: String, fileName: String): File =
File("/fake/signatures/$documentationId/$fileName")
override suspend fun decode(documentationId: String, fileName: String): Bitmap? {
decodeCalls += documentationId to fileName
return decodeResult
}
}
/** Zeichnet die an den Exporter übergebenen Modelle auf und liefert ein konfigurierbares Ergebnis. */
class FakePdfExporter : DocumentationPdfExporter {
val models = mutableListOf<PdfExportModel>()
var result: ((PdfExportModel) -> PdfExportResult) = { model ->
PdfExportResult.Success(File("/fake/pdfs/${model.fileName}"))
}
override suspend fun export(model: PdfExportModel): PdfExportResult {
models += model
return result(model)
}
}
/** Zeichnet Aufrufe auf und liefert ein konfigurierbares Ergebnis. */
class FakePdfToImagesConverter : PdfToImagesConverter {
val calls = mutableListOf<Pair<String, File>>()
var result: PdfToImagesResult = PdfToImagesResult.Success(emptyList())
override suspend fun convert(documentationId: String, pdfFile: File): PdfToImagesResult {
calls += documentationId to pdfFile
return result
}
}
@@ -0,0 +1,133 @@
package de.medapp.wunddoku.testutil
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import androidx.room.Room
import androidx.test.platform.app.InstrumentationRegistry
import de.medapp.wunddoku.data.database.AppDatabase
import de.medapp.wunddoku.data.entity.PatientEntity
import de.medapp.wunddoku.data.repository.DocumentationRepository
import de.medapp.wunddoku.data.repository.PatientRepository
import de.medapp.wunddoku.data.repository.WoundCarerRepository
import de.medapp.wunddoku.domain.model.CheckItem
import de.medapp.wunddoku.domain.model.DocumentationBlock
import de.medapp.wunddoku.domain.model.DocumentationStatus
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.runBlocking
/**
* Gemeinsame Testumgebung für alle Instrumented-Tests: echte Room-Datenbank im Arbeitsspeicher
* (gleiche Entities/DAOs/Foreign Keys wie in Produktion), aber Fakes für Dateispeicher und
* PDF-Erzeugung. Jeder Test erzeugt eine eigene Instanz und ruft am Ende [close] auf.
*/
class TestEnvironment {
val db: AppDatabase = Room.inMemoryDatabaseBuilder(
InstrumentationRegistry.getInstrumentation().targetContext,
AppDatabase::class.java,
).build()
val photoStorage = FakePhotoStorage()
val signatureStorage = FakeSignatureStorage()
val woundCarerSignatureStorage = FakeSignatureStorage()
val pdfExporter = FakePdfExporter()
val pdfToImagesConverter = FakePdfToImagesConverter()
val patientRepository = PatientRepository(patientDao = db.patientDao())
val woundCarerRepository = WoundCarerRepository(
woundCarerDao = db.woundCarerDao(),
woundCarerSignatureStorage = woundCarerSignatureStorage,
)
val documentationRepository = DocumentationRepository(
documentationDao = db.documentationDao(),
blockStateDao = db.blockStateDao(),
checkItemStateDao = db.checkItemStateDao(),
photoDao = db.photoDao(),
signatureDao = db.signatureDao(),
patientDao = db.patientDao(),
photoStorage = photoStorage,
signatureStorage = signatureStorage,
woundCarerRepository = woundCarerRepository,
pdfExporter = pdfExporter,
pdfToImagesConverter = pdfToImagesConverter,
db = db,
)
fun close() = db.close()
/** Legt direkt über das DAO einen Patienten an (ohne Repository-Logik). */
suspend fun insertPatient(
id: String = "patient-1",
firstName: String = "Max",
lastName: String = "Mustermann",
birthDate: String = "1980-03-14",
street: String = "Musterstraße 1",
postalCode: String = "12345",
city: String = "Musterstadt",
): PatientEntity {
val entity = PatientEntity(
id = id,
firstName = firstName,
lastName = lastName,
birthDate = birthDate,
street = street,
postalCode = postalCode,
city = city,
phone1 = "0123 456789",
phone2 = null,
phone3 = null,
createdAt = 1_000L,
updatedAt = 1_000L,
)
db.patientDao().insert(entity)
return entity
}
/** Setzt createdAt einer Dokumentation gezielt (vermeidet zeitabhängige Tests). */
suspend fun setCreatedAt(documentationId: String, createdAt: Long) {
val dao = db.documentationDao()
dao.update(dao.getById(documentationId)!!.copy(createdAt = createdAt))
}
suspend fun setStatus(documentationId: String, status: DocumentationStatus) {
val dao = db.documentationDao()
dao.update(dao.getById(documentationId)!!.copy(status = status))
}
}
/** Führt einen Test mit Suspend-Aufrufen aus; Rückgabetyp Unit, damit JUnit die Methode akzeptiert. */
fun runDbTest(block: suspend CoroutineScope.() -> Unit) {
runBlocking { block() }
}
/** Erstes Item eines Blocks, das eine normale Checkbox ist (kein Freitext, hint oder strukturierte Eingabe). */
fun DocumentationBlock.firstPlainItem(): CheckItem =
items.first { !it.isFreeText && !it.isStaticHint && it.inputSpec.isEmpty() }
/** Weißes Bitmap ohne jeden Strich - gilt als "leere Signatur". */
fun blankBitmap(): Bitmap =
Bitmap.createBitmap(40, 20, Bitmap.Config.ARGB_8888).apply { eraseColor(Color.WHITE) }
/** Weißes Bitmap mit einem schwarzen Strich - gilt als gültige Signatur. */
fun signatureBitmap(): Bitmap = blankBitmap().also { bitmap ->
val paint = Paint().apply {
color = Color.BLACK
strokeWidth = 3f
}
Canvas(bitmap).drawLine(2f, 2f, 38f, 18f, paint)
}
/** Erwartet, dass [block] eine Exception vom Typ [T] wirft, und liefert sie zurück. */
inline fun <reified T : Throwable> expectThrows(block: () -> Unit): T {
try {
block()
} catch (e: Throwable) {
if (e is T) return e
throw e
}
throw AssertionError("Erwartet: ${T::class.java.simpleName}, es wurde aber keine Exception geworfen")
}
@@ -0,0 +1,473 @@
package de.medapp.wunddoku.ui.documentation.edit
import android.database.sqlite.SQLiteFullException
import android.net.Uri
import androidx.test.ext.junit.runners.AndroidJUnit4
import de.medapp.wunddoku.data.repository.DocumentationRepository
import de.medapp.wunddoku.domain.model.CheckItem
import de.medapp.wunddoku.pdf.PdfExportError
import de.medapp.wunddoku.pdf.PdfExportResult
import de.medapp.wunddoku.pdf.PdfToImagesResult
import de.medapp.wunddoku.testutil.FailingCheckItemStateDao
import de.medapp.wunddoku.testutil.FailingDocumentationDao
import de.medapp.wunddoku.testutil.FailingPhotoStorage
import de.medapp.wunddoku.testutil.FailingSignatureStorage
import de.medapp.wunddoku.testutil.TestEnvironment
import de.medapp.wunddoku.testutil.ThrowingPdfExporter
import de.medapp.wunddoku.testutil.awaitCondition
import de.medapp.wunddoku.testutil.awaitUntil
import de.medapp.wunddoku.testutil.blankBitmap
import de.medapp.wunddoku.testutil.buildRepository
import de.medapp.wunddoku.testutil.firstPlainItem
import de.medapp.wunddoku.testutil.onMain
import de.medapp.wunddoku.testutil.runDbTest
import de.medapp.wunddoku.testutil.signatureBitmap
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import java.io.File
import java.io.IOException
/**
* Fehlerbehandlung im [DocumentationEditViewModel] mit echter In-Memory-Room-DB: Autosave bei
* vollem Speicher (Datenverlust verhindern), Fotoimport (defekte Bilder, Speichermangel),
* Signatur, PDF-Export und Teilen. Kein Fehler darf einen Absturz oder einen dauerhaft
* hängenden Ladezustand verursachen.
*/
@RunWith(AndroidJUnit4::class)
class DocumentationEditViewModelErrorHandlingTest {
private lateinit var env: TestEnvironment
private lateinit var documentationId: String
private val someUri: Uri = Uri.parse("content://test/bild/1")
@Before
fun setUp() = runDbTest {
env = TestEnvironment()
env.insertPatient()
documentationId = env.documentationRepository.createEmpty("patient-1").id
}
@After
fun tearDown() {
env.close()
}
private suspend fun createViewModel(
repository: DocumentationRepository = env.documentationRepository,
id: String = documentationId,
): DocumentationEditViewModel {
val viewModel = onMain { DocumentationEditViewModel(id, repository, env.woundCarerRepository) }
awaitCondition { !viewModel.uiState.value.isLoading }
return viewModel
}
private fun DocumentationEditViewModel.plainItem(): CheckItem =
uiState.value.documentation!!.blocks.first().firstPlainItem()
private fun DocumentationEditViewModel.freeTextItem(): CheckItem =
uiState.value.documentation!!.blocks.first().items.first { it.isFreeText }
private fun DocumentationEditViewModel.itemById(id: String): CheckItem =
uiState.value.documentation!!.blocks.flatMap { it.items }.first { it.checkItemStateId == id }
private suspend fun storedFreeText(itemId: String): String? =
env.documentationRepository.getFullDocumentation(documentationId)
?.blocks?.flatMap { it.items }?.firstOrNull { it.checkItemStateId == itemId }?.freeTextValue
// --- Laden ---------------------------------------------------------------------------
@Test
fun unbekannte_dokumentation_zeigt_fehler_und_laedt_nicht_endlos() = runDbTest {
val viewModel = createViewModel(id = "gibt-es-nicht")
assertEquals("Dokumentation nicht gefunden.", viewModel.uiState.value.error)
assertFalse(viewModel.uiState.value.isLoading)
}
@Test
fun datenbankfehler_beim_laden_zeigt_fehler_statt_dauerhaftem_ladezustand() = runDbTest {
val failingDao = FailingDocumentationDao(env.db.documentationDao())
.apply { loadFailure = IllegalStateException("Datenbank nicht verfügbar (Test)") }
val repository = env.buildRepository(documentationDao = failingDao)
val viewModel = createViewModel(repository)
assertFalse(viewModel.uiState.value.isLoading)
assertNotNull(viewModel.uiState.value.error)
assertNull(viewModel.uiState.value.documentation)
}
// --- Autosave: Haken -----------------------------------------------------------------
@Test
fun haken_wird_bei_normalem_betrieb_gespeichert() = runDbTest {
val viewModel = createViewModel()
val item = viewModel.plainItem()
onMain { viewModel.onCheckedChanged(item.checkItemStateId, true) }
awaitUntil {
env.documentationRepository.getFullDocumentation(documentationId)!!
.blocks.flatMap { it.items }.first { it.checkItemStateId == item.checkItemStateId }.checked
}
assertNull(viewModel.uiState.value.saveError)
}
@Test
fun haken_bei_vollem_speicher_meldet_fehler_und_zeigt_den_tatsaechlich_gespeicherten_stand() = runDbTest {
val failingDao = FailingCheckItemStateDao(env.db.checkItemStateDao())
.apply { failure = SQLiteFullException("database or disk is full") }
val viewModel = createViewModel(env.buildRepository(checkItemStateDao = failingDao))
val item = viewModel.plainItem()
onMain { viewModel.onCheckedChanged(item.checkItemStateId, true) }
awaitCondition {
viewModel.uiState.value.saveError != null && !viewModel.itemById(item.checkItemStateId).checked
}
assertTrue(viewModel.uiState.value.saveError!!.contains("Speicherplatz"))
assertFalse("Anzeige darf keinen nicht gespeicherten Haken zeigen", viewModel.itemById(item.checkItemStateId).checked)
}
@Test
fun speicherfehler_beim_haken_laesst_die_app_nicht_abstuerzen_und_spaetere_aenderungen_funktionieren() = runDbTest {
val failingDao = FailingCheckItemStateDao(env.db.checkItemStateDao())
.apply { failure = SQLiteFullException("database or disk is full") }
val viewModel = createViewModel(env.buildRepository(checkItemStateDao = failingDao))
val item = viewModel.plainItem()
onMain { viewModel.onCheckedChanged(item.checkItemStateId, true) }
awaitCondition { viewModel.uiState.value.saveError != null }
onMain { viewModel.dismissSaveError() }
failingDao.failure = null
onMain { viewModel.onCheckedChanged(item.checkItemStateId, true) }
awaitUntil {
env.documentationRepository.getFullDocumentation(documentationId)!!
.blocks.flatMap { it.items }.first { it.checkItemStateId == item.checkItemStateId }.checked
}
assertNull(viewModel.uiState.value.saveError)
}
@Test
fun dismissSaveError_leert_die_meldung() = runDbTest {
val failingDao = FailingCheckItemStateDao(env.db.checkItemStateDao()).apply { failure = IOException("x") }
val viewModel = createViewModel(env.buildRepository(checkItemStateDao = failingDao))
onMain { viewModel.onCheckedChanged(viewModel.plainItem().checkItemStateId, true) }
awaitCondition { viewModel.uiState.value.saveError != null }
onMain { viewModel.dismissSaveError() }
assertNull(viewModel.uiState.value.saveError)
}
// --- Autosave: Freitext (Datenverlust verhindern) ------------------------------------
@Test
fun freitext_wird_nach_debounce_gespeichert() = runDbTest {
val viewModel = createViewModel()
val item = viewModel.freeTextItem()
onMain { viewModel.onFreeTextChanged(item.checkItemStateId, "Wunde trocken") }
awaitUntil { storedFreeText(item.checkItemStateId) == "Wunde trocken" }
}
@Test
fun freitext_bleibt_bei_speicherfehler_in_der_anzeige_erhalten_und_wird_spaeter_nachgeschrieben() = runDbTest {
val failingDao = FailingCheckItemStateDao(env.db.checkItemStateDao())
.apply { failure = SQLiteFullException("database or disk is full") }
val viewModel = createViewModel(env.buildRepository(checkItemStateDao = failingDao))
val item = viewModel.freeTextItem()
onMain { viewModel.onFreeTextChanged(item.checkItemStateId, "Wichtiger Befund") }
awaitCondition { viewModel.uiState.value.saveError != null }
assertEquals(
"Der Nutzer darf seinen Text nicht verlieren",
"Wichtiger Befund",
viewModel.itemById(item.checkItemStateId).freeTextValue,
)
assertEquals("", storedFreeText(item.checkItemStateId))
// Speicher wieder verfügbar: der ON_STOP-Flush schreibt den bisher nicht gespeicherten Text.
failingDao.failure = null
onMain { viewModel.flushPendingChanges() }
awaitUntil { storedFreeText(item.checkItemStateId) == "Wichtiger Befund" }
}
@Test
fun flush_bei_weiterhin_vollem_speicher_verwirft_den_text_nicht() = runDbTest {
val failingDao = FailingCheckItemStateDao(env.db.checkItemStateDao())
.apply { failure = SQLiteFullException("database or disk is full") }
val viewModel = createViewModel(env.buildRepository(checkItemStateDao = failingDao))
val item = viewModel.freeTextItem()
onMain { viewModel.onFreeTextChanged(item.checkItemStateId, "Text") }
onMain { viewModel.flushPendingChanges() }
awaitCondition { viewModel.uiState.value.saveError != null }
onMain { viewModel.dismissSaveError() }
// Zweiter Flush nach erfolgreicher Freigabe von Speicher: der Text muss noch vorhanden sein.
failingDao.failure = null
onMain { viewModel.flushPendingChanges() }
awaitUntil { storedFreeText(item.checkItemStateId) == "Text" }
}
@Test
fun speicherfehler_beendet_den_freitext_autosave_nicht_dauerhaft() = runDbTest {
val failingDao = FailingCheckItemStateDao(env.db.checkItemStateDao())
.apply { failure = SQLiteFullException("database or disk is full") }
val viewModel = createViewModel(env.buildRepository(checkItemStateDao = failingDao))
val item = viewModel.freeTextItem()
onMain { viewModel.onFreeTextChanged(item.checkItemStateId, "Erster Versuch") }
awaitCondition { viewModel.uiState.value.saveError != null }
failingDao.failure = null
onMain { viewModel.onFreeTextChanged(item.checkItemStateId, "Zweiter Versuch") }
awaitUntil { storedFreeText(item.checkItemStateId) == "Zweiter Versuch" }
}
@Test
fun flush_ohne_ausstehende_aenderungen_tut_nichts() = runDbTest {
val viewModel = createViewModel()
onMain { viewModel.flushPendingChanges() }
assertNull(viewModel.uiState.value.saveError)
}
// --- Fotoimport ----------------------------------------------------------------------
@Test
fun foto_import_bei_defekten_bildern_meldet_fehler_und_beendet_den_ladezustand() = runDbTest {
val storage = FailingPhotoStorage().apply { importFailure = { IOException("Bild defekt (Test)") } }
val viewModel = createViewModel(env.buildRepository(photoStorage = storage))
onMain { viewModel.onPhotosPicked(listOf(someUri, someUri)) }
awaitCondition { viewModel.uiState.value.photoImportError != null && !viewModel.uiState.value.photoImportInProgress }
assertTrue(viewModel.uiState.value.photoImportError!!.startsWith("2 von 2"))
assertTrue(viewModel.uiState.value.documentation!!.photos.isEmpty())
}
@Test
fun foto_import_uebernimmt_gueltige_bilder_auch_wenn_ein_bild_fehlschlaegt() = runDbTest {
val storage = FailingPhotoStorage().apply { importFailure = { call -> if (call == 2) IOException("defekt") else null } }
val viewModel = createViewModel(env.buildRepository(photoStorage = storage))
onMain { viewModel.onPhotosPicked(listOf(someUri, someUri, someUri)) }
awaitCondition { viewModel.uiState.value.photoImportError != null && !viewModel.uiState.value.photoImportInProgress }
assertTrue(viewModel.uiState.value.photoImportError!!.startsWith("1 von 3"))
assertEquals(2, viewModel.uiState.value.documentation!!.photos.size)
assertEquals(2, env.db.photoDao().getForDocumentation(documentationId).size)
}
@Test
fun foto_import_bei_arbeitsspeichermangel_stuerzt_nicht_ab_und_nennt_den_grund() = runDbTest {
val storage = FailingPhotoStorage().apply { importFailure = { OutOfMemoryError("Test") } }
val viewModel = createViewModel(env.buildRepository(photoStorage = storage))
onMain { viewModel.onPhotosPicked(listOf(someUri)) }
awaitCondition { viewModel.uiState.value.photoImportError != null && !viewModel.uiState.value.photoImportInProgress }
assertTrue(viewModel.uiState.value.photoImportError!!.contains("Arbeitsspeicher"))
}
@Test
fun foto_import_bei_vollem_geraetespeicher_nennt_den_grund() = runDbTest {
val storage = FailingPhotoStorage().apply {
importFailure = { IOException("write failed: ENOSPC (No space left on device)") }
}
val viewModel = createViewModel(env.buildRepository(photoStorage = storage))
onMain { viewModel.onPhotosPicked(listOf(someUri)) }
awaitCondition { viewModel.uiState.value.photoImportError != null && !viewModel.uiState.value.photoImportInProgress }
assertTrue(viewModel.uiState.value.photoImportError!!.contains("Speicherplatz"))
}
@Test
fun kamera_aufraeumen_laeuft_auch_wenn_der_import_fehlschlaegt() = runDbTest {
val storage = FailingPhotoStorage().apply { importFailure = { IOException("defekt") } }
val viewModel = createViewModel(env.buildRepository(photoStorage = storage))
val cleanedUp = java.util.concurrent.atomic.AtomicBoolean(false)
onMain { viewModel.onCameraPhotoCaptured(someUri) { cleanedUp.set(true) } }
awaitCondition { cleanedUp.get() && !viewModel.uiState.value.photoImportInProgress }
assertTrue(viewModel.uiState.value.photoImportError != null)
}
@Test
fun leere_auswahl_ist_kein_fehler() = runDbTest {
val viewModel = createViewModel()
onMain { viewModel.onPhotosPicked(emptyList()) }
assertNull(viewModel.uiState.value.photoImportError)
assertFalse(viewModel.uiState.value.photoImportInProgress)
}
@Test
fun fehlende_kamera_app_wird_gemeldet() = runDbTest {
val viewModel = createViewModel()
onMain { viewModel.onCameraUnavailable() }
assertNotNull(viewModel.uiState.value.photoImportError)
}
// --- Signatur ------------------------------------------------------------------------
@Test
fun signatur_bei_dateifehler_zeigt_meldung_und_laesst_den_dialog_offen() = runDbTest {
val storage = FailingSignatureStorage().apply { saveFailure = IOException("defekt (Test)") }
val viewModel = createViewModel(env.buildRepository(signatureStorage = storage))
onMain { viewModel.requestAddSignature() }
onMain { viewModel.saveSignature("Pfleger Paul", signatureBitmap()) }
awaitCondition { viewModel.uiState.value.signatureError != null }
assertTrue(viewModel.uiState.value.showAddSignatureDialog)
assertTrue(viewModel.uiState.value.documentation!!.signatures.isEmpty())
}
@Test
fun signatur_bei_vollem_speicher_nennt_den_grund() = runDbTest {
val storage = FailingSignatureStorage().apply {
saveFailure = IOException("write failed: ENOSPC (No space left on device)")
}
val viewModel = createViewModel(env.buildRepository(signatureStorage = storage))
onMain { viewModel.saveSignature("Pfleger Paul", signatureBitmap()) }
awaitCondition { viewModel.uiState.value.signatureError != null }
assertTrue(viewModel.uiState.value.signatureError!!.contains("Speicherplatz"))
}
@Test
fun signatur_bei_arbeitsspeichermangel_stuerzt_nicht_ab() = runDbTest {
val storage = FailingSignatureStorage().apply { saveFailure = OutOfMemoryError("Test") }
val viewModel = createViewModel(env.buildRepository(signatureStorage = storage))
onMain { viewModel.saveSignature("Pfleger Paul", signatureBitmap()) }
awaitCondition { viewModel.uiState.value.signatureError != null }
assertTrue(viewModel.uiState.value.signatureError!!.contains("Arbeitsspeicher"))
}
@Test
fun leere_signatur_wird_mit_fachlicher_meldung_abgelehnt() = runDbTest {
val viewModel = createViewModel()
onMain { viewModel.saveSignature("Pfleger Paul", blankBitmap()) }
awaitCondition { viewModel.uiState.value.signatureError != null }
assertTrue(viewModel.uiState.value.documentation!!.signatures.isEmpty())
}
@Test
fun signatur_ohne_namen_wird_abgelehnt() = runDbTest {
val viewModel = createViewModel()
onMain { viewModel.saveSignature(" ", signatureBitmap()) }
awaitCondition { viewModel.uiState.value.signatureError != null }
assertTrue(viewModel.uiState.value.documentation!!.signatures.isEmpty())
}
// --- PDF-Export ----------------------------------------------------------------------
@Test
fun pdf_export_fehler_beendet_ladezustand_und_bietet_kein_anzeigen_an() = runDbTest {
env.pdfExporter.result = { PdfExportResult.Failure(PdfExportError.WriteFailed("x")) }
val viewModel = createViewModel()
onMain { viewModel.exportPdf() }
awaitCondition { viewModel.uiState.value.pdfExportMessage != null && !viewModel.uiState.value.pdfExportInProgress }
assertTrue(viewModel.uiState.value.pdfExportMessage!!.contains("fehlgeschlagen"))
assertNull(viewModel.uiState.value.lastExportedPdfFile)
}
@Test
fun pdf_export_mit_speichermangel_im_exporter_haengt_nicht_und_stuerzt_nicht_ab() = runDbTest {
val repository = env.buildRepository(pdfExporter = ThrowingPdfExporter(OutOfMemoryError("Test")))
val viewModel = createViewModel(repository)
onMain { viewModel.exportPdf() }
awaitCondition { viewModel.uiState.value.pdfExportMessage != null && !viewModel.uiState.value.pdfExportInProgress }
assertTrue(viewModel.uiState.value.pdfExportMessage!!.contains("Arbeitsspeicher"))
}
@Test
fun pdf_export_erfolg_stellt_die_datei_bereit() = runDbTest {
val viewModel = createViewModel()
onMain { viewModel.exportPdf() }
awaitCondition { viewModel.uiState.value.lastExportedPdfFile != null && !viewModel.uiState.value.pdfExportInProgress }
assertNotNull(viewModel.uiState.value.lastExportedPdfFile)
}
// --- Teilen --------------------------------------------------------------------------
@Test
fun teilen_als_pdf_bei_vollem_speicher_zeigt_meldung_und_teilt_nichts() = runDbTest {
env.pdfExporter.result = { PdfExportResult.Failure(PdfExportError.InsufficientStorage) }
val viewModel = createViewModel()
onMain { viewModel.shareAsPdf() }
awaitCondition { viewModel.uiState.value.shareMessage != null && !viewModel.uiState.value.shareInProgress }
assertTrue(viewModel.uiState.value.shareMessage!!.contains("Speicherplatz"))
assertNull(viewModel.uiState.value.pdfToShare)
}
@Test
fun teilen_als_bilder_bei_konvertierungsfehler_zeigt_meldung_und_teilt_nichts() = runDbTest {
env.pdfToImagesConverter.result = PdfToImagesResult.Failure.OutOfMemory
val viewModel = createViewModel()
onMain { viewModel.shareAsImages() }
awaitCondition { viewModel.uiState.value.shareMessage != null && !viewModel.uiState.value.shareInProgress }
assertTrue(viewModel.uiState.value.shareMessage!!.startsWith("Bildexport fehlgeschlagen"))
assertNull(viewModel.uiState.value.imagesToShare)
}
@Test
fun teilen_als_bilder_bei_export_fehler_ueberspringt_die_konvertierung() = runDbTest {
env.pdfExporter.result = { PdfExportResult.Failure(PdfExportError.OutOfMemory) }
val viewModel = createViewModel()
onMain { viewModel.shareAsImages() }
awaitCondition { viewModel.uiState.value.shareMessage != null && !viewModel.uiState.value.shareInProgress }
assertTrue(env.pdfToImagesConverter.calls.isEmpty())
assertNull(viewModel.uiState.value.imagesToShare)
}
@Test
fun teilen_als_pdf_erfolg_stellt_die_datei_bereit() = runDbTest {
val viewModel = createViewModel()
onMain { viewModel.shareAsPdf() }
awaitCondition { viewModel.uiState.value.pdfToShare != null && !viewModel.uiState.value.shareInProgress }
assertNull(viewModel.uiState.value.shareMessage)
assertTrue(viewModel.uiState.value.pdfToShare is File)
}
}
@@ -0,0 +1,151 @@
package de.medapp.wunddoku.ui.patients.form
import android.database.sqlite.SQLiteFullException
import androidx.test.ext.junit.runners.AndroidJUnit4
import de.medapp.wunddoku.data.repository.PatientRepository
import de.medapp.wunddoku.testutil.FailingDocumentationDao
import de.medapp.wunddoku.testutil.FailingPatientDao
import de.medapp.wunddoku.testutil.TestEnvironment
import de.medapp.wunddoku.testutil.awaitCondition
import de.medapp.wunddoku.testutil.buildRepository
import de.medapp.wunddoku.testutil.onMain
import de.medapp.wunddoku.testutil.runDbTest
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
/**
* Fehlerbehandlung beim Speichern im [PatientFormViewModel]: voller Speicher/Datenbankfehler
* dürfen weder abstürzen noch zu einer scheinbar erfolgreichen Navigation führen, und die
* Eingaben des Nutzers bleiben im Formular erhalten.
*/
@RunWith(AndroidJUnit4::class)
class PatientFormViewModelErrorHandlingTest {
private lateinit var env: TestEnvironment
@Before
fun setUp() {
env = TestEnvironment()
}
@After
fun tearDown() {
env.close()
}
private fun fill(viewModel: PatientFormViewModel) = onMain {
viewModel.onFirstNameChanged("Max")
viewModel.onLastNameChanged("Mustermann")
viewModel.onBirthDateDigitsChanged("14031980")
viewModel.onStreetChanged("Musterstraße 1")
viewModel.onPostalCodeChanged("12345")
viewModel.onCityChanged("Musterstadt")
viewModel.onPhone1Changed("0123456789")
}
private suspend fun createViewModel(
patientRepository: PatientRepository,
startsNewDocumentation: Boolean,
documentationDao: FailingDocumentationDao? = null,
): PatientFormViewModel {
val documentationRepository = if (documentationDao != null) {
env.buildRepository(documentationDao = documentationDao)
} else {
env.documentationRepository
}
val viewModel = onMain {
PatientFormViewModel(
patientId = null,
startsNewDocumentation = startsNewDocumentation,
repository = patientRepository,
documentationRepository = documentationRepository,
)
}
awaitCondition { !viewModel.uiState.value.isLoading }
return viewModel
}
@Test
fun speicherfehler_beim_anlegen_des_patienten_zeigt_meldung_und_behaelt_die_eingaben() = runDbTest {
val failingDao = FailingPatientDao(env.db.patientDao())
.apply { insertFailure = SQLiteFullException("database or disk is full") }
val viewModel = createViewModel(PatientRepository(failingDao), startsNewDocumentation = true)
fill(viewModel)
onMain { viewModel.save() }
awaitCondition { viewModel.uiState.value.validationError != null }
val state = viewModel.uiState.value
assertTrue(state.validationError!!.contains("Speicherplatz"))
assertFalse(state.patientSaved)
assertNull(state.createdDocumentationId)
assertEquals("Max", state.firstName)
assertEquals("Mustermann", state.lastName)
}
@Test
fun sonstiger_datenbankfehler_beim_speichern_stuerzt_nicht_ab() = runDbTest {
val failingDao = FailingPatientDao(env.db.patientDao())
.apply { insertFailure = IllegalStateException("Test") }
val viewModel = createViewModel(PatientRepository(failingDao), startsNewDocumentation = false)
fill(viewModel)
onMain { viewModel.save() }
awaitCondition { viewModel.uiState.value.validationError != null }
assertFalse(viewModel.uiState.value.patientSaved)
}
@Test
fun fehler_beim_anlegen_der_dokumentation_meldet_fehler_ohne_navigation() = runDbTest {
val failingDocDao = FailingDocumentationDao(env.db.documentationDao())
.apply { loadFailure = SQLiteFullException("database or disk is full") }
val viewModel = createViewModel(
env.patientRepository,
startsNewDocumentation = true,
documentationDao = failingDocDao,
)
fill(viewModel)
onMain { viewModel.save() }
awaitCondition { viewModel.uiState.value.validationError != null }
assertNull("keine Navigation zu einer nicht angelegten Dokumentation", viewModel.uiState.value.createdDocumentationId)
assertNotNull(viewModel.uiState.value.validationError)
// Die Dokumentation wurde per Transaktion zurückgerollt: es gibt keine halbe Dokumentation.
assertEquals(0, env.db.documentationDao().countForPatient(env.db.patientDao().findExisting("Max", "Mustermann", "1980-03-14")!!.id))
}
@Test
fun speichern_funktioniert_nach_einem_fehler_erneut() = runDbTest {
val failingDao = FailingPatientDao(env.db.patientDao())
.apply { insertFailure = IllegalStateException("Test") }
val viewModel = createViewModel(PatientRepository(failingDao), startsNewDocumentation = false)
fill(viewModel)
onMain { viewModel.save() }
awaitCondition { viewModel.uiState.value.validationError != null }
failingDao.insertFailure = null
onMain { viewModel.save() }
awaitCondition { viewModel.uiState.value.patientSaved }
assertNull(viewModel.uiState.value.validationError)
}
@Test
fun ungueltige_eingaben_werden_weiterhin_fachlich_abgelehnt() = runDbTest {
val viewModel = createViewModel(env.patientRepository, startsNewDocumentation = false)
onMain { viewModel.save() }
assertNotNull(viewModel.uiState.value.validationError)
assertFalse(viewModel.uiState.value.patientSaved)
}
}
@@ -18,6 +18,8 @@ import de.medapp.wunddoku.data.entity.SignatureEntity
import de.medapp.wunddoku.data.mapper.toDomain import de.medapp.wunddoku.data.mapper.toDomain
import de.medapp.wunddoku.data.mapper.toSummary import de.medapp.wunddoku.data.mapper.toSummary
import de.medapp.wunddoku.domain.catalog.DocumentationCatalog import de.medapp.wunddoku.domain.catalog.DocumentationCatalog
import de.medapp.wunddoku.domain.error.FailureCategory
import de.medapp.wunddoku.domain.error.runCatchingCancellable
import de.medapp.wunddoku.domain.model.Documentation import de.medapp.wunddoku.domain.model.Documentation
import de.medapp.wunddoku.domain.model.DocumentationStatus import de.medapp.wunddoku.domain.model.DocumentationStatus
import de.medapp.wunddoku.domain.model.DocumentationSummary import de.medapp.wunddoku.domain.model.DocumentationSummary
@@ -34,6 +36,7 @@ import de.medapp.wunddoku.pdf.PdfToImagesConverter
import de.medapp.wunddoku.pdf.PdfToImagesResult import de.medapp.wunddoku.pdf.PdfToImagesResult
import de.medapp.wunddoku.signature.SignatureBitmapValidation import de.medapp.wunddoku.signature.SignatureBitmapValidation
import de.medapp.wunddoku.signature.SignatureStorage import de.medapp.wunddoku.signature.SignatureStorage
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import java.io.File import java.io.File
@@ -201,6 +204,7 @@ class DocumentationRepository(
// Unterschriften werden bewusst NICHT kopiert (siehe Doc-Kommentar oben). // Unterschriften werden bewusst NICHT kopiert (siehe Doc-Kommentar oben).
if (copyPhotos) { if (copyPhotos) {
try {
for (sourcePhoto in source.photos) { for (sourcePhoto in source.photos) {
val copiedFileName = photoStorage.duplicate( val copiedFileName = photoStorage.duplicate(
sourceDocumentationId = source.documentation.id, sourceDocumentationId = source.documentation.id,
@@ -217,6 +221,13 @@ class DocumentationRepository(
) )
) )
} }
} catch (e: Throwable) {
// Die Room-Transaktion rollt die DB-Zeilen zurück - bereits kopierte Fotodateien
// der nie entstandenen Dokumentation würden aber verwaist im Speicher bleiben
// (bei Speichermangel besonders ärgerlich). Aufräumen, dann Fehler weiterreichen.
runCatching { photoStorage.deleteAll(newDocumentationId) }
throw e
}
} }
documentationDao.getFullDocumentation(newDocumentationId)!!.toDomain() documentationDao.getFullDocumentation(newDocumentationId)!!.toDomain()
@@ -250,16 +261,22 @@ class DocumentationRepository(
suspend fun addPhoto(documentationId: String, sourceUri: Uri): Photo { suspend fun addPhoto(documentationId: String, sourceUri: Uri): Photo {
val fileName = photoStorage.importFrom(documentationId, sourceUri) val fileName = photoStorage.importFrom(documentationId, sourceUri)
val nextOrder = photoDao.getMaxOrderIndex(documentationId) + 1
val now = Instant.now() val now = Instant.now()
val entity = PhotoEntity( val entity = try {
val nextOrder = photoDao.getMaxOrderIndex(documentationId) + 1
PhotoEntity(
id = UUID.randomUUID().toString(), id = UUID.randomUUID().toString(),
documentationId = documentationId, documentationId = documentationId,
fileName = fileName, fileName = fileName,
orderIndex = nextOrder, orderIndex = nextOrder,
createdAt = now.toEpochMilli(), createdAt = now.toEpochMilli(),
) ).also { photoDao.insert(it) }
photoDao.insert(entity) } catch (e: Throwable) {
// DB-Eintrag fehlgeschlagen (z. B. Datenbank voll): die gerade importierte Datei
// wäre sonst eine verwaiste Datei ohne Referenz.
runCatching { photoStorage.delete(documentationId, fileName) }
throw e
}
return Photo(entity.id, entity.documentationId, entity.fileName, entity.orderIndex, now) return Photo(entity.id, entity.documentationId, entity.fileName, entity.orderIndex, now)
} }
@@ -284,10 +301,22 @@ class DocumentationRepository(
* verwendet) und delegiert die eigentliche Erzeugung an [pdfExporter]. * verwendet) und delegiert die eigentliche Erzeugung an [pdfExporter].
*/ */
suspend fun exportPdf(documentationId: String): PdfExportResult { suspend fun exportPdf(documentationId: String): PdfExportResult {
return try {
val documentation = getFullDocumentation(documentationId) val documentation = getFullDocumentation(documentationId)
?: return PdfExportResult.Failure(PdfExportError.DocumentationNotFound) ?: return PdfExportResult.Failure(PdfExportError.DocumentationNotFound)
val model = DocumentationToPdfExportModelMapper.map(documentation) val model = DocumentationToPdfExportModelMapper.map(documentation)
return pdfExporter.export(model) pdfExporter.export(model)
} catch (e: CancellationException) {
throw e
} catch (e: Throwable) {
// Vertrag: der Export wirft nie - weder bei Datenbankfehlern noch bei Speichermangel.
// Technische Fehlertexte werden bewusst nicht weitergereicht (Datenschutz).
when (FailureCategory.of(e)) {
FailureCategory.OUT_OF_MEMORY -> PdfExportResult.Failure(PdfExportError.OutOfMemory)
FailureCategory.OUT_OF_STORAGE -> PdfExportResult.Failure(PdfExportError.InsufficientStorage)
FailureCategory.OTHER -> PdfExportResult.Failure(PdfExportError.Unexpected(e::class.java.simpleName))
}
}
} }
/** /**
@@ -298,7 +327,17 @@ class DocumentationRepository(
* erneutes Laden wäre unnötige Doppelarbeit. * erneutes Laden wäre unnötige Doppelarbeit.
*/ */
suspend fun convertPdfToImages(documentationId: String, pdfFile: File): PdfToImagesResult = suspend fun convertPdfToImages(documentationId: String, pdfFile: File): PdfToImagesResult =
try {
pdfToImagesConverter.convert(documentationId, pdfFile) pdfToImagesConverter.convert(documentationId, pdfFile)
} catch (e: CancellationException) {
throw e
} catch (e: Throwable) {
when (FailureCategory.of(e)) {
FailureCategory.OUT_OF_MEMORY -> PdfToImagesResult.Failure.OutOfMemory
FailureCategory.OUT_OF_STORAGE -> PdfToImagesResult.Failure.InsufficientStorage
FailureCategory.OTHER -> PdfToImagesResult.Failure.Unexpected(e::class.java.simpleName)
}
}
/** /**
* Liefert eine speicherschonende Vorschauversion eines Fotos für die Anzeige im Fotogitter * Liefert eine speicherschonende Vorschauversion eines Fotos für die Anzeige im Fotogitter
@@ -352,18 +391,24 @@ class DocumentationRepository(
require(!SignatureBitmapValidation.isBlank(bitmap)) { "Eine leere Signatur kann nicht gespeichert werden." } require(!SignatureBitmapValidation.isBlank(bitmap)) { "Eine leere Signatur kann nicht gespeichert werden." }
val fileName = signatureStorage.save(documentationId, bitmap) val fileName = signatureStorage.save(documentationId, bitmap)
val nextOrderIndex = signatureDao.getMaxOrderIndex(documentationId) + 1
val now = Instant.now() val now = Instant.now()
val entity = SignatureEntity( val entity = try {
SignatureEntity(
id = UUID.randomUUID().toString(), id = UUID.randomUUID().toString(),
documentationId = documentationId, documentationId = documentationId,
signerName = trimmedName, signerName = trimmedName,
fileName = fileName, fileName = fileName,
orderIndex = nextOrderIndex, orderIndex = signatureDao.getMaxOrderIndex(documentationId) + 1,
createdAt = now.toEpochMilli(), createdAt = now.toEpochMilli(),
) ).also { signatureDao.insert(it) }
signatureDao.insert(entity) } catch (e: Throwable) {
woundCarerRepository.recordSignatureUsage(trimmedName, bitmap) runCatching { signatureStorage.delete(documentationId, fileName) }
throw e
}
// Das Wundversorger-Register ist nur eine Komfortfunktion (Namensvorschläge). Scheitert
// es, ist die Signatur der Dokumentation trotzdem bereits sicher gespeichert - der
// Nutzer darf dann keine Fehlermeldung bekommen, die ihn die Signatur erneut erfassen lässt.
runCatchingCancellable { woundCarerRepository.recordSignatureUsage(trimmedName, bitmap) }
return Signature(entity.id, entity.documentationId, entity.signerName, entity.fileName, entity.orderIndex, now) return Signature(entity.id, entity.documentationId, entity.signerName, entity.fileName, entity.orderIndex, now)
} }
@@ -28,11 +28,11 @@ import de.medapp.wunddoku.domain.model.InputSpec
* Abschnitt 2.3b. * Abschnitt 2.3b.
* *
* [CATALOG_VERSION] wird beim Erstellen einer neuen Dokumentation als rein informatives * [CATALOG_VERSION] wird beim Erstellen einer neuen Dokumentation als rein informatives
* Metadatum in Documentation.catalogVersion übernommen. Auf 4 erhöht mit diesem Umbau. * Metadatum in Documentation.catalogVersion übernommen. Auf 5 erhöht (Decubitus als einfache Checkbox, Umbau "Empfohlene Wundversorgung").
*/ */
object DocumentationCatalog { object DocumentationCatalog {
const val CATALOG_VERSION: Int = 4 const val CATALOG_VERSION: Int = 5
/** /**
* Label der automatisch an jeden Block angehängten Freitext-Option. Zentral hier definiert, * Label der automatisch an jeden Block angehängten Freitext-Option. Zentral hier definiert,
@@ -50,7 +50,6 @@ object DocumentationCatalog {
"0,5 cm", "1 cm", "1,5 cm", "2 cm", "2,5 cm", "3 cm", "3,5 cm", "4 cm", "0,5 cm", "1 cm", "1,5 cm", "2 cm", "2,5 cm", "3 cm", "3,5 cm", "4 cm",
"4,5 cm", "5 cm", "5,5 cm", "6 cm", "6,5 cm", "7 cm", "7,5 cm", "8 cm", ">8 cm", "4,5 cm", "5 cm", "5,5 cm", "6 cm", "6,5 cm", "7 cm", "7,5 cm", "8 cm", ">8 cm",
) )
private val DECUBITUS_GRADE_OPTIONS: List<String> = listOf("I", "II", "III", "IV")
val blocks: List<BlockDefinition> = listOf( val blocks: List<BlockDefinition> = listOf(
BlockDefinition( BlockDefinition(
@@ -60,15 +59,7 @@ object DocumentationCatalog {
items = listOf( items = listOf(
CheckItemDefinition(id = "wundart_nicht_bekannt_diagnostiziert", label = "Noch nicht diagnostiziert / nicht bekannt", order = 1), CheckItemDefinition(id = "wundart_nicht_bekannt_diagnostiziert", label = "Noch nicht diagnostiziert / nicht bekannt", order = 1),
CheckItemDefinition(id = "wundart_erscheint_wie", label = "Erscheint wie", order = 2, isFreeText = true), CheckItemDefinition(id = "wundart_erscheint_wie", label = "Erscheint wie", order = 2, isFreeText = true),
CheckItemDefinition( CheckItemDefinition(id = "wundart_decubitus", label = "Decubitus", order = 3),
id = "wundart_decubitus",
label = "Decubitus",
order = 3,
inputSpec = InputSpec(
pdfTemplate = "Decubitus Grad {0}",
fields = listOf(InputFieldDef.Choice(label = "Grad", options = DECUBITUS_GRADE_OPTIONS)),
),
),
CheckItemDefinition(id = "wundart_ulcus_cruris_unbezeichnet", label = "Ulcus Cruris (nicht näher bezeichnet)", order = 4), CheckItemDefinition(id = "wundart_ulcus_cruris_unbezeichnet", label = "Ulcus Cruris (nicht näher bezeichnet)", order = 4),
CheckItemDefinition(id = "wundart_ulcus_cruris_venosum", label = "Ulcus Cruris ven.", order = 5), CheckItemDefinition(id = "wundart_ulcus_cruris_venosum", label = "Ulcus Cruris ven.", order = 5),
CheckItemDefinition(id = "wundart_ulcus_cruris_arteriosum", label = "Ulcus Cruris art.", order = 6), CheckItemDefinition(id = "wundart_ulcus_cruris_arteriosum", label = "Ulcus Cruris art.", order = 6),
@@ -507,7 +498,9 @@ object DocumentationCatalog {
CheckItemDefinition(id = "empfohlene_wundversorgung_sterile_kompresse", label = "Sterile Kompresse", order = 2), CheckItemDefinition(id = "empfohlene_wundversorgung_sterile_kompresse", label = "Sterile Kompresse", order = 2),
CheckItemDefinition(id = "empfohlene_wundversorgung_nacl", label = "NaCl 0,9%", order = 3), CheckItemDefinition(id = "empfohlene_wundversorgung_nacl", label = "NaCl 0,9%", order = 3),
CheckItemDefinition(id = "empfohlene_wundversorgung_octenisept", label = "Octenisept", order = 4), CheckItemDefinition(id = "empfohlene_wundversorgung_octenisept", label = "Octenisept", order = 4),
CheckItemDefinition(id = "empfohlene_wundversorgung_granudacyn", label = "Granudacyn", order = 5), // Granudacyn: Checkbox + direkt folgendes Freitextfeld, das nur bei angehakter
// Checkbox aktiv ist (= vorhandenes isFreeText-Verhalten, s. FreeTextCheckItemRow).
CheckItemDefinition(id = "empfohlene_wundversorgung_granudacyn", label = "Granudacyn", order = 5, isFreeText = true),
CheckItemDefinition( CheckItemDefinition(
id = "empfohlene_wundversorgung_getraenkte_kompresse", id = "empfohlene_wundversorgung_getraenkte_kompresse",
label = "Getränkte Kompresse", label = "Getränkte Kompresse",
@@ -523,36 +516,43 @@ object DocumentationCatalog {
CheckItemDefinition(id = "empfohlene_wundversorgung_umgebung_trocken_tupfen", label = "Umgebung trocken tupfen", order = 10), CheckItemDefinition(id = "empfohlene_wundversorgung_umgebung_trocken_tupfen", label = "Umgebung trocken tupfen", order = 10),
CheckItemDefinition(id = "empfohlene_wundversorgung_primaer_abdecken_austamponieren", label = "Primär Wunde abdecken/austamponieren", order = 11), CheckItemDefinition(id = "empfohlene_wundversorgung_primaer_abdecken_austamponieren", label = "Primär Wunde abdecken/austamponieren", order = 11),
CheckItemDefinition(id = "empfohlene_wundversorgung_ueber_wundrand_hinaus", label = "Über Wundrand hinaus", order = 12), CheckItemDefinition(id = "empfohlene_wundversorgung_ueber_wundrand_hinaus", label = "Über Wundrand hinaus", order = 12),
CheckItemDefinition(id = "empfohlene_wundversorgung_nicht_ueber_wundrand_mit", label = "Nicht über Wundrand mit", order = 13, isFreeText = true), CheckItemDefinition(id = "empfohlene_wundversorgung_nicht_ueber_wundrand_mit", label = "Nicht über Wundrand mit", order = 13),
CheckItemDefinition(id = "empfohlene_wundversorgung_hinweis_wundrandschutz_mit", label = "Wundrandschutz mit (bis 3x Wo.):", order = 14, isStaticHint = true), CheckItemDefinition(id = "empfohlene_wundversorgung_nicht_ueber_wundrand_mit_freitext", label = FREE_TEXT_LABEL, order = 14, isFreeText = true),
CheckItemDefinition(id = "empfohlene_wundversorgung_wundrandschutz_ja", label = "Ja", order = 15), CheckItemDefinition(id = "empfohlene_wundversorgung_hinweis_wundrandschutz", label = "Wundrandschutz:", order = 15, isStaticHint = true),
CheckItemDefinition(id = "empfohlene_wundversorgung_wundrandschutz_nein", label = "Nein", order = 16), CheckItemDefinition(id = "empfohlene_wundversorgung_wundrandschutz_ja", label = "Ja", order = 16),
CheckItemDefinition(id = "empfohlene_wundversorgung_wundrandschutz_nein", label = "Nein", order = 17),
CheckItemDefinition(id = "empfohlene_wundversorgung_wundrandschutz_freitext", label = FREE_TEXT_LABEL, order = 18, isFreeText = true),
// "Wie oft pro Woche": eigene Checkbox; die Auswahl (1-7x) ist erst nach dem
// Anhaken aktiv (InputSpec.requiresOwnCheckbox).
CheckItemDefinition( CheckItemDefinition(
id = "empfohlene_wundversorgung_wundrandschutz_tage_pro_woche", id = "empfohlene_wundversorgung_wundrandschutz_tage_pro_woche",
label = "Wie oft pro Woche?", label = "Wie oft pro Woche",
order = 17, order = 19,
inputSpec = InputSpec( inputSpec = InputSpec(
pdfTemplate = "{0}x pro Woche", pdfTemplate = "{0}x pro Woche",
fields = listOf(InputFieldDef.Choice(label = "Tage pro Woche", options = WEEKDAY_COUNT_OPTIONS)), fields = listOf(InputFieldDef.Choice(label = "Tage pro Woche", options = WEEKDAY_COUNT_OPTIONS)),
requiresOwnCheckbox = true,
), ),
), ),
CheckItemDefinition(id = "empfohlene_wundversorgung_hinweis_sekundaere_wundauflage", label = "Sekundäre Wundauflage:", order = 18, isStaticHint = true), CheckItemDefinition(id = "empfohlene_wundversorgung_hinweis_sekundaere_wundauflage", label = "Sekundäre Wundauflage:", order = 20, isStaticHint = true),
CheckItemDefinition(id = "empfohlene_wundversorgung_sekundaer_sterile_kompresse", label = "Sterile Kompresse", order = 19), CheckItemDefinition(id = "empfohlene_wundversorgung_sekundaer_sterile_kompresse", label = "Sterile Kompresse", order = 21),
CheckItemDefinition(id = "empfohlene_wundversorgung_saugkompresse", label = "Saugkompresse", order = 20), CheckItemDefinition(id = "empfohlene_wundversorgung_saugkompresse", label = "Saugkompresse", order = 22),
CheckItemDefinition(id = "empfohlene_wundversorgung_sonstige_wundauflage", label = "Sonstiges (Wundauflage)", order = 21, isFreeText = true), CheckItemDefinition(id = "empfohlene_wundversorgung_sonstige_wundauflage", label = "Sonstiges (Wundauflage)", order = 23, isFreeText = true),
CheckItemDefinition(id = "empfohlene_wundversorgung_hinweis_fixieren_mit", label = "Fixieren mit:", order = 22, isStaticHint = true), CheckItemDefinition(id = "empfohlene_wundversorgung_hinweis_fixieren_mit", label = "Fixieren mit:", order = 24, isStaticHint = true),
CheckItemDefinition(id = "empfohlene_wundversorgung_mullbinde", label = "Mullbinde", order = 23), CheckItemDefinition(id = "empfohlene_wundversorgung_mullbinde", label = "Mullbinde", order = 25),
CheckItemDefinition(id = "empfohlene_wundversorgung_klebevlies", label = "Klebevlies", order = 24), CheckItemDefinition(id = "empfohlene_wundversorgung_klebevlies", label = "Klebevlies", order = 26),
CheckItemDefinition(id = "empfohlene_wundversorgung_folienverband", label = "Folienverband", order = 25), CheckItemDefinition(id = "empfohlene_wundversorgung_folienverband", label = "Folienverband", order = 27),
CheckItemDefinition(id = "empfohlene_wundversorgung_schlauchverband", label = "Schlauchverband", order = 26), CheckItemDefinition(id = "empfohlene_wundversorgung_schlauchverband", label = "Schlauchverband", order = 28),
CheckItemDefinition(id = "empfohlene_wundversorgung_kohaesive_fixierbinde", label = "Kohäsive Fixierbinde", order = 27, isFreeText = true), CheckItemDefinition(id = "empfohlene_wundversorgung_kohaesive_fixierbinde", label = "Kohäsive Fixierbinde", order = 29),
CheckItemDefinition(id = "empfohlene_wundversorgung_kompressionsverband_system", label = "Kompressionsverband / System", order = 28), CheckItemDefinition(id = "empfohlene_wundversorgung_kohaesive_fixierbinde_freitext", label = FREE_TEXT_LABEL, order = 30, isFreeText = true),
CheckItemDefinition(id = "empfohlene_wundversorgung_wechselintervall", label = "Wechselintervall", order = 29, isFreeText = true), CheckItemDefinition(id = "empfohlene_wundversorgung_kompressionsverband_system", label = "Kompressionsverband / System", order = 31),
CheckItemDefinition(id = "empfohlene_wundversorgung_hinweis_bedarf", label = "+ bei Bedarf", order = 30, isStaticHint = true), CheckItemDefinition(id = "empfohlene_wundversorgung_kompressionsverband_system_freitext", label = FREE_TEXT_LABEL, order = 32, isFreeText = true),
CheckItemDefinition(id = "empfohlene_wundversorgung_wechselintervall", label = "Wechselintervall", order = 33, isFreeText = true),
CheckItemDefinition(id = "empfohlene_wundversorgung_hinweis_bedarf", label = "+ bei Bedarf", order = 34, isStaticHint = true),
CheckItemDefinition( CheckItemDefinition(
id = "empfohlene_wundversorgung_hinweis_abklaerung", id = "empfohlene_wundversorgung_hinweis_abklaerung",
label = "Generell: bei akuter Verschlechterung sofort ärztliche Abklärung", label = "Generell: bei akuter Verschlechterung sofort ärztliche Abklärung",
order = 31, order = 35,
isStaticHint = true, isStaticHint = true,
), ),
), ),
@@ -0,0 +1,59 @@
package de.medapp.wunddoku.domain.error
import kotlinx.coroutines.CancellationException
/**
* Grobe Einordnung eines Fehlers für die Anzeige und Weiterbehandlung.
*
* Bewusst rein in Kotlin/JVM gehalten (keine Android-Klassen), damit die Einordnung per
* einfachem JVM-Unit-Test prüfbar ist. Android-spezifische Typen (z. B. `SQLiteFullException`)
* werden deshalb über Klassenname bzw. Meldungstext erkannt.
*/
enum class FailureCategory {
/** Arbeitsspeicher erschöpft ([OutOfMemoryError]). */
OUT_OF_MEMORY,
/** Geräte-/Datenbankspeicher voll (ENOSPC, `SQLiteFullException`, "disk is full"). */
OUT_OF_STORAGE,
/** Alles andere. */
OTHER,
;
companion object {
private const val SQLITE_FULL_EXCEPTION = "android.database.sqlite.SQLiteFullException"
private val STORAGE_FULL_MARKERS = listOf(
"no space left on device",
"enospc",
"database or disk is full",
"disk full",
"sqlite_full",
)
/** Durchsucht [throwable] samt gesamter Ursachenkette. */
fun of(throwable: Throwable): FailureCategory {
val seen = HashSet<Throwable>()
var current: Throwable? = throwable
while (current != null && seen.add(current)) {
if (current is OutOfMemoryError) return OUT_OF_MEMORY
if (current.javaClass.name == SQLITE_FULL_EXCEPTION) return OUT_OF_STORAGE
val message = current.message?.lowercase()
if (message != null && STORAGE_FULL_MARKERS.any { message.contains(it) }) return OUT_OF_STORAGE
current = current.cause
}
return OTHER
}
}
}
/**
* Wie [runCatching], reicht aber eine Coroutine-Abbruch-Exception immer weiter - ein
* abgebrochener Vorgang darf nicht als "Fehler" verschluckt werden.
*/
inline fun <T> runCatchingCancellable(block: () -> T): Result<T> = try {
Result.success(block())
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Result.failure(e)
}
@@ -46,6 +46,14 @@ sealed interface InputFieldDef {
data class InputSpec( data class InputSpec(
val pdfTemplate: String, val pdfTemplate: String,
val fields: List<InputFieldDef>, val fields: List<InputFieldDef>,
/**
* `true`: der Eintrag hat zusätzlich eine EIGENE Checkbox mit dem Label des Eintrags (z. B.
* "Wie oft pro Woche"). Die Eingabefelder sind dann nur aktiv, solange diese Checkbox
* angehakt ist; das Häkchen wird NICHT mehr automatisch aus den Feldwerten abgeleitet,
* sondern ausschließlich vom Nutzer gesetzt. Standard `false` = bisheriges Verhalten
* (Felder immer aktiv, Häkchen automatisch bei mindestens einem Wert).
*/
val requiresOwnCheckbox: Boolean = false,
) { ) {
/** Zerlegt den gespeicherten Gesamtwert (s. [encodeValues]) in genau [fields].size Einzelwerte. */ /** Zerlegt den gespeicherten Gesamtwert (s. [encodeValues]) in genau [fields].size Einzelwerte. */
@@ -86,7 +94,7 @@ data class InputSpec(
* prüfbar) und ohne zusätzliche Abhängigkeit. Die verwendeten Trennzeichen kommen in Labels, * prüfbar) und ohne zusätzliche Abhängigkeit. Die verwendeten Trennzeichen kommen in Labels,
* Textvorlagen und Auswahlwerten nicht vor. * Textvorlagen und Auswahlwerten nicht vor.
* *
* Aufbau: `pdfTemplate ␞ feld1 ␞ feld2 ...`, jedes Feld `N␟label␟maxLength␟decimal` bzw. * Aufbau: `pdfTemplate ␞ feld1 ␞ feld2 ... [␞ B]`, jedes Feld `N␟label␟maxLength␟decimal` bzw.
* `C␟label␟option␝option␝...`. * `C␟label␟option␝option␝...`.
*/ */
object InputSpecCodec { object InputSpecCodec {
@@ -94,6 +102,11 @@ object InputSpecCodec {
private const val UNIT_SEPARATOR = '\u001F' private const val UNIT_SEPARATOR = '\u001F'
private const val GROUP_SEPARATOR = '\u001D' private const val GROUP_SEPARATOR = '\u001D'
/** Optionaler Abschluss-Datensatz "eigene Checkbox" (s. [InputSpec.requiresOwnCheckbox]).
* Ältere Decoder ignorieren unbekannte Datensätze, bereits gespeicherte Snapshots ohne
* diesen Datensatz werden mit `requiresOwnCheckbox = false` gelesen. */
private const val OWN_CHECKBOX_RECORD = "B"
fun encode(spec: InputSpec): String { fun encode(spec: InputSpec): String {
val records = mutableListOf(spec.pdfTemplate) val records = mutableListOf(spec.pdfTemplate)
for (field in spec.fields) { for (field in spec.fields) {
@@ -112,6 +125,7 @@ object InputSpecCodec {
).joinToString(UNIT_SEPARATOR.toString()) ).joinToString(UNIT_SEPARATOR.toString())
} }
} }
if (spec.requiresOwnCheckbox) records += OWN_CHECKBOX_RECORD
return records.joinToString(RECORD_SEPARATOR.toString()) return records.joinToString(RECORD_SEPARATOR.toString())
} }
@@ -150,6 +164,10 @@ object InputSpecCodec {
} }
} }
if (fields.isEmpty()) return null if (fields.isEmpty()) return null
return InputSpec(pdfTemplate = records.first(), fields = fields) return InputSpec(
pdfTemplate = records.first(),
fields = fields,
requiresOwnCheckbox = records.drop(1).any { it == OWN_CHECKBOX_RECORD },
)
} }
} }
@@ -172,21 +172,36 @@ class InternalPhotoStorage(private val context: Context) : PhotoStorage {
}.getOrDefault(ExifInterface.ORIENTATION_NORMAL) }.getOrDefault(ExifInterface.ORIENTATION_NORMAL)
} ?: ExifInterface.ORIENTATION_NORMAL } ?: ExifInterface.ORIENTATION_NORMAL
val corrected = ImageDownsampler.applyExifOrientation(decoded, orientation) val corrected = try {
ImageDownsampler.applyExifOrientation(decoded, orientation)
} catch (e: Throwable) {
decoded.recycle()
throw e
}
// 4) Als neue, eigenständige JPEG-Datei im dokumentationsspezifischen Verzeichnis // 4) Als neue, eigenständige JPEG-Datei im dokumentationsspezifischen Verzeichnis
// speichern. Erneutes Kodieren als JPEG verwirft dabei nebenbei die ursprünglichen // speichern. Erneutes Kodieren als JPEG verwirft dabei nebenbei die ursprünglichen
// EXIF-Metadaten (z. B. GPS-Position) des Originalfotos, statt sie 1:1 zu // EXIF-Metadaten (z. B. GPS-Position) des Originalfotos.
// übernehmen - für sensible medizinische Dokumentationsfotos ein willkommener //
// Nebeneffekt, keine bewusste Anforderung dieser Aufgabe. // Fehlerbehandlung: Bitmap.compress() liefert bei einem Schreibfehler (z. B. Speicher
// voll) nur `false` statt zu werfen - das wird hier zu einer Exception, und eine
// halb geschriebene Datei wird in jedem Fehlerfall wieder entfernt, damit nie eine
// defekte Bilddatei im Dokumentationsordner liegen bleibt.
val targetDir = ensureDocumentationDir(documentationId) val targetDir = ensureDocumentationDir(documentationId)
val fileName = "${UUID.randomUUID()}.jpg" val fileName = "${UUID.randomUUID()}.jpg"
File(targetDir, fileName).outputStream().use { out -> val targetFile = File(targetDir, fileName)
try {
val written = targetFile.outputStream().use { out ->
corrected.compress(Bitmap.CompressFormat.JPEG, ImageDownsampler.JPEG_QUALITY, out) corrected.compress(Bitmap.CompressFormat.JPEG, ImageDownsampler.JPEG_QUALITY, out)
} }
if (!written) throw java.io.IOException("Bild konnte nicht gespeichert werden.")
} catch (e: Throwable) {
targetFile.delete()
throw e
} finally {
if (corrected !== decoded) decoded.recycle() if (corrected !== decoded) decoded.recycle()
corrected.recycle() corrected.recycle()
}
fileName fileName
} }
@@ -199,7 +214,13 @@ class InternalPhotoStorage(private val context: Context) : PhotoStorage {
val sourceFile = resolve(sourceDocumentationId, sourceFileName) val sourceFile = resolve(sourceDocumentationId, sourceFileName)
val targetFileName = "${UUID.randomUUID()}.jpg" val targetFileName = "${UUID.randomUUID()}.jpg"
val targetFile = File(ensureDocumentationDir(targetDocumentationId), targetFileName) val targetFile = File(ensureDocumentationDir(targetDocumentationId), targetFileName)
try {
sourceFile.copyTo(targetFile, overwrite = false) sourceFile.copyTo(targetFile, overwrite = false)
} catch (e: Throwable) {
// Keine halb kopierte Datei zurücklassen.
targetFile.delete()
throw e
}
targetFileName targetFileName
} }
@@ -4,8 +4,10 @@ import android.content.Context
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.Color import android.graphics.Color
import android.graphics.Paint import android.graphics.Paint
import android.graphics.Typeface
import android.graphics.pdf.PdfDocument import android.graphics.pdf.PdfDocument
import android.text.TextPaint import android.text.TextPaint
import de.medapp.wunddoku.domain.error.FailureCategory
import de.medapp.wunddoku.domain.model.InputSpecCodec import de.medapp.wunddoku.domain.model.InputSpecCodec
import de.medapp.wunddoku.media.PhotoStorage import de.medapp.wunddoku.media.PhotoStorage
import de.medapp.wunddoku.signature.SignatureStorage import de.medapp.wunddoku.signature.SignatureStorage
@@ -99,6 +101,14 @@ class AndroidPdfDocumentExporter(
*/ */
private const val PHOTO_TARGET_DPI = 150 private const val PHOTO_TARGET_DPI = 150
/**
* Änderungswunsch: Bilder im Fotoanhang werden auf 50 % ihrer bisherigen Größe
* ausgegeben (Breite und Höhe je halbiert, Seitenverhältnis unverändert). Derselbe
* Faktor gilt für die Decode-Auflösung (s. [PHOTO_MAX_WIDTH_PX]), sodass die
* Bilddichte bei [PHOTO_TARGET_DPI] bleibt und das PDF zugleich deutlich kleiner wird.
*/
private const val PHOTO_SIZE_FACTOR = 0.5f
// Verfügbare Bildfläche in Punkten (Seite abzüglich Rand) - siehe drawText()-nahe // Verfügbare Bildfläche in Punkten (Seite abzüglich Rand) - siehe drawText()-nahe
// Verwendung von contentWidth/remainingHeight für die tatsächliche Platzierung; diese // Verwendung von contentWidth/remainingHeight für die tatsächliche Platzierung; diese
// Konstanten bestimmen ausschließlich die DECODE-Zielgröße (wie groß ein Foto maximal // Konstanten bestimmen ausschließlich die DECODE-Zielgröße (wie groß ein Foto maximal
@@ -108,11 +118,11 @@ class AndroidPdfDocumentExporter(
private const val PHOTO_AREA_WIDTH_PT = PAGE_WIDTH - 2 * MARGIN private const val PHOTO_AREA_WIDTH_PT = PAGE_WIDTH - 2 * MARGIN
private const val PHOTO_AREA_HEIGHT_PT = PAGE_HEIGHT - 2 * MARGIN private const val PHOTO_AREA_HEIGHT_PT = PAGE_HEIGHT - 2 * MARGIN
/** Maximale Decode-Breite in Pixeln bei [PHOTO_TARGET_DPI] (≈ 1073px bei 515pt/150dpi). */ /** Maximale Decode-Breite in Pixeln bei [PHOTO_TARGET_DPI] und [PHOTO_SIZE_FACTOR] (≈ 536px bei 257,5pt/150dpi). */
private val PHOTO_MAX_WIDTH_PX = (PHOTO_AREA_WIDTH_PT / 72f * PHOTO_TARGET_DPI).toInt() private val PHOTO_MAX_WIDTH_PX = (PHOTO_AREA_WIDTH_PT * PHOTO_SIZE_FACTOR / 72f * PHOTO_TARGET_DPI).toInt()
/** Maximale Decode-Höhe in Pixeln bei [PHOTO_TARGET_DPI] (≈ 1587px bei 762pt/150dpi). */ /** Maximale Decode-Höhe in Pixeln bei [PHOTO_TARGET_DPI] und [PHOTO_SIZE_FACTOR] (≈ 793px bei 381pt/150dpi). */
private val PHOTO_MAX_HEIGHT_PX = (PHOTO_AREA_HEIGHT_PT / 72f * PHOTO_TARGET_DPI).toInt() private val PHOTO_MAX_HEIGHT_PX = (PHOTO_AREA_HEIGHT_PT * PHOTO_SIZE_FACTOR / 72f * PHOTO_TARGET_DPI).toInt()
private val HEADER_DATE_FORMATTER: DateTimeFormatter = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM) private val HEADER_DATE_FORMATTER: DateTimeFormatter = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM)
} }
@@ -176,11 +186,18 @@ class AndroidPdfDocumentExporter(
} }
PdfExportResult.Success(targetFile) PdfExportResult.Success(targetFile)
} catch (e: Exception) { } catch (e: Throwable) {
tempFile.delete() tempFile.delete()
PdfExportResult.Failure( if (e is kotlinx.coroutines.CancellationException) throw e
// Bewusst Throwable: OutOfMemoryError ist ein Error, keine Exception, und würde
// sonst die App beenden. Eine bereits vorhandene PDF am Zielpfad bleibt unangetastet.
when (FailureCategory.of(e)) {
FailureCategory.OUT_OF_MEMORY -> PdfExportResult.Failure(PdfExportError.OutOfMemory)
FailureCategory.OUT_OF_STORAGE -> PdfExportResult.Failure(PdfExportError.InsufficientStorage)
FailureCategory.OTHER -> PdfExportResult.Failure(
PdfExportError.WriteFailed(e.message ?: "Unbekannter Fehler bei der PDF-Erzeugung.") PdfExportError.WriteFailed(e.message ?: "Unbekannter Fehler bei der PDF-Erzeugung.")
) )
}
} finally { } finally {
pdfDocument.close() pdfDocument.close()
// Erst JETZT sicher, da writeTo() (im try-Block oben) zu diesem Zeitpunkt entweder // Erst JETZT sicher, da writeTo() (im try-Block oben) zu diesem Zeitpunkt entweder
@@ -209,10 +226,11 @@ class AndroidPdfDocumentExporter(
textSize = HEADER_INFO_TEXT_SIZE textSize = HEADER_INFO_TEXT_SIZE
color = Color.BLACK color = Color.BLACK
} }
// Überschriften (Blocktitel, "Unterschriften:", "Fotoanhang:"): fett UND kursiv.
val blockTitle = TextPaint().apply { val blockTitle = TextPaint().apply {
isAntiAlias = true isAntiAlias = true
textSize = BLOCK_TITLE_TEXT_SIZE textSize = BLOCK_TITLE_TEXT_SIZE
isFakeBoldText = true typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD_ITALIC)
color = Color.BLACK color = Color.BLACK
} }
val item = TextPaint().apply { val item = TextPaint().apply {
@@ -268,8 +286,15 @@ class AndroidPdfDocumentExporter(
cursor.drawDivider(spacingAfter = 12f) cursor.drawDivider(spacingAfter = 12f)
} }
/** Überschriften erscheinen im PDF immer mit abschließendem Doppelpunkt (kein doppelter
* Doppelpunkt, falls der Titel bereits mit einem endet). */
private fun headingText(title: String): String {
val trimmed = title.trimEnd()
return if (trimmed.endsWith(":")) trimmed else "$trimmed:"
}
private fun drawBlock(cursor: PdfPageCursor, block: PdfExportBlock, paints: ExportPaints) { private fun drawBlock(cursor: PdfPageCursor, block: PdfExportBlock, paints: ExportPaints) {
cursor.drawText(block.title, paints.blockTitle, spacingAfter = 6f) cursor.drawText(headingText(block.title), paints.blockTitle, spacingAfter = 6f)
for (item in block.items) { for (item in block.items) {
drawItem(cursor, item, paints) drawItem(cursor, item, paints)
@@ -289,8 +314,15 @@ class AndroidPdfDocumentExporter(
// vorgibt. // vorgibt.
val decodedInputSpec = if (item.inputSpec.isNotEmpty()) InputSpecCodec.decode(item.inputSpec) else null val decodedInputSpec = if (item.inputSpec.isNotEmpty()) InputSpecCodec.decode(item.inputSpec) else null
if (decodedInputSpec != null) { if (decodedInputSpec != null) {
// Eintrag mit eigener Checkbox (z. B. "Wie oft pro Woche"), angehakt aber ohne
// Auswahl: nur das Label ausgeben statt einer Vorlage mit "–"-Platzhaltern.
val itemText = if (decodedInputSpec.requiresOwnCheckbox && !decodedInputSpec.hasAnyValue(item.freeTextValue)) {
item.label
} else {
decodedInputSpec.formatForPdf(item.freeTextValue)
}
cursor.drawText( cursor.drawText(
text = decodedInputSpec.formatForPdf(item.freeTextValue), text = itemText,
paint = paints.item, paint = paints.item,
indent = ITEM_INDENT, indent = ITEM_INDENT,
spacingAfter = 3f, spacingAfter = 3f,
@@ -298,8 +330,10 @@ class AndroidPdfDocumentExporter(
return return
} }
// Die Freitext-Option bekommt einen abschließenden Doppelpunkt, normale Optionen nicht. // Die Freitext-Option bekommt einen abschließenden Doppelpunkt, sofern tatsächlich Text
val labelText = if (item.isFreeText) "${item.label}:" else item.label // folgt (z. B. "Granudacyn" ohne Text bleibt ohne hängenden Doppelpunkt), normale
// Optionen nie.
val labelText = if (item.isFreeText && item.freeTextValue.isNotBlank()) "${item.label}:" else item.label
cursor.drawText( cursor.drawText(
text = labelText, text = labelText,
@@ -334,7 +368,7 @@ class AndroidPdfDocumentExporter(
paints: ExportPaints, paints: ExportPaints,
retainedSignatureBitmaps: MutableList<Bitmap>, retainedSignatureBitmaps: MutableList<Bitmap>,
) { ) {
cursor.drawText("Unterschriften", paints.blockTitle, spacingAfter = SIGNATURE_SECTION_TITLE_SPACING_AFTER) cursor.drawText(headingText("Unterschriften"), paints.blockTitle, spacingAfter = SIGNATURE_SECTION_TITLE_SPACING_AFTER)
for (signature in model.signatures) { for (signature in model.signatures) {
drawSignatureBlock(cursor, model, signature, paints, retainedSignatureBitmaps) drawSignatureBlock(cursor, model, signature, paints, retainedSignatureBitmaps)
} }
@@ -430,7 +464,7 @@ class AndroidPdfDocumentExporter(
) { ) {
cursor.forceNewPage() cursor.forceNewPage()
if (index == 0) { if (index == 0) {
cursor.drawText("Fotoanhang", paints.blockTitle, spacingAfter = 8f) cursor.drawText(headingText("Fotoanhang"), paints.blockTitle, spacingAfter = 8f)
} }
cursor.drawText("Foto ${index + 1} von ${model.photos.size}", paints.headerInfo, spacingAfter = 8f) cursor.drawText("Foto ${index + 1} von ${model.photos.size}", paints.headerInfo, spacingAfter = 8f)
@@ -445,7 +479,7 @@ class AndroidPdfDocumentExporter(
if (bitmap != null) { if (bitmap != null) {
retainedPhotoBitmaps.add(bitmap) retainedPhotoBitmaps.add(bitmap)
cursor.drawBitmapFitted(bitmap, paints.photo) cursor.drawBitmapFitted(bitmap, paints.photo, sizeFactor = PHOTO_SIZE_FACTOR)
} else { } else {
// Fehlende/beschädigte Datei, IOException, ungültige Abmessungen o. Ä. - Option B: // Fehlende/beschädigte Datei, IOException, ungültige Abmessungen o. Ä. - Option B:
// neutrale Hinweisseite statt stillschweigendem Überspringen, damit nachvollziehbar // neutrale Hinweisseite statt stillschweigendem Überspringen, damit nachvollziehbar
@@ -38,4 +38,25 @@ sealed interface PdfExportError {
data class DirectoryCreationFailed(val path: String) : PdfExportError data class DirectoryCreationFailed(val path: String) : PdfExportError
data class WriteFailed(val message: String) : PdfExportError data class WriteFailed(val message: String) : PdfExportError
data class Unexpected(val message: String) : PdfExportError data class Unexpected(val message: String) : PdfExportError
/** Arbeitsspeicher reichte für die PDF-Erzeugung nicht aus (z. B. sehr viele Fotos). */
data object OutOfMemory : PdfExportError
/** Auf dem Gerät ist nicht genug freier Speicherplatz für die PDF-Datei vorhanden. */
data object InsufficientStorage : PdfExportError
}
/**
* Verständliche Anzeige-Meldung ohne technische Details (keine Pfade, keine Exception-Texte -
* Datenschutz: es können Patientendaten im Dateinamen/Pfad stecken).
*/
fun PdfExportError.toUserMessage(): String = when (this) {
PdfExportError.DocumentationNotFound -> "Die Dokumentation wurde nicht gefunden."
is PdfExportError.DirectoryCreationFailed -> "Zielverzeichnis konnte nicht angelegt werden."
is PdfExportError.WriteFailed -> "Beim Schreiben der PDF-Datei ist ein Fehler aufgetreten."
is PdfExportError.Unexpected -> "Es ist ein unerwarteter Fehler aufgetreten."
PdfExportError.OutOfMemory ->
"Nicht genügend Arbeitsspeicher. Bitte andere Apps schließen oder weniger Fotos verwenden."
PdfExportError.InsufficientStorage ->
"Nicht genügend freier Speicherplatz auf dem Gerät."
} }
@@ -107,17 +107,21 @@ internal class PdfPageCursor(
* darf; reicht der verbleibende Platz nicht aus, wird das Bild in den vorhandenen Rest * darf; reicht der verbleibende Platz nicht aus, wird das Bild in den vorhandenen Rest
* hineinskaliert (kleiner, nie abgeschnitten). * hineinskaliert (kleiner, nie abgeschnitten).
*/ */
fun drawBitmapFitted(bitmap: Bitmap, paint: Paint) { fun drawBitmapFitted(bitmap: Bitmap, paint: Paint, sizeFactor: Float = 1f) {
startIfNeeded() startIfNeeded()
val availableWidth = contentWidth.toFloat() val availableWidth = contentWidth.toFloat()
val availableHeight = remainingHeight val availableHeight = remainingHeight
if (availableWidth <= 0f || availableHeight <= 0f || bitmap.width <= 0 || bitmap.height <= 0) return if (availableWidth <= 0f || availableHeight <= 0f || bitmap.width <= 0 || bitmap.height <= 0) return
val scale = minOf(availableWidth / bitmap.width, availableHeight / bitmap.height) // [sizeFactor] < 1 (Änderungswunsch: Fotoanhang auf 50 %): das Bild wird zuerst wie
// gewohnt in den verfügbaren Platz eingepasst und dann um diesen Faktor verkleinert
// (Seitenverhältnis bleibt). Das verkleinerte Bild steht horizontal zentriert direkt
// unter der Bildunterschrift (oben ausgerichtet) statt mitten in der Restfläche.
val scale = minOf(availableWidth / bitmap.width, availableHeight / bitmap.height) * sizeFactor
val drawWidth = bitmap.width * scale val drawWidth = bitmap.width * scale
val drawHeight = bitmap.height * scale val drawHeight = bitmap.height * scale
val left = marginLeft + (availableWidth - drawWidth) / 2f val left = marginLeft + (availableWidth - drawWidth) / 2f
val top = cursorY + (availableHeight - drawHeight) / 2f val top = if (sizeFactor < 1f) cursorY else cursorY + (availableHeight - drawHeight) / 2f
canvas!!.drawBitmap(bitmap, null, RectF(left, top, left + drawWidth, top + drawHeight), paint) canvas!!.drawBitmap(bitmap, null, RectF(left, top, left + drawWidth, top + drawHeight), paint)
cursorY = contentBottom() cursorY = contentBottom()
@@ -5,6 +5,7 @@ import android.graphics.Bitmap
import android.graphics.Color import android.graphics.Color
import android.graphics.pdf.PdfRenderer import android.graphics.pdf.PdfRenderer
import android.os.ParcelFileDescriptor import android.os.ParcelFileDescriptor
import de.medapp.wunddoku.domain.error.FailureCategory
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.io.File import java.io.File
@@ -41,6 +42,12 @@ sealed interface PdfToImagesResult {
data object EmptyDocument : Failure data object EmptyDocument : Failure
data class Unexpected(val message: String) : Failure data class Unexpected(val message: String) : Failure
/** Arbeitsspeicher reichte zum Rendern der Seiten nicht aus. */
data object OutOfMemory : Failure
/** Nicht genug freier Speicherplatz für die Bilddateien. */
data object InsufficientStorage : Failure
} }
} }
@@ -51,6 +58,10 @@ fun PdfToImagesResult.Failure.toUserMessage(): String = when (this) {
"Die PDF-Datei enthält keine Seiten." "Die PDF-Datei enthält keine Seiten."
is PdfToImagesResult.Failure.Unexpected -> is PdfToImagesResult.Failure.Unexpected ->
"Die PDF-Datei konnte nicht in Bilder umgewandelt werden." "Die PDF-Datei konnte nicht in Bilder umgewandelt werden."
PdfToImagesResult.Failure.OutOfMemory ->
"Nicht genügend Arbeitsspeicher zum Umwandeln in Bilder."
PdfToImagesResult.Failure.InsufficientStorage ->
"Nicht genügend freier Speicherplatz für die Bilder."
} }
class AndroidPdfToImagesConverter(private val context: Context) : PdfToImagesConverter { class AndroidPdfToImagesConverter(private val context: Context) : PdfToImagesConverter {
@@ -102,10 +113,19 @@ class AndroidPdfToImagesConverter(private val context: Context) : PdfToImagesCon
PdfToImagesResult.Success(imageFiles) PdfToImagesResult.Success(imageFiles)
} }
} }
} catch (e: Exception) { } catch (e: Throwable) {
// Abbruch der Coroutine nie verschlucken.
if (e is kotlinx.coroutines.CancellationException) throw e
// Halbfertigen Bildsatz entfernen: ein unvollständiger Satz darf nie geteilt werden.
outputDir.deleteRecursively()
when (FailureCategory.of(e)) {
FailureCategory.OUT_OF_MEMORY -> PdfToImagesResult.Failure.OutOfMemory
FailureCategory.OUT_OF_STORAGE -> PdfToImagesResult.Failure.InsufficientStorage
FailureCategory.OTHER ->
PdfToImagesResult.Failure.Unexpected(e.message ?: "Unbekannter Fehler bei der Bildkonvertierung.") PdfToImagesResult.Failure.Unexpected(e.message ?: "Unbekannter Fehler bei der Bildkonvertierung.")
} }
} }
}
companion object { companion object {
/** PDF-Koordinaten sind in Punkten (1/72 Zoll) angegeben - diese Ziel-Auflösung ergibt /** PDF-Koordinaten sind in Punkten (1/72 Zoll) angegeben - diese Ziel-Auflösung ergibt
@@ -104,12 +104,21 @@ class InternalSignatureStorage(
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
val fileName = "${UUID.randomUUID()}.png" val fileName = "${UUID.randomUUID()}.png"
val targetDir = ensureDocumentationDir(documentationId) val targetDir = ensureDocumentationDir(documentationId)
FileOutputStream(File(targetDir, fileName)).use { out -> val targetFile = File(targetDir, fileName)
try {
val written = FileOutputStream(targetFile).use { out ->
// Qualität 100 ist bei PNG (verlustfreies Format) bedeutungslos - wird trotzdem // Qualität 100 ist bei PNG (verlustfreies Format) bedeutungslos - wird trotzdem
// explizit angegeben, da Bitmap.compress() den Parameter unabhängig vom Format // explizit angegeben, da Bitmap.compress() den Parameter unabhängig vom Format
// verlangt. // verlangt.
bitmap.compress(Bitmap.CompressFormat.PNG, 100, out) bitmap.compress(Bitmap.CompressFormat.PNG, 100, out)
} }
// compress() meldet Schreibfehler (z. B. Speicher voll) nur über `false`.
if (!written) throw java.io.IOException("Signatur konnte nicht gespeichert werden.")
} catch (e: Throwable) {
// Keine leere/halbe PNG-Datei als scheinbar gültige Signatur zurücklassen.
targetFile.delete()
throw e
}
fileName fileName
} }
@@ -1,5 +1,6 @@
package de.medapp.wunddoku.ui.documentation.edit package de.medapp.wunddoku.ui.documentation.edit
import android.content.ActivityNotFoundException
import android.graphics.Bitmap import android.graphics.Bitmap
import android.net.Uri import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
@@ -22,6 +23,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
@@ -31,6 +33,7 @@ import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.ExpandLess import androidx.compose.material.icons.filled.ExpandLess
import androidx.compose.material.icons.filled.ExpandMore import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material.icons.filled.PhotoCamera
import androidx.compose.material.icons.filled.PictureAsPdf import androidx.compose.material.icons.filled.PictureAsPdf
import androidx.compose.material.icons.filled.Share import androidx.compose.material.icons.filled.Share
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
@@ -45,6 +48,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedIconButton
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHost
@@ -59,6 +63,7 @@ import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
@@ -73,6 +78,7 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.Dialog
import androidx.core.content.FileProvider
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.LocalLifecycleOwner
@@ -93,6 +99,7 @@ import de.medapp.wunddoku.pdf.PdfOpenResult
import de.medapp.wunddoku.pdf.PdfShareResult import de.medapp.wunddoku.pdf.PdfShareResult
import de.medapp.wunddoku.pdf.toUserMessage import de.medapp.wunddoku.pdf.toUserMessage
import de.medapp.wunddoku.ui.documentation.edit.signature.AddSignatureDialog import de.medapp.wunddoku.ui.documentation.edit.signature.AddSignatureDialog
import java.io.File
/** /**
* Bearbeitungsansicht für eine Wunddokumentation. Zeigt einen Block nach dem anderen mit * Bearbeitungsansicht für eine Wunddokumentation. Zeigt einen Block nach dem anderen mit
@@ -154,6 +161,51 @@ fun DocumentationEditScreen(
val pdfImageSharer = remember(context) { AndroidPdfImageSharer(context) } val pdfImageSharer = remember(context) { AndroidPdfImageSharer(context) }
val pdfSharer = remember(context) { AndroidPdfSharer(context) } val pdfSharer = remember(context) { AndroidPdfSharer(context) }
val snackbarHostState = remember { SnackbarHostState() } val snackbarHostState = remember { SnackbarHostState() }
// Kamerabutton im Fotobereich (Änderungswunsch): nimmt mehrere Fotos hintereinander auf.
// Es wird die System-Kamera-App über ACTION_IMAGE_CAPTURE (TakePicture-Contract) genutzt -
// dafür ist KEINE CAMERA-Berechtigung nötig. Nach jeder erfolgreichen Aufnahme öffnet sich
// die Kamera automatisch erneut; Abbrechen/Zurück beendet die Serie. Die Aufnahme landet
// zunächst in einer temporären Cache-Datei (FileProvider-Pfad "camera/") und wird dann wie
// eine Picker-Auswahl in die Dokumentation importiert (Downsampling, EXIF, interne Ablage).
// Der Pfad der gerade laufenden Aufnahme ist rememberSaveable, damit er einen Wechsel der
// Konfiguration (Drehen) oder Prozesstod während der Kamera-App übersteht.
var pendingCameraFilePath by rememberSaveable { mutableStateOf<String?>(null) }
var cameraRelaunchRequested by remember { mutableStateOf(false) }
val cameraLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.TakePicture(),
) { success: Boolean ->
val file = pendingCameraFilePath?.let(::File)
pendingCameraFilePath = null
if (file != null && success && file.length() > 0L) {
val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file)
viewModel.onCameraPhotoCaptured(uri) { file.delete() }
// Nächste Aufnahme der Serie.
cameraRelaunchRequested = true
} else {
// Abgebrochen (oder leere Datei): Serie beendet, temporäre Datei aufräumen.
file?.delete()
}
}
fun startCamera() {
val captureDir = File(context.cacheDir, "camera").apply { mkdirs() }
val file = File.createTempFile("capture_", ".jpg", captureDir)
pendingCameraFilePath = file.absolutePath
val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file)
try {
cameraLauncher.launch(uri)
} catch (e: ActivityNotFoundException) {
pendingCameraFilePath = null
file.delete()
viewModel.onCameraUnavailable()
}
}
LaunchedEffect(cameraRelaunchRequested) {
if (cameraRelaunchRequested) {
cameraRelaunchRequested = false
startCamera()
}
}
LaunchedEffect(uiState.pdfExportMessage) { LaunchedEffect(uiState.pdfExportMessage) {
val message = uiState.pdfExportMessage ?: return@LaunchedEffect val message = uiState.pdfExportMessage ?: return@LaunchedEffect
val exportedFile = uiState.lastExportedPdfFile val exportedFile = uiState.lastExportedPdfFile
@@ -200,6 +252,13 @@ fun DocumentationEditScreen(
snackbarHostState.showSnackbar(message) snackbarHostState.showSnackbar(message)
viewModel.dismissShareMessage() viewModel.dismissShareMessage()
} }
// Speicherfehler beim Autosave/Löschen: der Nutzer muss erfahren, dass eine Änderung NICHT
// gespeichert wurde (sonst entsteht unbemerkter Datenverlust).
LaunchedEffect(uiState.saveError) {
val message = uiState.saveError ?: return@LaunchedEffect
snackbarHostState.showSnackbar(message)
viewModel.dismissSaveError()
}
Scaffold( Scaffold(
snackbarHost = { SnackbarHost(snackbarHostState) }, snackbarHost = { SnackbarHost(snackbarHostState) },
@@ -280,6 +339,7 @@ fun DocumentationEditScreen(
PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly) PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)
) )
}, },
onTakePhotosClick = ::startCamera,
onDeletePhotoRequested = viewModel::requestDeletePhoto, onDeletePhotoRequested = viewModel::requestDeletePhoto,
loadThumbnail = viewModel::loadPhotoThumbnail, loadThumbnail = viewModel::loadPhotoThumbnail,
onPhotoClick = { viewedPhoto = it }, onPhotoClick = { viewedPhoto = it },
@@ -439,6 +499,7 @@ private fun DocumentationEditContent(
photoImportError: String?, photoImportError: String?,
onDismissPhotoImportError: () -> Unit, onDismissPhotoImportError: () -> Unit,
onAddPhotosClick: () -> Unit, onAddPhotosClick: () -> Unit,
onTakePhotosClick: () -> Unit,
onDeletePhotoRequested: (photoId: String) -> Unit, onDeletePhotoRequested: (photoId: String) -> Unit,
loadThumbnail: suspend (documentationId: String, fileName: String) -> Bitmap?, loadThumbnail: suspend (documentationId: String, fileName: String) -> Bitmap?,
onPhotoClick: (Photo) -> Unit, onPhotoClick: (Photo) -> Unit,
@@ -446,8 +507,28 @@ private fun DocumentationEditContent(
onDeleteSignatureRequested: (signatureId: String) -> Unit, onDeleteSignatureRequested: (signatureId: String) -> Unit,
loadSignatureBitmap: suspend (documentationId: String, fileName: String) -> Bitmap?, loadSignatureBitmap: suspend (documentationId: String, fileName: String) -> Bitmap?,
) { ) {
val listState = rememberLazyListState()
// Änderungswunsch: Beim Aufklappen eines Blocks dorthin scrollen, damit sein erster Eintrag
// sichtbar wird - wichtig bei langen Formularen, wenn ein weiter unten liegender Block
// aufgeklappt wird und sein Inhalt sonst unterhalb des sichtbaren Bereichs läge. Reagiert
// NUR auf tatsächliches Aufklappen (expandedBlockId wechselt zu einer konkreten Block-ID),
// nicht auf das Zuklappen (expandedBlockId wird null) - dort gibt es nichts, wohin gescrollt
// werden müsste. Da jeder Block GENAU EIN LazyColumn-Element ist (Kopfzeile + bei Bedarf
// aufgeklappter Inhalt gemeinsam, s. [BlockAccordionItem]) und die Block-Liste die ERSTEN
// Elemente der LazyColumn sind, landet der Block-Index 1:1 auf dem passenden
// LazyColumn-Index - der Block erscheint dadurch ganz oben im sichtbaren Bereich, sein
// erster Eintrag direkt darunter.
LaunchedEffect(expandedBlockId) {
val targetIndex = blocks.indexOfFirst { it.blockStateId == expandedBlockId }
if (targetIndex >= 0) {
listState.animateScrollToItem(targetIndex)
}
}
LazyColumn( LazyColumn(
modifier = modifier, modifier = modifier,
state = listState,
contentPadding = PaddingValues(16.dp), contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp),
) { ) {
@@ -515,6 +596,10 @@ private fun DocumentationEditContent(
if (photoImportInProgress) { if (photoImportInProgress) {
CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp) CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp)
} }
// Kamera: mehrere Aufnahmen hintereinander (s. startCamera).
OutlinedIconButton(onClick = onTakePhotosClick) {
Icon(Icons.Filled.PhotoCamera, contentDescription = "Fotos aufnehmen")
}
OutlinedButton(onClick = onAddPhotosClick, enabled = !photoImportInProgress) { OutlinedButton(onClick = onAddPhotosClick, enabled = !photoImportInProgress) {
Icon(Icons.Filled.Add, contentDescription = null) Icon(Icons.Filled.Add, contentDescription = null)
Spacer(Modifier.width(4.dp)) Spacer(Modifier.width(4.dp))
@@ -873,15 +958,32 @@ private fun StructuredInputRow(
val spec = remember(item.inputSpec) { InputSpecCodec.decode(item.inputSpec) } ?: return val spec = remember(item.inputSpec) { InputSpecCodec.decode(item.inputSpec) } ?: return
val values = remember(item.freeTextValue, spec) { spec.decodeValues(item.freeTextValue) } val values = remember(item.freeTextValue, spec) { spec.decodeValues(item.freeTextValue) }
// Eintrag mit eigener Checkbox (z. B. "Wie oft pro Woche"): Felder nur bei angehakter
// Checkbox aktiv, das Häkchen setzt allein der Nutzer. Sonst bisheriges Verhalten.
val ownCheckbox = spec.requiresOwnCheckbox
val fieldsEnabled = !ownCheckbox || item.checked
fun updateValue(fieldIndex: Int, newValue: String) { fun updateValue(fieldIndex: Int, newValue: String) {
val newValues = values.toMutableList().apply { this[fieldIndex] = newValue } val newValues = values.toMutableList().apply { this[fieldIndex] = newValue }
val encoded = spec.encodeValues(newValues) val encoded = spec.encodeValues(newValues)
onFreeTextChanged(item.checkItemStateId, encoded) onFreeTextChanged(item.checkItemStateId, encoded)
if (!ownCheckbox) {
onCheckedChanged(item.checkItemStateId, spec.hasAnyValue(encoded)) onCheckedChanged(item.checkItemStateId, spec.hasAnyValue(encoded))
} }
}
Column(modifier = Modifier.fillMaxWidth()) { Column(modifier = Modifier.fillMaxWidth()) {
if (ownCheckbox) {
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(
checked = item.checked,
onCheckedChange = { checked -> onCheckedChanged(item.checkItemStateId, checked) },
)
Text(item.label)
}
} else {
Text(item.label, style = MaterialTheme.typography.bodyMedium) Text(item.label, style = MaterialTheme.typography.bodyMedium)
}
Spacer(Modifier.height(4.dp)) Spacer(Modifier.height(4.dp))
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
@@ -909,6 +1011,7 @@ private fun StructuredInputRow(
}, },
label = { Text(field.label) }, label = { Text(field.label) },
singleLine = true, singleLine = true,
enabled = fieldsEnabled,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) )
@@ -916,6 +1019,7 @@ private fun StructuredInputRow(
is InputFieldDef.Choice -> ChoiceField( is InputFieldDef.Choice -> ChoiceField(
field = field, field = field,
selected = values.getOrElse(index) { "" }, selected = values.getOrElse(index) { "" },
enabled = fieldsEnabled,
onSelected = { updateValue(index, it) }, onSelected = { updateValue(index, it) },
) )
} }
@@ -932,11 +1036,12 @@ private fun StructuredInputRow(
private fun ChoiceField( private fun ChoiceField(
field: InputFieldDef.Choice, field: InputFieldDef.Choice,
selected: String, selected: String,
enabled: Boolean = true,
onSelected: (String) -> Unit, onSelected: (String) -> Unit,
) { ) {
var expanded by remember { mutableStateOf(false) } var expanded by remember { mutableStateOf(false) }
Box(modifier = Modifier.fillMaxWidth()) { Box(modifier = Modifier.fillMaxWidth()) {
OutlinedButton(onClick = { expanded = true }, modifier = Modifier.fillMaxWidth()) { OutlinedButton(onClick = { expanded = true }, enabled = enabled, modifier = Modifier.fillMaxWidth()) {
Text(selected.ifBlank { field.label }, maxLines = 1) Text(selected.ifBlank { field.label }, maxLines = 1)
} }
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
@@ -11,13 +11,15 @@ import androidx.navigation.toRoute
import de.medapp.wunddoku.data.repository.DocumentationRepository import de.medapp.wunddoku.data.repository.DocumentationRepository
import de.medapp.wunddoku.data.repository.WoundCarerRepository import de.medapp.wunddoku.data.repository.WoundCarerRepository
import de.medapp.wunddoku.di.wunddokuApplication import de.medapp.wunddoku.di.wunddokuApplication
import de.medapp.wunddoku.domain.error.FailureCategory
import de.medapp.wunddoku.domain.error.runCatchingCancellable
import de.medapp.wunddoku.domain.model.Documentation import de.medapp.wunddoku.domain.model.Documentation
import de.medapp.wunddoku.domain.model.WoundCarer import de.medapp.wunddoku.domain.model.WoundCarer
import de.medapp.wunddoku.pdf.PdfExportError
import de.medapp.wunddoku.pdf.PdfExportResult import de.medapp.wunddoku.pdf.PdfExportResult
import de.medapp.wunddoku.pdf.PdfToImagesResult import de.medapp.wunddoku.pdf.PdfToImagesResult
import de.medapp.wunddoku.pdf.toUserMessage import de.medapp.wunddoku.pdf.toUserMessage
import de.medapp.wunddoku.ui.navigation.Screen import de.medapp.wunddoku.ui.navigation.Screen
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@@ -26,7 +28,10 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import java.io.File import java.io.File
import java.util.concurrent.atomic.AtomicInteger
data class DocumentationEditUiState( data class DocumentationEditUiState(
val documentation: Documentation? = null, val documentation: Documentation? = null,
@@ -87,6 +92,10 @@ data class DocumentationEditUiState(
/** Analog [imagesToShare], nur für die Variante "als PDF teilen" - Gegenstück /** Analog [imagesToShare], nur für die Variante "als PDF teilen" - Gegenstück
* [DocumentationEditViewModel.onPdfShareHandled]. */ * [DocumentationEditViewModel.onPdfShareHandled]. */
val pdfToShare: File? = null, val pdfToShare: File? = null,
/** Einmalige Fehlermeldung, wenn eine Änderung (Haken, Freitext, Löschen) NICHT in der
* Datenbank gespeichert werden konnte - z. B. bei vollem Speicher. Die Anzeige zeigt dann
* wieder den tatsächlich gespeicherten Stand, nie einen nur scheinbar gespeicherten. */
val saveError: String? = null,
) { ) {
val blockCount: Int get() = documentation?.blocks?.size ?: 0 val blockCount: Int get() = documentation?.blocks?.size ?: 0
@@ -117,6 +126,10 @@ class DocumentationEditViewModel(
private val woundCarerRepository: WoundCarerRepository, private val woundCarerRepository: WoundCarerRepository,
) : ViewModel() { ) : ViewModel() {
/** Serialisiert Fotoimporte (s. [importPhotos]) und zählt laufende Importe für den Ladeindikator. */
private val photoImportMutex = Mutex()
private val pendingPhotoImports = AtomicInteger(0)
private val _uiState = MutableStateFlow(DocumentationEditUiState()) private val _uiState = MutableStateFlow(DocumentationEditUiState())
val uiState: StateFlow<DocumentationEditUiState> = _uiState.asStateFlow() val uiState: StateFlow<DocumentationEditUiState> = _uiState.asStateFlow()
@@ -133,7 +146,14 @@ class DocumentationEditViewModel(
private suspend fun loadDocumentation() { private suspend fun loadDocumentation() {
_uiState.update { it.copy(isLoading = true, error = null) } _uiState.update { it.copy(isLoading = true, error = null) }
val documentation = repository.getFullDocumentation(documentationId) val documentation = try {
repository.getFullDocumentation(documentationId)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
_uiState.update { it.copy(isLoading = false, error = "Dokumentation konnte nicht geladen werden.") }
return
}
_uiState.update { _uiState.update {
if (documentation != null) { if (documentation != null) {
it.copy(documentation = documentation, isLoading = false) it.copy(documentation = documentation, isLoading = false)
@@ -153,11 +173,48 @@ class DocumentationEditViewModel(
} }
private suspend fun persistFreeText(checkItemStateId: String, text: String) { private suspend fun persistFreeText(checkItemStateId: String, text: String) {
repository.updateFreeText(checkItemStateId, text) // Fehler (z. B. voller Speicher) dürfen weder die Debounce-Schleife beenden noch die App
// abstürzen lassen. Der Wert bleibt in [pendingFreeText] und wird beim nächsten Flush
// (ON_STOP) bzw. bei der nächsten Eingabe erneut geschrieben - kein stiller Datenverlust.
val result = runCatchingCancellable { repository.updateFreeText(checkItemStateId, text) }
if (result.isFailure) {
reportSaveFailure(result.exceptionOrNull())
return
}
// Nur entfernen, wenn zwischenzeitlich kein neuerer Wert eingetroffen ist. // Nur entfernen, wenn zwischenzeitlich kein neuerer Wert eingetroffen ist.
if (pendingFreeText[checkItemStateId] == text) pendingFreeText.remove(checkItemStateId) if (pendingFreeText[checkItemStateId] == text) pendingFreeText.remove(checkItemStateId)
} }
private fun reportSaveFailure(cause: Throwable?, fallback: String = "Die Änderung konnte nicht gespeichert werden.") {
val message = when (cause?.let { FailureCategory.of(it) }) {
FailureCategory.OUT_OF_STORAGE -> "Nicht genügend Speicherplatz - die Änderung konnte nicht gespeichert werden."
FailureCategory.OUT_OF_MEMORY -> "Nicht genügend Arbeitsspeicher - die Änderung konnte nicht gespeichert werden."
else -> fallback
}
_uiState.update { it.copy(saveError = message) }
}
/** Lädt den tatsächlich gespeicherten Stand neu (best effort), z. B. nach einem
* fehlgeschlagenen Schreibvorgang, damit die Anzeige nie vom Datenbankstand abweicht. */
private suspend fun refreshFromDatabase() {
val refreshed = runCatchingCancellable { repository.getFullDocumentation(documentationId) }.getOrNull()
if (refreshed != null) {
// Noch nicht gespeicherte Freitexte (pendingFreeText) bleiben in der Anzeige erhalten.
val merged = if (pendingFreeText.isEmpty()) refreshed else refreshed.copy(
blocks = refreshed.blocks.map { block ->
block.copy(items = block.items.map { item ->
pendingFreeText[item.checkItemStateId]?.let { item.copy(freeTextValue = it) } ?: item
})
},
)
_uiState.update { it.copy(documentation = merged) }
}
}
fun dismissSaveError() {
_uiState.update { it.copy(saveError = null) }
}
/** /**
* [checkItemStateId] referenziert die Freitext-Option eines Blocks (CheckItem.isFreeText = * [checkItemStateId] referenziert die Freitext-Option eines Blocks (CheckItem.isFreeText =
* true), NICHT den Block selbst - der Freitext ist seit der Umstellung auf eine * true), NICHT den Block selbst - der Freitext ist seit der Umstellung auf eine
@@ -195,7 +252,13 @@ class DocumentationEditViewModel(
state.copy(documentation = documentation.copy(blocks = updatedBlocks)) state.copy(documentation = documentation.copy(blocks = updatedBlocks))
} }
// 2) sofort persistieren (kein Debounce nötig, einzelnes günstiges UPDATE) // 2) sofort persistieren (kein Debounce nötig, einzelnes günstiges UPDATE)
viewModelScope.launch { repository.setChecked(checkItemStateId, checked) } viewModelScope.launch {
val result = runCatchingCancellable { repository.setChecked(checkItemStateId, checked) }
if (result.isFailure) {
reportSaveFailure(result.exceptionOrNull())
refreshFromDatabase()
}
}
} }
/** /**
@@ -224,15 +287,50 @@ class DocumentationEditViewModel(
* UI konsistent den tatsächlichen Datenbankstand zeigt. * UI konsistent den tatsächlichen Datenbankstand zeigt.
*/ */
fun onPhotosPicked(uris: List<Uri>) { fun onPhotosPicked(uris: List<Uri>) {
if (uris.isEmpty()) return importPhotos(uris, cleanup = {})
}
/**
* Übernimmt ein mit der Kamera aufgenommenes Foto (Kamerabutton im Fotobereich, s.
* DocumentationEditScreen). Läuft über denselben Importweg wie die Photo-Picker-Auswahl
* (Downsampling, EXIF-Korrektur, interne Ablage). [cleanup] löscht anschließend die
* temporäre Aufnahmedatei im Cache - unabhängig davon, ob der Import gelungen ist.
*/
fun onCameraPhotoCaptured(uri: Uri, cleanup: () -> Unit) {
importPhotos(listOf(uri), cleanup)
}
/**
* Gemeinsamer Importweg für Picker-Auswahl und Kameraaufnahmen. Importe laufen über einen
* [photoImportMutex] strikt nacheinander: Bei einer Kameraserie kann die nächste Aufnahme
* eintreffen, während die vorige noch importiert wird - ohne Sperre könnten zwei Importe
* dieselbe nächste orderIndex vergeben.
*/
private fun importPhotos(uris: List<Uri>, cleanup: () -> Unit) {
if (uris.isEmpty()) {
runCatching { cleanup() }
return
}
viewModelScope.launch { viewModelScope.launch {
_uiState.update { it.copy(photoImportInProgress = true, photoImportError = null) } _uiState.update { it.copy(photoImportInProgress = true, photoImportError = null) }
pendingPhotoImports.incrementAndGet()
try {
photoImportMutex.withLock {
var failureCount = 0 var failureCount = 0
var outOfMemoryCount = 0
var outOfStorageCount = 0
for (uri in uris) { for (uri in uris) {
try { try {
repository.addPhoto(documentationId, uri) repository.addPhoto(documentationId, uri)
} catch (e: Exception) { } catch (e: CancellationException) {
throw e
} catch (e: Throwable) {
// Throwable (statt Exception): OutOfMemoryError beim Dekodieren großer
// Fotos darf die App nicht beenden - das Bild gilt als fehlgeschlagen,
// die übrigen Bilder werden weiter importiert.
failureCount++ failureCount++
if (FailureCategory.of(e) == FailureCategory.OUT_OF_MEMORY) outOfMemoryCount++
if (FailureCategory.of(e) == FailureCategory.OUT_OF_STORAGE) outOfStorageCount++
// Bewusst OHNE Uri/Dateipfad geloggt (Datenschutz-Vorgabe: keine Bild-Uris/ // Bewusst OHNE Uri/Dateipfad geloggt (Datenschutz-Vorgabe: keine Bild-Uris/
// Dateipfade in Logs) - die Exception-Nachricht selbst genügt zur Diagnose. // Dateipfade in Logs) - die Exception-Nachricht selbst genügt zur Diagnose.
android.util.Log.w( android.util.Log.w(
@@ -241,19 +339,35 @@ class DocumentationEditViewModel(
) )
} }
} }
val refreshed = repository.getFullDocumentation(documentationId) val refreshed = runCatchingCancellable { repository.getFullDocumentation(documentationId) }.getOrNull()
val reason = when {
outOfStorageCount > 0 -> " Nicht genügend Speicherplatz."
outOfMemoryCount > 0 -> " Nicht genügend Arbeitsspeicher."
else -> ""
}
_uiState.update { state -> _uiState.update { state ->
state.copy( state.copy(
documentation = refreshed ?: state.documentation, documentation = refreshed ?: state.documentation,
photoImportInProgress = false,
photoImportError = if (failureCount > 0) { photoImportError = if (failureCount > 0) {
"$failureCount von ${uris.size} Bild(ern) konnten nicht importiert werden." "$failureCount von ${uris.size} Bild(ern) konnten nicht importiert werden.$reason"
} else { } else {
null state.photoImportError
}, },
) )
} }
} }
} finally {
runCatching { cleanup() }
if (pendingPhotoImports.decrementAndGet() == 0) {
_uiState.update { it.copy(photoImportInProgress = false) }
}
}
}
}
/** Meldet, dass die Kamera auf dem Gerät nicht gestartet werden konnte (keine Kamera-App). */
fun onCameraUnavailable() {
_uiState.update { it.copy(photoImportError = "Es konnte keine Kamera-App gestartet werden.") }
} }
fun dismissPhotoImportError() { fun dismissPhotoImportError() {
@@ -273,8 +387,9 @@ class DocumentationEditViewModel(
fun confirmDeletePhoto() { fun confirmDeletePhoto() {
val photoId = _uiState.value.pendingDeletePhotoId ?: return val photoId = _uiState.value.pendingDeletePhotoId ?: return
viewModelScope.launch { viewModelScope.launch {
repository.removePhoto(photoId) val result = runCatchingCancellable { repository.removePhoto(photoId) }
val refreshed = repository.getFullDocumentation(documentationId) if (result.isFailure) reportSaveFailure(result.exceptionOrNull(), "Das Foto konnte nicht gelöscht werden.")
val refreshed = runCatchingCancellable { repository.getFullDocumentation(documentationId) }.getOrNull()
_uiState.update { state -> _uiState.update { state ->
state.copy( state.copy(
documentation = refreshed ?: state.documentation, documentation = refreshed ?: state.documentation,
@@ -314,6 +429,17 @@ class DocumentationEditViewModel(
} }
} catch (e: IllegalArgumentException) { } catch (e: IllegalArgumentException) {
_uiState.update { it.copy(signatureError = e.message ?: "Signatur konnte nicht gespeichert werden.") } _uiState.update { it.copy(signatureError = e.message ?: "Signatur konnte nicht gespeichert werden.") }
} catch (e: CancellationException) {
throw e
} catch (e: Throwable) {
// Speicherfehler o. Ä.: Dialog bleibt offen, die gezeichnete Signatur geht nicht
// verloren und kann nach Freigabe von Speicher erneut gespeichert werden.
val message = when (FailureCategory.of(e)) {
FailureCategory.OUT_OF_STORAGE -> "Nicht genügend Speicherplatz - die Signatur konnte nicht gespeichert werden."
FailureCategory.OUT_OF_MEMORY -> "Nicht genügend Arbeitsspeicher - die Signatur konnte nicht gespeichert werden."
FailureCategory.OTHER -> "Die Signatur konnte nicht gespeichert werden."
}
_uiState.update { it.copy(signatureError = message) }
} }
} }
} }
@@ -335,8 +461,9 @@ class DocumentationEditViewModel(
fun confirmDeleteSignature() { fun confirmDeleteSignature() {
val signatureId = _uiState.value.pendingDeleteSignatureId ?: return val signatureId = _uiState.value.pendingDeleteSignatureId ?: return
viewModelScope.launch { viewModelScope.launch {
repository.removeSignature(signatureId) val result = runCatchingCancellable { repository.removeSignature(signatureId) }
val refreshed = repository.getFullDocumentation(documentationId) if (result.isFailure) reportSaveFailure(result.exceptionOrNull(), "Die Signatur konnte nicht gelöscht werden.")
val refreshed = runCatchingCancellable { repository.getFullDocumentation(documentationId) }.getOrNull()
_uiState.update { state -> _uiState.update { state ->
state.copy( state.copy(
documentation = refreshed ?: state.documentation, documentation = refreshed ?: state.documentation,
@@ -495,13 +622,6 @@ class DocumentationEditViewModel(
_uiState.update { it.copy(shareMessage = null) } _uiState.update { it.copy(shareMessage = null) }
} }
private fun PdfExportError.toUserMessage(): String = when (this) {
PdfExportError.DocumentationNotFound -> "Die Dokumentation wurde nicht gefunden."
is PdfExportError.DirectoryCreationFailed -> "Zielverzeichnis konnte nicht angelegt werden."
is PdfExportError.WriteFailed -> "Beim Schreiben der PDF-Datei ist ein Fehler aufgetreten."
is PdfExportError.Unexpected -> "Es ist ein unerwarteter Fehler aufgetreten."
}
/** /**
* Lädt eine speicherschonende Vorschauversion eines Fotos für die Anzeige im Fotogitter. * Lädt eine speicherschonende Vorschauversion eines Fotos für die Anzeige im Fotogitter.
* Bewusst NICHT im zentralen UiState gehalten (kein `Bitmap`-Feld in * Bewusst NICHT im zentralen UiState gehalten (kein `Bitmap`-Feld in
@@ -545,9 +665,10 @@ class DocumentationEditViewModel(
fun flushPendingChanges() { fun flushPendingChanges() {
if (pendingFreeText.isEmpty()) return if (pendingFreeText.isEmpty()) return
val toFlush = pendingFreeText.toMap() val toFlush = pendingFreeText.toMap()
pendingFreeText.clear()
viewModelScope.launch { viewModelScope.launch {
toFlush.forEach { (checkItemStateId, text) -> repository.updateFreeText(checkItemStateId, text) } // Einträge werden erst NACH erfolgreichem Schreiben aus pendingFreeText entfernt -
// scheitert das Schreiben, bleibt der Text für den nächsten Versuch erhalten.
toFlush.forEach { (checkItemStateId, text) -> persistFreeText(checkItemStateId, text) }
} }
} }
@@ -9,6 +9,7 @@ import androidx.navigation.toRoute
import de.medapp.wunddoku.data.repository.DocumentationRepository import de.medapp.wunddoku.data.repository.DocumentationRepository
import de.medapp.wunddoku.data.repository.PatientRepository import de.medapp.wunddoku.data.repository.PatientRepository
import de.medapp.wunddoku.di.wunddokuApplication import de.medapp.wunddoku.di.wunddokuApplication
import de.medapp.wunddoku.domain.error.FailureCategory
import de.medapp.wunddoku.domain.model.Patient import de.medapp.wunddoku.domain.model.Patient
import de.medapp.wunddoku.domain.model.PatientListItem import de.medapp.wunddoku.domain.model.PatientListItem
import de.medapp.wunddoku.ui.components.format.DATE_MAX_DIGITS import de.medapp.wunddoku.ui.components.format.DATE_MAX_DIGITS
@@ -18,6 +19,7 @@ import de.medapp.wunddoku.ui.components.format.filterDigits
import de.medapp.wunddoku.ui.components.format.germanDateDigitsToLocalDateOrNull import de.medapp.wunddoku.ui.components.format.germanDateDigitsToLocalDateOrNull
import de.medapp.wunddoku.ui.components.format.toGermanDateDigits import de.medapp.wunddoku.ui.components.format.toGermanDateDigits
import de.medapp.wunddoku.ui.navigation.Screen import de.medapp.wunddoku.ui.navigation.Screen
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@@ -187,7 +189,7 @@ class PatientFormViewModel(
fun useExistingPatient() { fun useExistingPatient() {
val existing = _uiState.value.duplicateWarning ?: return val existing = _uiState.value.duplicateWarning ?: return
_uiState.update { it.copy(duplicateWarning = null) } _uiState.update { it.copy(duplicateWarning = null) }
viewModelScope.launch { finish(existing.id) } viewModelScope.launch { guardedFinish(existing.id) }
} }
/** Der Nutzer entscheidet sich im Duplikat-Dialog, trotzdem einen neuen Patienten anzulegen. */ /** Der Nutzer entscheidet sich im Duplikat-Dialog, trotzdem einen neuen Patienten anzulegen. */
@@ -241,10 +243,37 @@ class PatientFormViewModel(
createdAt = Instant.EPOCH, // wird von PatientRepository.upsert() korrekt gesetzt createdAt = Instant.EPOCH, // wird von PatientRepository.upsert() korrekt gesetzt
updatedAt = Instant.EPOCH, updatedAt = Instant.EPOCH,
) )
val saved = repository.upsert(patient) val saved = try {
_uiState.update { it.copy(validationError = null) } repository.upsert(patient)
finish(saved.id) } catch (e: CancellationException) {
throw e
} catch (e: Exception) {
reportPersistenceFailure(e, "Der Patient konnte nicht gespeichert werden.")
return@launch
} }
_uiState.update { it.copy(validationError = null) }
guardedFinish(saved.id)
}
}
/** Wie [finish], fängt aber Datenbankfehler (z. B. voller Speicher) ab: Das Formular bleibt
* mit allen Eingaben offen und zeigt eine Meldung, statt dass die App abstürzt. */
private suspend fun guardedFinish(resolvedPatientId: String) {
try {
finish(resolvedPatientId)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
reportPersistenceFailure(e, "Die Dokumentation konnte nicht angelegt werden.")
}
}
private fun reportPersistenceFailure(cause: Throwable, fallback: String) {
val message = when (FailureCategory.of(cause)) {
FailureCategory.OUT_OF_STORAGE -> "Nicht genügend Speicherplatz - bitte Speicher freigeben und erneut versuchen."
else -> fallback
}
_uiState.update { it.copy(validationError = message) }
} }
/** /**
+4 -1
View File
@@ -3,11 +3,14 @@
Gibt ausschließlich Unterordner des privaten internen App-Speichers (context.filesDir) Gibt ausschließlich Unterordner des privaten internen App-Speichers (context.filesDir)
für die Freigabe über den FileProvider frei. Es gibt bewusst keinen <external-path>- für die Freigabe über den FileProvider frei. Es gibt bewusst keinen <external-path>-
oder <external-files-path>-Eintrag: Fotos, Signaturen und PDFs liegen grundsätzlich oder <external-files-path>-Eintrag: Fotos, Signaturen und PDFs liegen grundsätzlich
intern (Abschnitt "Storage-Strategie" der Architektur, v3). intern (nur die kurzlebigen Kameraaufnahmen zusätzlich im internen Cache) (Abschnitt "Storage-Strategie" der Architektur, v3).
--> -->
<paths xmlns:android="http://schemas.android.com/apk/res/android"> <paths xmlns:android="http://schemas.android.com/apk/res/android">
<files-path name="pdf_exports" path="pdf/" /> <files-path name="pdf_exports" path="pdf/" />
<files-path name="pdf_images" path="pdf_images/" /> <files-path name="pdf_images" path="pdf_images/" />
<files-path name="photos" path="photos/" /> <files-path name="photos" path="photos/" />
<files-path name="signatures" path="signatures/" /> <files-path name="signatures" path="signatures/" />
<!-- Temporäre Kameraaufnahmen (Kamerabutton im Fotobereich): nur bis zum Import in die
Dokumentation, danach sofort gelöscht. -->
<cache-path name="camera_captures" path="camera/" />
</paths> </paths>
@@ -0,0 +1,91 @@
package de.medapp.wunddoku.domain.error
import kotlinx.coroutines.CancellationException
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Assert.fail
import org.junit.Test
import java.io.FileNotFoundException
import java.io.IOException
/**
* Reine JVM-Tests für die Fehlereinordnung (Speichermangel erkennen) und
* [runCatchingCancellable].
*/
class FailureCategoryTest {
// --- Arbeitsspeicher -----------------------------------------------------------------
@Test
fun out_of_memory_error_wird_erkannt() {
assertEquals(FailureCategory.OUT_OF_MEMORY, FailureCategory.of(OutOfMemoryError("Failed to allocate")))
}
@Test
fun out_of_memory_error_wird_auch_als_ursache_erkannt() {
val wrapped = RuntimeException("Export fehlgeschlagen", OutOfMemoryError())
assertEquals(FailureCategory.OUT_OF_MEMORY, FailureCategory.of(wrapped))
}
// --- Speicherplatz -------------------------------------------------------------------
@Test
fun enospc_ioexception_wird_als_speicher_voll_erkannt() {
val e = IOException("write failed: ENOSPC (No space left on device)")
assertEquals(FailureCategory.OUT_OF_STORAGE, FailureCategory.of(e))
}
@Test
fun sqlite_disk_full_meldung_wird_als_speicher_voll_erkannt() {
val e = RuntimeException("database or disk is full (code 13 SQLITE_FULL)")
assertEquals(FailureCategory.OUT_OF_STORAGE, FailureCategory.of(e))
}
@Test
fun erkennung_ist_unabhaengig_von_gross_und_kleinschreibung() {
assertEquals(FailureCategory.OUT_OF_STORAGE, FailureCategory.of(IOException("NO SPACE LEFT ON DEVICE")))
}
@Test
fun speicher_voll_in_tiefer_ursachenkette_wird_erkannt() {
val root = IOException("No space left on device")
val chain = IllegalStateException("a", RuntimeException("b", RuntimeException("c", root)))
assertEquals(FailureCategory.OUT_OF_STORAGE, FailureCategory.of(chain))
}
// --- Sonstige Fehler -----------------------------------------------------------------
@Test
fun gewoehnliche_fehler_sind_other() {
assertEquals(FailureCategory.OTHER, FailureCategory.of(FileNotFoundException("weg")))
assertEquals(FailureCategory.OTHER, FailureCategory.of(IllegalStateException()))
assertEquals(FailureCategory.OTHER, FailureCategory.of(RuntimeException(null as String?)))
}
@Test
fun zyklische_ursachenkette_fuehrt_nicht_zur_endlosschleife() {
val a = RuntimeException("a")
val b = RuntimeException("b", a)
a.initCause(b)
assertEquals(FailureCategory.OTHER, FailureCategory.of(a))
}
// --- runCatchingCancellable ----------------------------------------------------------
@Test
fun runCatchingCancellable_liefert_erfolg_und_fehler_als_result() {
assertEquals(42, runCatchingCancellable { 42 }.getOrNull())
val failure = runCatchingCancellable<Int> { throw IOException("x") }
assertTrue(failure.exceptionOrNull() is IOException)
}
@Test
fun runCatchingCancellable_reicht_coroutine_abbruch_weiter() {
try {
runCatchingCancellable<Unit> { throw CancellationException("abgebrochen") }
fail("CancellationException darf nicht verschluckt werden")
} catch (expected: CancellationException) {
assertEquals("abgebrochen", expected.message)
}
}
}
@@ -0,0 +1,151 @@
package de.medapp.wunddoku.domain.usecase
import de.medapp.wunddoku.domain.model.CheckItem
import de.medapp.wunddoku.domain.model.DocumentationBlock
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Blockstatus-Tests: Wann gilt ein Dokumentationsblock als "bearbeitet"?
* Diese Regel steuert Fortschrittsanzeige UND PDF-Export und muss deshalb exakt stimmen.
*/
class BlockEditedPolicyTest {
private var idCounter = 0
private fun item(
checked: Boolean,
isFreeText: Boolean = false,
freeTextValue: String = "",
isStaticHint: Boolean = false,
inputSpec: String = "",
): CheckItem {
idCounter++
return CheckItem(
checkItemStateId = "state-$idCounter",
definitionId = "def-$idCounter",
label = "Eintrag $idCounter",
order = idCounter,
checked = checked,
isFreeText = isFreeText,
freeTextValue = freeTextValue,
isStaticHint = isStaticHint,
inputSpec = inputSpec,
)
}
private fun block(vararg items: CheckItem) = DocumentationBlock(
blockStateId = "block-state",
definitionId = "block-def",
title = "Testblock",
order = 1,
items = items.toList(),
)
// --- nicht bearbeitet ----------------------------------------------------------------
@Test
fun block_ohne_eintraege_ist_nicht_bearbeitet() {
assertFalse(BlockEditedPolicy.isEdited(block()))
}
@Test
fun block_ohne_angehakten_eintrag_ist_nicht_bearbeitet() {
val b = block(item(checked = false), item(checked = false), item(checked = false, isFreeText = true))
assertFalse(BlockEditedPolicy.isEdited(b))
}
@Test
fun nicht_angehakter_freitext_mit_text_zaehlt_nicht_als_bearbeitung() {
// Das Freitextfeld ist in der UI gesperrt, solange die Checkbox nicht angehakt ist.
val b = block(item(checked = false, isFreeText = true, freeTextValue = "Text ohne Haken"))
assertFalse(BlockEditedPolicy.isEdited(b))
}
@Test
fun nur_hinweistexte_machen_einen_block_nicht_bearbeitet() {
// Hinweistexte sind technisch immer checked = true, aber keine Nutzereingabe.
val b = block(
item(checked = true, isStaticHint = true),
item(checked = true, isStaticHint = true),
)
assertFalse(BlockEditedPolicy.isEdited(b))
}
@Test
fun hinweistexte_plus_nicht_angehakte_eintraege_sind_nicht_bearbeitet() {
val b = block(
item(checked = true, isStaticHint = true),
item(checked = false),
item(checked = false, isFreeText = true),
)
assertFalse(BlockEditedPolicy.isEdited(b))
}
// --- bearbeitet ----------------------------------------------------------------------
@Test
fun ein_angehakter_normaler_eintrag_macht_den_block_bearbeitet() {
assertTrue(BlockEditedPolicy.isEdited(block(item(checked = true))))
}
@Test
fun angehakter_freitext_zaehlt_auch_ohne_eingegebenen_text() {
val b = block(item(checked = true, isFreeText = true, freeTextValue = ""))
assertTrue(BlockEditedPolicy.isEdited(b))
}
@Test
fun angehakter_freitext_mit_text_ist_bearbeitet() {
val b = block(item(checked = true, isFreeText = true, freeTextValue = "Rötung am Wundrand"))
assertTrue(BlockEditedPolicy.isEdited(b))
}
@Test
fun angehakter_eintrag_mit_strukturierter_eingabe_ist_bearbeitet() {
val b = block(item(checked = true, freeTextValue = "3", inputSpec = "spec"))
assertTrue(BlockEditedPolicy.isEdited(b))
}
@Test
fun hinweistext_wird_ignoriert_sobald_ein_echter_eintrag_angehakt_ist() {
val b = block(
item(checked = true, isStaticHint = true),
item(checked = true),
)
assertTrue(BlockEditedPolicy.isEdited(b))
}
@Test
fun mehrere_angehakte_eintraege_sind_bearbeitet() {
val b = block(item(checked = true), item(checked = true), item(checked = false))
assertTrue(BlockEditedPolicy.isEdited(b))
}
// --- Zusammenspiel mit DocumentationBlock.isEdited ------------------------------------
@Test
fun documentation_block_isEdited_delegiert_an_die_policy() {
val edited = block(item(checked = true))
val untouched = block(item(checked = false), item(checked = true, isStaticHint = true))
assertEquals(BlockEditedPolicy.isEdited(edited), edited.isEdited)
assertEquals(BlockEditedPolicy.isEdited(untouched), untouched.isEdited)
assertTrue(edited.isEdited)
assertFalse(untouched.isEdited)
}
@Test
fun isEdited_wird_aus_dem_aktuellen_inhalt_abgeleitet_und_nicht_gespeichert() {
val untouched = block(item(checked = false))
assertFalse(untouched.isEdited)
val nowChecked = untouched.copy(items = untouched.items.map { it.copy(checked = true) })
assertTrue(nowChecked.isEdited)
val uncheckedAgain = nowChecked.copy(items = nowChecked.items.map { it.copy(checked = false) })
assertFalse(uncheckedAgain.isEdited)
}
}
@@ -0,0 +1,80 @@
package de.medapp.wunddoku.media
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Reine JVM-Tests für [ImageDownsampler.computeInSampleSize] - der Kern der speicherschonenden
* Bilddekodierung (Schutz vor OutOfMemoryError bei großen Kamerafotos) und der Robustheit
* gegenüber ungültigen Abmessungen defekter Bilddateien.
*/
class ImageDownsamplerTest {
private val target = ImageDownsampler.MAX_STORED_DIMENSION_PX
// --- ungültige / defekte Abmessungen -------------------------------------------------
@Test
fun ungueltige_abmessungen_defekter_bilder_liefern_1_statt_zu_werfen() {
assertEquals(1, ImageDownsampler.computeInSampleSize(0, 0, target))
assertEquals(1, ImageDownsampler.computeInSampleSize(-1, -1, target))
assertEquals(1, ImageDownsampler.computeInSampleSize(4000, 0, target))
assertEquals(1, ImageDownsampler.computeInSampleSize(0, 4000, target))
assertEquals(1, ImageDownsampler.computeInSampleSize(4000, 3000, 0))
assertEquals(1, ImageDownsampler.computeInSampleSize(4000, 3000, -5))
}
// --- Bilder, die nicht verkleinert werden müssen -------------------------------------
@Test
fun kleine_bilder_werden_nicht_heruntergerechnet() {
assertEquals(1, ImageDownsampler.computeInSampleSize(100, 100, target))
assertEquals(1, ImageDownsampler.computeInSampleSize(target, target, target))
assertEquals(1, ImageDownsampler.computeInSampleSize(1, 1, target))
}
// --- Große Bilder (Speichermangel vermeiden) -----------------------------------------
@Test
fun grosse_kamerafotos_werden_deutlich_heruntergerechnet() {
// 48 MP ≈ 8000 x 6000
val sample = ImageDownsampler.computeInSampleSize(8000, 6000, target)
assertTrue("erwartet >= 2, war $sample", sample >= 2)
}
@Test
fun ergebnis_ist_immer_eine_zweierpotenz_und_haelt_die_zielgroesse_ein() {
val sizes = listOf(1, 2, 99, 100, 1999, 2000, 2001, 3999, 4000, 4001, 6000, 8000, 12000, 20000, 65535)
for (width in sizes) {
for (height in sizes) {
val sample = ImageDownsampler.computeInSampleSize(width, height, target)
val largest = maxOf(width, height)
assertTrue("Zweierpotenz erwartet bei ${width}x$height, war $sample", sample >= 1 && sample and (sample - 1) == 0)
if (largest > target) {
// Nie unter die Zielgröße dekodieren ...
assertTrue("${width}x$height: dekodiert wäre ${largest / sample}", largest / sample >= target)
// ... aber auch nie mehr als doppelt so groß wie nötig (kein Speicher-Verschwenden).
assertTrue("${width}x$height: dekodiert wäre ${largest / sample}", largest / sample < 2 * target)
} else {
assertEquals(1, sample)
}
}
}
}
@Test
fun extrem_grosse_werte_laufen_nicht_ueber() {
val sample = ImageDownsampler.computeInSampleSize(Int.MAX_VALUE, Int.MAX_VALUE, target)
assertTrue(sample >= 1)
assertTrue(sample and (sample - 1) == 0)
}
@Test
fun thumbnail_ziel_ist_kleiner_als_speicherziel() {
val full = ImageDownsampler.computeInSampleSize(2000, 1500, target)
val thumb = ImageDownsampler.computeInSampleSize(2000, 1500, ImageDownsampler.THUMBNAIL_MAX_DIMENSION_PX)
assertTrue(thumb > full)
}
}
@@ -0,0 +1,245 @@
package de.medapp.wunddoku.pdf
import de.medapp.wunddoku.domain.model.CheckItem
import de.medapp.wunddoku.domain.model.Documentation
import de.medapp.wunddoku.domain.model.DocumentationBlock
import de.medapp.wunddoku.domain.model.DocumentationStatus
import de.medapp.wunddoku.domain.model.PatientStammdatenSnapshot
import de.medapp.wunddoku.domain.model.Photo
import de.medapp.wunddoku.domain.model.Signature
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneId
/**
* Blockstatus im PDF-Export: Nur bearbeitete Blöcke und nur angehakte Optionen gelangen ins PDF,
* alles wird aus den historischen Snapshots gebildet und stabil sortiert.
*/
class DocumentationToPdfExportModelMapperTest {
private val snapshot = PatientStammdatenSnapshot(
firstName = "Max",
lastName = "Mustermann",
birthDate = LocalDate.of(1980, 3, 14),
street = "Musterstraße 1",
postalCode = "12345",
city = "Musterstadt",
)
// Mittag in der Systemzeitzone -> das lokale Datum ist zeitzonenunabhängig der 23.08.2026.
private val createdAt: Instant =
LocalDate.of(2026, 8, 23).atTime(12, 0).atZone(ZoneId.systemDefault()).toInstant()
private fun item(
id: String,
order: Int,
checked: Boolean,
label: String = "Label $id",
isFreeText: Boolean = false,
freeTextValue: String = "",
isStaticHint: Boolean = false,
inputSpec: String = "",
) = CheckItem(
checkItemStateId = "state-$id",
definitionId = id,
label = label,
order = order,
checked = checked,
isFreeText = isFreeText,
freeTextValue = freeTextValue,
isStaticHint = isStaticHint,
inputSpec = inputSpec,
)
private fun block(id: String, order: Int, title: String = "Titel $id", items: List<CheckItem>) =
DocumentationBlock("state-$id", id, title, order, items)
private fun documentation(
blocks: List<DocumentationBlock>,
photos: List<Photo> = emptyList(),
signatures: List<Signature> = emptyList(),
) = Documentation(
id = "doc-1",
patientId = "patient-1",
patientSnapshot = snapshot,
status = DocumentationStatus.IN_PROGRESS,
createdAt = createdAt,
updatedAt = createdAt,
continuedFromDocumentationId = null,
catalogVersion = 4,
blocks = blocks,
photos = photos,
signatures = signatures,
)
// --- Blockfilter ---------------------------------------------------------------------
@Test
fun nur_bearbeitete_bloecke_werden_exportiert() {
val doc = documentation(
listOf(
block("a", 1, items = listOf(item("a1", 1, checked = true))),
block("b", 2, items = listOf(item("b1", 1, checked = false))),
block("c", 3, items = listOf(item("c1", 1, checked = true))),
)
)
val model = DocumentationToPdfExportModelMapper.map(doc)
assertEquals(listOf("Titel a", "Titel c"), model.blocks.map { it.title })
}
@Test
fun block_mit_nur_hinweistexten_wird_nicht_exportiert() {
val doc = documentation(
listOf(
block(
"hint", 1,
items = listOf(
item("h1", 1, checked = true, isStaticHint = true),
item("h2", 2, checked = false),
),
),
)
)
assertTrue(DocumentationToPdfExportModelMapper.map(doc).blocks.isEmpty())
}
@Test
fun dokumentation_ohne_bearbeiteten_block_ergibt_leere_blockliste() {
val doc = documentation(listOf(block("a", 1, items = listOf(item("a1", 1, checked = false)))))
assertTrue(DocumentationToPdfExportModelMapper.map(doc).blocks.isEmpty())
}
// --- Itemfilter ----------------------------------------------------------------------
@Test
fun nur_angehakte_optionen_erscheinen_im_pdf() {
val doc = documentation(
listOf(
block(
"a", 1,
items = listOf(
item("a1", 1, checked = true, label = "Ja"),
item("a2", 2, checked = false, label = "Nein"),
item("a3", 3, checked = true, label = "Auch ja"),
),
),
)
)
val items = DocumentationToPdfExportModelMapper.map(doc).blocks.single().items
assertEquals(listOf("Ja", "Auch ja"), items.map { it.label })
}
@Test
fun hinweistexte_eines_bearbeiteten_blocks_bleiben_im_pdf_erhalten() {
val doc = documentation(
listOf(
block(
"a", 1,
items = listOf(
item("hint", 1, checked = true, label = "Reinigen mit:", isStaticHint = true),
item("a2", 2, checked = true, label = "Ringer-Lösung"),
),
),
)
)
val items = DocumentationToPdfExportModelMapper.map(doc).blocks.single().items
assertEquals(listOf("Reinigen mit:", "Ringer-Lösung"), items.map { it.label })
}
@Test
fun freitext_und_strukturierte_eingabe_werden_unveraendert_durchgereicht() {
val doc = documentation(
listOf(
block(
"a", 1,
items = listOf(
item("f", 1, checked = true, label = "Freitext", isFreeText = true, freeTextValue = "Rötung"),
item("s", 2, checked = true, label = "Maße", freeTextValue = "3|4", inputSpec = "SPEC"),
),
),
)
)
val items = DocumentationToPdfExportModelMapper.map(doc).blocks.single().items
assertEquals(PdfExportItem("Freitext", isFreeText = true, freeTextValue = "Rötung", inputSpec = ""), items[0])
assertEquals(PdfExportItem("Maße", isFreeText = false, freeTextValue = "3|4", inputSpec = "SPEC"), items[1])
}
// --- Sortierung ----------------------------------------------------------------------
@Test
fun bloecke_und_optionen_werden_nach_order_sortiert() {
val doc = documentation(
listOf(
block("zweiter", 2, items = listOf(item("z1", 1, checked = true))),
block(
"erster", 1,
items = listOf(
item("e2", 2, checked = true, label = "B"),
item("e1", 1, checked = true, label = "A"),
),
),
)
)
val blocks = DocumentationToPdfExportModelMapper.map(doc).blocks
assertEquals(listOf("Titel erster", "Titel zweiter"), blocks.map { it.title })
assertEquals(listOf("A", "B"), blocks.first().items.map { it.label })
}
@Test
fun fotos_und_signaturen_werden_nach_orderIndex_sortiert() {
val photos = listOf(
Photo("p2", "doc-1", "zweites.jpg", 1, createdAt),
Photo("p1", "doc-1", "erstes.jpg", 0, createdAt),
)
val signatures = listOf(
Signature("s2", "doc-1", "Berta", "b.png", 1, createdAt),
Signature("s1", "doc-1", "Anna", "a.png", 0, createdAt),
)
val model = DocumentationToPdfExportModelMapper.map(documentation(emptyList(), photos, signatures))
assertEquals(listOf("erstes.jpg", "zweites.jpg"), model.photos.map { it.fileName })
assertEquals(listOf("Anna", "Berta"), model.signatures.map { it.signerName })
assertEquals(listOf("a.png", "b.png"), model.signatures.map { it.fileName })
}
@Test
fun alle_signaturen_werden_exportiert_ohne_obergrenze() {
val signatures = (0 until 5).map { Signature("s$it", "doc-1", "Person $it", "s$it.png", it, createdAt) }
val model = DocumentationToPdfExportModelMapper.map(documentation(emptyList(), signatures = signatures))
assertEquals(5, model.signatures.size)
}
// --- Kopfdaten (Snapshot) ------------------------------------------------------------
@Test
fun kopfdaten_stammen_aus_dem_patienten_snapshot_der_dokumentation() {
val model = DocumentationToPdfExportModelMapper.map(documentation(emptyList()))
assertEquals("doc-1", model.documentationId)
assertEquals("Max Mustermann", model.patientFullName)
assertEquals(LocalDate.of(1980, 3, 14), model.patientBirthDate)
assertEquals("Musterstraße 1, 12345 Musterstadt", model.patientDisplayAddress)
assertEquals(LocalDate.of(2026, 8, 23), model.createdAt)
}
@Test
fun dateiname_folgt_dem_schema_und_nutzt_snapshot_und_erstelldatum() {
val model = DocumentationToPdfExportModelMapper.map(documentation(emptyList()))
assertEquals("2026-08-23_Max_Mustermann_1980-03-14.pdf", model.fileName)
}
}
@@ -0,0 +1,106 @@
package de.medapp.wunddoku.pdf
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Reine JVM-Tests für die Fehlermeldungen des PDF-Bereichs: jede Fehlerart hat eine
* verständliche deutsche Meldung, und technische Details (Pfade, Exception-Texte - können
* Patientennamen enthalten) gelangen nie in die Anzeige.
*/
class ErrorMessagesTest {
private val sensitive = "/data/user/0/de.medapp.wunddoku/files/pdf/2026-08-19_Max_Mustermann_1980-03-14.pdf"
private val exportErrors: List<PdfExportError> = listOf(
PdfExportError.DocumentationNotFound,
PdfExportError.DirectoryCreationFailed(sensitive),
PdfExportError.WriteFailed(sensitive),
PdfExportError.Unexpected(sensitive),
PdfExportError.OutOfMemory,
PdfExportError.InsufficientStorage,
)
private val openFailures: List<PdfOpenResult.Failure> = listOf(
PdfOpenResult.Failure.FileNotFound,
PdfOpenResult.Failure.UriResolutionFailed(sensitive),
PdfOpenResult.Failure.NoViewerAvailable,
PdfOpenResult.Failure.Unexpected(sensitive),
)
private val shareFailures: List<PdfShareResult.Failure> = listOf(
PdfShareResult.Failure.UriResolutionFailed(sensitive),
PdfShareResult.Failure.NoAppAvailable,
PdfShareResult.Failure.Unexpected(sensitive),
)
private val imageShareFailures: List<PdfImageShareResult.Failure> = listOf(
PdfImageShareResult.Failure.NoImages,
PdfImageShareResult.Failure.UriResolutionFailed(sensitive),
PdfImageShareResult.Failure.NoAppAvailable,
PdfImageShareResult.Failure.Unexpected(sensitive),
)
private val imageConversionFailures: List<PdfToImagesResult.Failure> = listOf(
PdfToImagesResult.Failure.FileNotFound,
PdfToImagesResult.Failure.EmptyDocument,
PdfToImagesResult.Failure.Unexpected(sensitive),
PdfToImagesResult.Failure.OutOfMemory,
PdfToImagesResult.Failure.InsufficientStorage,
)
private fun assertSafe(message: String) {
assertTrue("Meldung darf nicht leer sein", message.isNotBlank())
assertFalse("Meldung enthält Pfad/Patientendaten: $message", message.contains("Mustermann"))
assertFalse("Meldung enthält Pfad: $message", message.contains("/data/"))
assertFalse("Meldung enthält Dateiendung/Pfad: $message", message.contains(".pdf"))
}
@Test
fun alle_pdf_export_fehler_haben_eine_sichere_meldung() {
exportErrors.forEach { assertSafe(it.toUserMessage()) }
}
@Test
fun alle_oeffnen_fehler_haben_eine_sichere_meldung() {
openFailures.forEach { assertSafe(it.toUserMessage()) }
}
@Test
fun alle_pdf_teilen_fehler_haben_eine_sichere_meldung() {
shareFailures.forEach { assertSafe(it.toUserMessage()) }
}
@Test
fun alle_bilder_teilen_fehler_haben_eine_sichere_meldung() {
imageShareFailures.forEach { assertSafe(it.toUserMessage()) }
}
@Test
fun alle_bildkonvertierungs_fehler_haben_eine_sichere_meldung() {
imageConversionFailures.forEach { assertSafe(it.toUserMessage()) }
}
@Test
fun speichermangel_hat_eine_eigene_aussagekraeftige_meldung() {
assertNotEquals(PdfExportError.OutOfMemory.toUserMessage(), PdfExportError.InsufficientStorage.toUserMessage())
assertNotEquals(
PdfExportError.OutOfMemory.toUserMessage(),
PdfExportError.Unexpected("x").toUserMessage(),
)
assertNotEquals(
PdfToImagesResult.Failure.OutOfMemory.toUserMessage(),
PdfToImagesResult.Failure.InsufficientStorage.toUserMessage(),
)
assertTrue(PdfExportError.InsufficientStorage.toUserMessage().contains("Speicher"))
assertTrue(PdfExportError.OutOfMemory.toUserMessage().contains("Arbeitsspeicher"))
}
@Test
fun unterschiedliche_fehlerarten_erhalten_unterschiedliche_meldungen() {
val messages = exportErrors.map { it.toUserMessage() }
assertTrue(messages.toSet().size == messages.size)
}
}
@@ -0,0 +1,175 @@
package de.medapp.wunddoku.pdf
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import java.time.LocalDate
/**
* Reine JVM-Tests (keine Android-Abhängigkeit) für das Dateinamensschema
* `Erstelldatum_Vorname_Nachname_Geburtsdatum.pdf`.
*/
class PdfFileNameGeneratorTest {
private val created = LocalDate.of(2026, 8, 23)
private val birth = LocalDate.of(1980, 3, 14)
private fun name(
first: String,
last: String,
createdAt: LocalDate = created,
birthDate: LocalDate = birth,
): String = PdfFileNameGenerator.generate(createdAt, first, last, birthDate)
// --- Grundschema ---------------------------------------------------------------------
@Test
fun standardfall_folgt_dem_vereinbarten_schema() {
assertEquals("2026-08-23_Max_Mustermann_1980-03-14.pdf", name("Max", "Mustermann"))
}
@Test
fun datumsteile_werden_mit_fuehrenden_nullen_im_iso_format_ausgegeben() {
val result = name("Max", "Mustermann", createdAt = LocalDate.of(2026, 1, 5), birthDate = LocalDate.of(1999, 2, 3))
assertEquals("2026-01-05_Max_Mustermann_1999-02-03.pdf", result)
}
@Test
fun schaltjahr_geburtsdatum_wird_korrekt_ausgegeben() {
val result = name("Max", "Mustermann", birthDate = LocalDate.of(2000, 2, 29))
assertEquals("2026-08-23_Max_Mustermann_2000-02-29.pdf", result)
}
@Test
fun ergebnis_ist_deterministisch() {
assertEquals(name("Max", "Mustermann"), name("Max", "Mustermann"))
}
@Test
fun verschiedene_erstelldaten_fuehren_zu_verschiedenen_dateinamen() {
val a = name("Max", "Mustermann", createdAt = LocalDate.of(2026, 8, 23))
val b = name("Max", "Mustermann", createdAt = LocalDate.of(2026, 8, 24))
assertNotEquals(a, b)
}
// --- Umlaute / Diakritika ------------------------------------------------------------
@Test
fun deutsche_umlaute_werden_transliteriert_statt_entfernt() {
assertEquals("2026-08-23_Max_Mueller_1980-03-14.pdf", name("Max", "Müller"))
assertEquals("2026-08-23_Aenne_Oezdemir_1980-03-14.pdf", name("Änne", "Özdemir"))
assertEquals("2026-08-23_Uwe_Ueberall_1980-03-14.pdf", name("Uwe", "Überall"))
}
@Test
fun eszett_wird_zu_doppel_s() {
assertEquals("2026-08-23_Max_Weiss_1980-03-14.pdf", name("Max", "Weiß"))
}
@Test
fun mueller_und_mller_bleiben_unterscheidbar() {
assertNotEquals(name("Max", "Müller"), name("Max", "Mller"))
}
@Test
fun andere_diakritika_werden_entfernt() {
assertEquals("2026-08-23_Jose_Celik_1980-03-14.pdf", name("José", "Çelik"))
}
// --- Leerzeichen / Sonderzeichen -----------------------------------------------------
@Test
fun leerzeichen_werden_zu_unterstrichen() {
assertEquals("2026-08-23_Anna_Maria_Mustermann_1980-03-14.pdf", name("Anna Maria", "Mustermann"))
}
@Test
fun mehrfaches_whitespace_wird_zu_einem_unterstrich_zusammengefasst() {
assertEquals("2026-08-23_Anna_Maria_Mustermann_1980-03-14.pdf", name("Anna Maria", "Mustermann"))
assertEquals("2026-08-23_Anna_Maria_Mustermann_1980-03-14.pdf", name("Anna\t\nMaria", "Mustermann"))
}
@Test
fun mehrfache_unterstriche_werden_zusammengefasst() {
assertEquals("2026-08-23_Max_Muster_mann_1980-03-14.pdf", name("Max", "Muster__mann"))
}
@Test
fun fuehrende_und_nachgestellte_trennzeichen_werden_entfernt() {
val expected = "2026-08-23_Max_Mustermann_1980-03-14.pdf"
assertEquals(expected, name(" Max ", "Mustermann"))
assertEquals(expected, name("_Max_", "Mustermann"))
assertEquals(expected, name("-Max-", "Mustermann"))
}
@Test
fun bindestrich_im_doppelnamen_bleibt_erhalten() {
assertEquals("2026-08-23_Aenne-Marie_Meyer-Luedenscheidt_1980-03-14.pdf", name("Änne-Marie", "Meyer-Lüdenscheidt"))
}
@Test
fun apostroph_und_punkt_werden_entfernt() {
assertEquals("2026-08-23_Sean_OBrien_1980-03-14.pdf", name("Sean", "O'Brien"))
assertEquals("2026-08-23_Max_Dr_Mustermann_1980-03-14.pdf", name("Max", "Dr. Mustermann"))
}
// --- Sicherheit (Pfad-/Dateisystemzeichen) --------------------------------------------
@Test
fun pfadseparatoren_und_punkte_koennen_keinen_pfad_erzeugen() {
assertEquals(
"2026-08-23_Unbekannt_etcpasswd_1980-03-14.pdf",
name("../..", "../etc/passwd"),
)
}
@Test
fun fuer_dateinamen_ungueltige_zeichen_werden_entfernt() {
assertEquals(
"2026-08-23_MaxMuster_Mustermann_1980-03-14.pdf",
name("Max:Mus*ter?", "Mustermann"),
)
assertEquals(
"2026-08-23_Max_Mustermann_1980-03-14.pdf",
name("Ma<x>|\"", "Muster\\mann"),
)
}
@Test
fun emojis_werden_entfernt() {
assertEquals("2026-08-23_Max_Mustermann_1980-03-14.pdf", name("Max😀", "Mustermann"))
}
@Test
fun dateiname_besteht_immer_nur_aus_sicheren_zeichen_und_endet_auf_pdf() {
val inputs = listOf(
"Max" to "Mustermann",
"Änne-Marie" to "Özdemir Weiß",
"José" to "O'Brien",
"../../x" to "a\\b/c",
" " to "",
"😀" to "???",
"Ma<x>|\"" to "Muster\\mann",
)
val allowed = Regex("^[A-Za-z0-9_.-]+$")
for ((first, last) in inputs) {
val result = name(first, last)
assertTrue("Unsicherer Dateiname: $result", allowed.matches(result))
assertTrue("Falsche Endung: $result", result.endsWith(".pdf"))
}
}
// --- Platzhalter für leere Namen -----------------------------------------------------
@Test
fun leerer_oder_blanker_name_wird_zu_unbekannt() {
assertEquals("2026-08-23_Unbekannt_Mustermann_1980-03-14.pdf", name("", "Mustermann"))
assertEquals("2026-08-23_Max_Unbekannt_1980-03-14.pdf", name("Max", " "))
}
@Test
fun name_nur_aus_ungueltigen_zeichen_wird_zu_unbekannt() {
assertEquals("2026-08-23_Unbekannt_Unbekannt_1980-03-14.pdf", name("???", "***"))
}
}