Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 742ef39f05 | |||
| 489c4fa070 | |||
| e9403801d6 | |||
| 18a7af800f | |||
| 51370a9172 | |||
| 26cc81388e | |||
| dcfa11bef9 | |||
| e6bc99d144 | |||
| 2bc0d959e1 | |||
| b77f7f0f20 | |||
| 70bdc84cfa | |||
| cefecb86dd | |||
| 1fdcceb149 |
@@ -7,7 +7,7 @@ Die App ermöglicht die Verwaltung von Patienten und Wunddokumentationen,
|
||||
unterstützt Fotos und digitale Unterschriften und erzeugt aus
|
||||
abgeschlossenen Dokumentationen PDF-Dateien.
|
||||
|
||||
> **Projektstatus:** In Entwicklung
|
||||
> **Projektstatus:** beta1.1
|
||||
|
||||
------------------------------------------------------------------------
|
||||
|
||||
@@ -344,32 +344,6 @@ werden kann.
|
||||
- JDK über Android Studio
|
||||
- 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
|
||||
@@ -387,40 +361,40 @@ Run
|
||||
### Phase 2 -- Projektgrundgerüst
|
||||
|
||||
- [x] Android-Projekt
|
||||
- [ ] Room-Datenbank
|
||||
- [ ] Entities
|
||||
- [ ] DAOs
|
||||
- [ ] Repositories
|
||||
- [ ] Navigation
|
||||
- [ ] ViewModels
|
||||
- [x] Room-Datenbank
|
||||
- [x] Entities
|
||||
- [x] DAOs
|
||||
- [x] Repositories
|
||||
- [x] Navigation
|
||||
- [x] ViewModels
|
||||
|
||||
### Phase 3 -- Kernfunktionalität
|
||||
|
||||
- [ ] Patientenverwaltung
|
||||
- [ ] Patientenliste
|
||||
- [ ] Wunddokumentationsliste
|
||||
- [ ] Dokumentation erstellen
|
||||
- [ ] Dokumentationsblöcke
|
||||
- [ ] Blocknavigation
|
||||
- [ ] Autosave
|
||||
- [ ] Dokumentation bearbeiten
|
||||
- [ ] Dokumentation fortsetzen
|
||||
- [x] Patientenverwaltung
|
||||
- [x] Patientenliste
|
||||
- [x] Wunddokumentationsliste
|
||||
- [x] Dokumentation erstellen
|
||||
- [x] Dokumentationsblöcke
|
||||
- [x] Blocknavigation
|
||||
- [x] Autosave
|
||||
- [x] Dokumentation bearbeiten
|
||||
- [x] Dokumentation fortsetzen
|
||||
|
||||
### Phase 4 -- Medien
|
||||
|
||||
- [ ] Fotoimport
|
||||
- [ ] Bildverwaltung
|
||||
- [ ] Digitale Signatur
|
||||
- [ ] Mehrere Signaturen
|
||||
- [x] Fotoimport
|
||||
- [x] Bildverwaltung
|
||||
- [x] Digitale Signatur
|
||||
- [x] Mehrere Signaturen
|
||||
|
||||
### Phase 5 -- PDF
|
||||
|
||||
- [ ] PDF-Exporter
|
||||
- [ ] Dokumentationsblöcke
|
||||
- [ ] Signaturen
|
||||
- [ ] Fotoanhang
|
||||
- [ ] PDF anzeigen
|
||||
- [ ] PDF teilen
|
||||
- [x] PDF-Exporter
|
||||
- [x] Dokumentationsblöcke
|
||||
- [x] Signaturen
|
||||
- [x] Fotoanhang
|
||||
- [x] PDF anzeigen
|
||||
- [x] PDF teilen
|
||||
|
||||
### Phase 6 -- Qualität und Stabilität
|
||||
|
||||
@@ -431,29 +405,15 @@ Run
|
||||
- [ ] Tablet-Optimierung
|
||||
- [ ] Datenschutz-Review
|
||||
|
||||
------------------------------------------------------------------------
|
||||
### Phase 7 -- Multi-Device-Synchronisation
|
||||
|
||||
## Dokumentation
|
||||
|
||||
Die ausführliche technische Architektur befindet sich in der
|
||||
Projektdokumentation:
|
||||
|
||||
``` text
|
||||
docs/
|
||||
└── Architekturplanung.md
|
||||
```
|
||||
|
||||
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.
|
||||
- [ ] Geräteidentität und Pairing
|
||||
- [ ] Automatische Geräteerkennung im WLAN
|
||||
- [ ] Differenzabgleich / Sync-Protokoll
|
||||
- [ ] Patienten synchronisieren
|
||||
- [ ] Wunddokumentationen synchronisieren
|
||||
- [ ] Fotos synchronisieren
|
||||
- [ ] Konflikterkennung und Konfliktauflösung
|
||||
- [ ] Automatische Synchronisation
|
||||
- [ ] Sync-Status und manueller Fallback
|
||||
- [ ] Multi-Device- und Recovery-Tests
|
||||
|
||||
+378
@@ -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 })
|
||||
}
|
||||
}
|
||||
+274
@@ -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")
|
||||
}
|
||||
}
|
||||
+545
@@ -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 })
|
||||
}
|
||||
}
|
||||
+156
@@ -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)
|
||||
}
|
||||
}
|
||||
+114
@@ -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")
|
||||
}
|
||||
+473
@@ -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)
|
||||
}
|
||||
}
|
||||
+151
@@ -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.toSummary
|
||||
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.DocumentationStatus
|
||||
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.signature.SignatureBitmapValidation
|
||||
import de.medapp.wunddoku.signature.SignatureStorage
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import java.io.File
|
||||
@@ -201,6 +204,7 @@ class DocumentationRepository(
|
||||
// Unterschriften werden bewusst NICHT kopiert (siehe Doc-Kommentar oben).
|
||||
|
||||
if (copyPhotos) {
|
||||
try {
|
||||
for (sourcePhoto in source.photos) {
|
||||
val copiedFileName = photoStorage.duplicate(
|
||||
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()
|
||||
@@ -250,16 +261,22 @@ class DocumentationRepository(
|
||||
|
||||
suspend fun addPhoto(documentationId: String, sourceUri: Uri): Photo {
|
||||
val fileName = photoStorage.importFrom(documentationId, sourceUri)
|
||||
val nextOrder = photoDao.getMaxOrderIndex(documentationId) + 1
|
||||
val now = Instant.now()
|
||||
val entity = PhotoEntity(
|
||||
val entity = try {
|
||||
val nextOrder = photoDao.getMaxOrderIndex(documentationId) + 1
|
||||
PhotoEntity(
|
||||
id = UUID.randomUUID().toString(),
|
||||
documentationId = documentationId,
|
||||
fileName = fileName,
|
||||
orderIndex = nextOrder,
|
||||
createdAt = now.toEpochMilli(),
|
||||
)
|
||||
photoDao.insert(entity)
|
||||
).also { photoDao.insert(it) }
|
||||
} 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)
|
||||
}
|
||||
|
||||
@@ -284,10 +301,22 @@ class DocumentationRepository(
|
||||
* verwendet) und delegiert die eigentliche Erzeugung an [pdfExporter].
|
||||
*/
|
||||
suspend fun exportPdf(documentationId: String): PdfExportResult {
|
||||
return try {
|
||||
val documentation = getFullDocumentation(documentationId)
|
||||
?: return PdfExportResult.Failure(PdfExportError.DocumentationNotFound)
|
||||
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.
|
||||
*/
|
||||
suspend fun convertPdfToImages(documentationId: String, pdfFile: File): PdfToImagesResult =
|
||||
try {
|
||||
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
|
||||
@@ -352,18 +391,24 @@ class DocumentationRepository(
|
||||
require(!SignatureBitmapValidation.isBlank(bitmap)) { "Eine leere Signatur kann nicht gespeichert werden." }
|
||||
|
||||
val fileName = signatureStorage.save(documentationId, bitmap)
|
||||
val nextOrderIndex = signatureDao.getMaxOrderIndex(documentationId) + 1
|
||||
val now = Instant.now()
|
||||
val entity = SignatureEntity(
|
||||
val entity = try {
|
||||
SignatureEntity(
|
||||
id = UUID.randomUUID().toString(),
|
||||
documentationId = documentationId,
|
||||
signerName = trimmedName,
|
||||
fileName = fileName,
|
||||
orderIndex = nextOrderIndex,
|
||||
orderIndex = signatureDao.getMaxOrderIndex(documentationId) + 1,
|
||||
createdAt = now.toEpochMilli(),
|
||||
)
|
||||
signatureDao.insert(entity)
|
||||
woundCarerRepository.recordSignatureUsage(trimmedName, bitmap)
|
||||
).also { signatureDao.insert(it) }
|
||||
} catch (e: Throwable) {
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
@@ -28,11 +28,11 @@ import de.medapp.wunddoku.domain.model.InputSpec
|
||||
* Abschnitt 2.3b.
|
||||
*
|
||||
* [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 {
|
||||
|
||||
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,
|
||||
@@ -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",
|
||||
"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(
|
||||
BlockDefinition(
|
||||
@@ -60,15 +59,7 @@ object DocumentationCatalog {
|
||||
items = listOf(
|
||||
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_decubitus",
|
||||
label = "Decubitus",
|
||||
order = 3,
|
||||
inputSpec = InputSpec(
|
||||
pdfTemplate = "Decubitus Grad {0}",
|
||||
fields = listOf(InputFieldDef.Choice(label = "Grad", options = DECUBITUS_GRADE_OPTIONS)),
|
||||
),
|
||||
),
|
||||
CheckItemDefinition(id = "wundart_decubitus", label = "Decubitus", order = 3),
|
||||
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_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_nacl", label = "NaCl 0,9%", order = 3),
|
||||
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(
|
||||
id = "empfohlene_wundversorgung_getraenkte_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_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_nicht_ueber_wundrand_mit", label = "Nicht über Wundrand mit", order = 13, isFreeText = true),
|
||||
CheckItemDefinition(id = "empfohlene_wundversorgung_hinweis_wundrandschutz_mit", label = "Wundrandschutz mit (bis 3x Wo.):", order = 14, isStaticHint = true),
|
||||
CheckItemDefinition(id = "empfohlene_wundversorgung_wundrandschutz_ja", label = "Ja", order = 15),
|
||||
CheckItemDefinition(id = "empfohlene_wundversorgung_wundrandschutz_nein", label = "Nein", order = 16),
|
||||
CheckItemDefinition(id = "empfohlene_wundversorgung_nicht_ueber_wundrand_mit", label = "Nicht über Wundrand mit", order = 13),
|
||||
CheckItemDefinition(id = "empfohlene_wundversorgung_nicht_ueber_wundrand_mit_freitext", label = FREE_TEXT_LABEL, order = 14, isFreeText = true),
|
||||
CheckItemDefinition(id = "empfohlene_wundversorgung_hinweis_wundrandschutz", label = "Wundrandschutz:", order = 15, isStaticHint = true),
|
||||
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(
|
||||
id = "empfohlene_wundversorgung_wundrandschutz_tage_pro_woche",
|
||||
label = "Wie oft pro Woche?",
|
||||
order = 17,
|
||||
label = "Wie oft pro Woche",
|
||||
order = 19,
|
||||
inputSpec = InputSpec(
|
||||
pdfTemplate = "{0}x pro Woche",
|
||||
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_sekundaer_sterile_kompresse", label = "Sterile Kompresse", order = 19),
|
||||
CheckItemDefinition(id = "empfohlene_wundversorgung_saugkompresse", label = "Saugkompresse", order = 20),
|
||||
CheckItemDefinition(id = "empfohlene_wundversorgung_sonstige_wundauflage", label = "Sonstiges (Wundauflage)", order = 21, isFreeText = true),
|
||||
CheckItemDefinition(id = "empfohlene_wundversorgung_hinweis_fixieren_mit", label = "Fixieren mit:", order = 22, isStaticHint = true),
|
||||
CheckItemDefinition(id = "empfohlene_wundversorgung_mullbinde", label = "Mullbinde", order = 23),
|
||||
CheckItemDefinition(id = "empfohlene_wundversorgung_klebevlies", label = "Klebevlies", order = 24),
|
||||
CheckItemDefinition(id = "empfohlene_wundversorgung_folienverband", label = "Folienverband", order = 25),
|
||||
CheckItemDefinition(id = "empfohlene_wundversorgung_schlauchverband", label = "Schlauchverband", order = 26),
|
||||
CheckItemDefinition(id = "empfohlene_wundversorgung_kohaesive_fixierbinde", label = "Kohäsive Fixierbinde", order = 27, isFreeText = true),
|
||||
CheckItemDefinition(id = "empfohlene_wundversorgung_kompressionsverband_system", label = "Kompressionsverband / System", order = 28),
|
||||
CheckItemDefinition(id = "empfohlene_wundversorgung_wechselintervall", label = "Wechselintervall", order = 29, isFreeText = true),
|
||||
CheckItemDefinition(id = "empfohlene_wundversorgung_hinweis_bedarf", label = "+ bei Bedarf", order = 30, 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 = 21),
|
||||
CheckItemDefinition(id = "empfohlene_wundversorgung_saugkompresse", label = "Saugkompresse", order = 22),
|
||||
CheckItemDefinition(id = "empfohlene_wundversorgung_sonstige_wundauflage", label = "Sonstiges (Wundauflage)", order = 23, isFreeText = true),
|
||||
CheckItemDefinition(id = "empfohlene_wundversorgung_hinweis_fixieren_mit", label = "Fixieren mit:", order = 24, isStaticHint = true),
|
||||
CheckItemDefinition(id = "empfohlene_wundversorgung_mullbinde", label = "Mullbinde", order = 25),
|
||||
CheckItemDefinition(id = "empfohlene_wundversorgung_klebevlies", label = "Klebevlies", order = 26),
|
||||
CheckItemDefinition(id = "empfohlene_wundversorgung_folienverband", label = "Folienverband", order = 27),
|
||||
CheckItemDefinition(id = "empfohlene_wundversorgung_schlauchverband", label = "Schlauchverband", order = 28),
|
||||
CheckItemDefinition(id = "empfohlene_wundversorgung_kohaesive_fixierbinde", label = "Kohäsive Fixierbinde", order = 29),
|
||||
CheckItemDefinition(id = "empfohlene_wundversorgung_kohaesive_fixierbinde_freitext", label = FREE_TEXT_LABEL, order = 30, isFreeText = true),
|
||||
CheckItemDefinition(id = "empfohlene_wundversorgung_kompressionsverband_system", label = "Kompressionsverband / System", order = 31),
|
||||
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(
|
||||
id = "empfohlene_wundversorgung_hinweis_abklaerung",
|
||||
label = "Generell: bei akuter Verschlechterung sofort ärztliche Abklärung",
|
||||
order = 31,
|
||||
order = 35,
|
||||
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(
|
||||
val pdfTemplate: String,
|
||||
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. */
|
||||
@@ -86,7 +94,7 @@ data class InputSpec(
|
||||
* prüfbar) und ohne zusätzliche Abhängigkeit. Die verwendeten Trennzeichen kommen in Labels,
|
||||
* 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␝...`.
|
||||
*/
|
||||
object InputSpecCodec {
|
||||
@@ -94,6 +102,11 @@ object InputSpecCodec {
|
||||
private const val UNIT_SEPARATOR = '\u001F'
|
||||
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 {
|
||||
val records = mutableListOf(spec.pdfTemplate)
|
||||
for (field in spec.fields) {
|
||||
@@ -112,6 +125,7 @@ object InputSpecCodec {
|
||||
).joinToString(UNIT_SEPARATOR.toString())
|
||||
}
|
||||
}
|
||||
if (spec.requiresOwnCheckbox) records += OWN_CHECKBOX_RECORD
|
||||
return records.joinToString(RECORD_SEPARATOR.toString())
|
||||
}
|
||||
|
||||
@@ -150,6 +164,10 @@ object InputSpecCodec {
|
||||
}
|
||||
}
|
||||
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)
|
||||
} ?: 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
|
||||
// speichern. Erneutes Kodieren als JPEG verwirft dabei nebenbei die ursprünglichen
|
||||
// EXIF-Metadaten (z. B. GPS-Position) des Originalfotos, statt sie 1:1 zu
|
||||
// übernehmen - für sensible medizinische Dokumentationsfotos ein willkommener
|
||||
// Nebeneffekt, keine bewusste Anforderung dieser Aufgabe.
|
||||
// EXIF-Metadaten (z. B. GPS-Position) des Originalfotos.
|
||||
//
|
||||
// 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 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)
|
||||
}
|
||||
|
||||
if (!written) throw java.io.IOException("Bild konnte nicht gespeichert werden.")
|
||||
} catch (e: Throwable) {
|
||||
targetFile.delete()
|
||||
throw e
|
||||
} finally {
|
||||
if (corrected !== decoded) decoded.recycle()
|
||||
corrected.recycle()
|
||||
}
|
||||
|
||||
fileName
|
||||
}
|
||||
@@ -199,7 +214,13 @@ class InternalPhotoStorage(private val context: Context) : PhotoStorage {
|
||||
val sourceFile = resolve(sourceDocumentationId, sourceFileName)
|
||||
val targetFileName = "${UUID.randomUUID()}.jpg"
|
||||
val targetFile = File(ensureDocumentationDir(targetDocumentationId), targetFileName)
|
||||
try {
|
||||
sourceFile.copyTo(targetFile, overwrite = false)
|
||||
} catch (e: Throwable) {
|
||||
// Keine halb kopierte Datei zurücklassen.
|
||||
targetFile.delete()
|
||||
throw e
|
||||
}
|
||||
targetFileName
|
||||
}
|
||||
|
||||
|
||||
@@ -4,8 +4,10 @@ import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Color
|
||||
import android.graphics.Paint
|
||||
import android.graphics.Typeface
|
||||
import android.graphics.pdf.PdfDocument
|
||||
import android.text.TextPaint
|
||||
import de.medapp.wunddoku.domain.error.FailureCategory
|
||||
import de.medapp.wunddoku.domain.model.InputSpecCodec
|
||||
import de.medapp.wunddoku.media.PhotoStorage
|
||||
import de.medapp.wunddoku.signature.SignatureStorage
|
||||
@@ -99,6 +101,14 @@ class AndroidPdfDocumentExporter(
|
||||
*/
|
||||
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
|
||||
// Verwendung von contentWidth/remainingHeight für die tatsächliche Platzierung; diese
|
||||
// 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_HEIGHT_PT = PAGE_HEIGHT - 2 * MARGIN
|
||||
|
||||
/** Maximale Decode-Breite in Pixeln bei [PHOTO_TARGET_DPI] (≈ 1073px bei 515pt/150dpi). */
|
||||
private val PHOTO_MAX_WIDTH_PX = (PHOTO_AREA_WIDTH_PT / 72f * PHOTO_TARGET_DPI).toInt()
|
||||
/** 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 * PHOTO_SIZE_FACTOR / 72f * PHOTO_TARGET_DPI).toInt()
|
||||
|
||||
/** Maximale Decode-Höhe in Pixeln bei [PHOTO_TARGET_DPI] (≈ 1587px bei 762pt/150dpi). */
|
||||
private val PHOTO_MAX_HEIGHT_PX = (PHOTO_AREA_HEIGHT_PT / 72f * PHOTO_TARGET_DPI).toInt()
|
||||
/** 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 * PHOTO_SIZE_FACTOR / 72f * PHOTO_TARGET_DPI).toInt()
|
||||
|
||||
private val HEADER_DATE_FORMATTER: DateTimeFormatter = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM)
|
||||
}
|
||||
@@ -176,11 +186,18 @@ class AndroidPdfDocumentExporter(
|
||||
}
|
||||
|
||||
PdfExportResult.Success(targetFile)
|
||||
} catch (e: Exception) {
|
||||
} catch (e: Throwable) {
|
||||
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.")
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
pdfDocument.close()
|
||||
// Erst JETZT sicher, da writeTo() (im try-Block oben) zu diesem Zeitpunkt entweder
|
||||
@@ -209,10 +226,11 @@ class AndroidPdfDocumentExporter(
|
||||
textSize = HEADER_INFO_TEXT_SIZE
|
||||
color = Color.BLACK
|
||||
}
|
||||
// Überschriften (Blocktitel, "Unterschriften:", "Fotoanhang:"): fett UND kursiv.
|
||||
val blockTitle = TextPaint().apply {
|
||||
isAntiAlias = true
|
||||
textSize = BLOCK_TITLE_TEXT_SIZE
|
||||
isFakeBoldText = true
|
||||
typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD_ITALIC)
|
||||
color = Color.BLACK
|
||||
}
|
||||
val item = TextPaint().apply {
|
||||
@@ -268,8 +286,15 @@ class AndroidPdfDocumentExporter(
|
||||
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) {
|
||||
cursor.drawText(block.title, paints.blockTitle, spacingAfter = 6f)
|
||||
cursor.drawText(headingText(block.title), paints.blockTitle, spacingAfter = 6f)
|
||||
|
||||
for (item in block.items) {
|
||||
drawItem(cursor, item, paints)
|
||||
@@ -289,8 +314,15 @@ class AndroidPdfDocumentExporter(
|
||||
// vorgibt.
|
||||
val decodedInputSpec = if (item.inputSpec.isNotEmpty()) InputSpecCodec.decode(item.inputSpec) else 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(
|
||||
text = decodedInputSpec.formatForPdf(item.freeTextValue),
|
||||
text = itemText,
|
||||
paint = paints.item,
|
||||
indent = ITEM_INDENT,
|
||||
spacingAfter = 3f,
|
||||
@@ -298,8 +330,10 @@ class AndroidPdfDocumentExporter(
|
||||
return
|
||||
}
|
||||
|
||||
// Die Freitext-Option bekommt einen abschließenden Doppelpunkt, normale Optionen nicht.
|
||||
val labelText = if (item.isFreeText) "${item.label}:" else item.label
|
||||
// Die Freitext-Option bekommt einen abschließenden Doppelpunkt, sofern tatsächlich Text
|
||||
// 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(
|
||||
text = labelText,
|
||||
@@ -334,7 +368,7 @@ class AndroidPdfDocumentExporter(
|
||||
paints: ExportPaints,
|
||||
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) {
|
||||
drawSignatureBlock(cursor, model, signature, paints, retainedSignatureBitmaps)
|
||||
}
|
||||
@@ -430,7 +464,7 @@ class AndroidPdfDocumentExporter(
|
||||
) {
|
||||
cursor.forceNewPage()
|
||||
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)
|
||||
|
||||
@@ -445,7 +479,7 @@ class AndroidPdfDocumentExporter(
|
||||
|
||||
if (bitmap != null) {
|
||||
retainedPhotoBitmaps.add(bitmap)
|
||||
cursor.drawBitmapFitted(bitmap, paints.photo)
|
||||
cursor.drawBitmapFitted(bitmap, paints.photo, sizeFactor = PHOTO_SIZE_FACTOR)
|
||||
} else {
|
||||
// Fehlende/beschädigte Datei, IOException, ungültige Abmessungen o. Ä. - Option B:
|
||||
// neutrale Hinweisseite statt stillschweigendem Überspringen, damit nachvollziehbar
|
||||
|
||||
@@ -38,4 +38,25 @@ sealed interface PdfExportError {
|
||||
data class DirectoryCreationFailed(val path: String) : PdfExportError
|
||||
data class WriteFailed(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
|
||||
* hineinskaliert (kleiner, nie abgeschnitten).
|
||||
*/
|
||||
fun drawBitmapFitted(bitmap: Bitmap, paint: Paint) {
|
||||
fun drawBitmapFitted(bitmap: Bitmap, paint: Paint, sizeFactor: Float = 1f) {
|
||||
startIfNeeded()
|
||||
val availableWidth = contentWidth.toFloat()
|
||||
val availableHeight = remainingHeight
|
||||
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 drawHeight = bitmap.height * scale
|
||||
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)
|
||||
cursorY = contentBottom()
|
||||
|
||||
@@ -5,6 +5,7 @@ import android.graphics.Bitmap
|
||||
import android.graphics.Color
|
||||
import android.graphics.pdf.PdfRenderer
|
||||
import android.os.ParcelFileDescriptor
|
||||
import de.medapp.wunddoku.domain.error.FailureCategory
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
@@ -41,6 +42,12 @@ sealed interface PdfToImagesResult {
|
||||
data object EmptyDocument : 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."
|
||||
is PdfToImagesResult.Failure.Unexpected ->
|
||||
"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 {
|
||||
@@ -102,10 +113,19 @@ class AndroidPdfToImagesConverter(private val context: Context) : PdfToImagesCon
|
||||
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.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** PDF-Koordinaten sind in Punkten (1/72 Zoll) angegeben - diese Ziel-Auflösung ergibt
|
||||
|
||||
@@ -104,12 +104,21 @@ class InternalSignatureStorage(
|
||||
withContext(Dispatchers.IO) {
|
||||
val fileName = "${UUID.randomUUID()}.png"
|
||||
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
|
||||
// explizit angegeben, da Bitmap.compress() den Parameter unabhängig vom Format
|
||||
// verlangt.
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
+106
-1
@@ -1,5 +1,6 @@
|
||||
package de.medapp.wunddoku.ui.documentation.edit
|
||||
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.graphics.Bitmap
|
||||
import android.net.Uri
|
||||
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.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
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.ExpandLess
|
||||
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.Share
|
||||
import androidx.compose.material3.AlertDialog
|
||||
@@ -45,6 +48,7 @@ import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedIconButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
@@ -59,6 +63,7 @@ import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
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.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.core.content.FileProvider
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
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.toUserMessage
|
||||
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
|
||||
@@ -154,6 +161,51 @@ fun DocumentationEditScreen(
|
||||
val pdfImageSharer = remember(context) { AndroidPdfImageSharer(context) }
|
||||
val pdfSharer = remember(context) { AndroidPdfSharer(context) }
|
||||
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) {
|
||||
val message = uiState.pdfExportMessage ?: return@LaunchedEffect
|
||||
val exportedFile = uiState.lastExportedPdfFile
|
||||
@@ -200,6 +252,13 @@ fun DocumentationEditScreen(
|
||||
snackbarHostState.showSnackbar(message)
|
||||
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(
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
@@ -280,6 +339,7 @@ fun DocumentationEditScreen(
|
||||
PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)
|
||||
)
|
||||
},
|
||||
onTakePhotosClick = ::startCamera,
|
||||
onDeletePhotoRequested = viewModel::requestDeletePhoto,
|
||||
loadThumbnail = viewModel::loadPhotoThumbnail,
|
||||
onPhotoClick = { viewedPhoto = it },
|
||||
@@ -439,6 +499,7 @@ private fun DocumentationEditContent(
|
||||
photoImportError: String?,
|
||||
onDismissPhotoImportError: () -> Unit,
|
||||
onAddPhotosClick: () -> Unit,
|
||||
onTakePhotosClick: () -> Unit,
|
||||
onDeletePhotoRequested: (photoId: String) -> Unit,
|
||||
loadThumbnail: suspend (documentationId: String, fileName: String) -> Bitmap?,
|
||||
onPhotoClick: (Photo) -> Unit,
|
||||
@@ -446,8 +507,28 @@ private fun DocumentationEditContent(
|
||||
onDeleteSignatureRequested: (signatureId: String) -> Unit,
|
||||
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(
|
||||
modifier = modifier,
|
||||
state = listState,
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
@@ -515,6 +596,10 @@ private fun DocumentationEditContent(
|
||||
if (photoImportInProgress) {
|
||||
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) {
|
||||
Icon(Icons.Filled.Add, contentDescription = null)
|
||||
Spacer(Modifier.width(4.dp))
|
||||
@@ -873,15 +958,32 @@ private fun StructuredInputRow(
|
||||
val spec = remember(item.inputSpec) { InputSpecCodec.decode(item.inputSpec) } ?: return
|
||||
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) {
|
||||
val newValues = values.toMutableList().apply { this[fieldIndex] = newValue }
|
||||
val encoded = spec.encodeValues(newValues)
|
||||
onFreeTextChanged(item.checkItemStateId, encoded)
|
||||
if (!ownCheckbox) {
|
||||
onCheckedChanged(item.checkItemStateId, spec.hasAnyValue(encoded))
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
@@ -909,6 +1011,7 @@ private fun StructuredInputRow(
|
||||
},
|
||||
label = { Text(field.label) },
|
||||
singleLine = true,
|
||||
enabled = fieldsEnabled,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
@@ -916,6 +1019,7 @@ private fun StructuredInputRow(
|
||||
is InputFieldDef.Choice -> ChoiceField(
|
||||
field = field,
|
||||
selected = values.getOrElse(index) { "" },
|
||||
enabled = fieldsEnabled,
|
||||
onSelected = { updateValue(index, it) },
|
||||
)
|
||||
}
|
||||
@@ -932,11 +1036,12 @@ private fun StructuredInputRow(
|
||||
private fun ChoiceField(
|
||||
field: InputFieldDef.Choice,
|
||||
selected: String,
|
||||
enabled: Boolean = true,
|
||||
onSelected: (String) -> Unit,
|
||||
) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
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)
|
||||
}
|
||||
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
|
||||
|
||||
+144
-23
@@ -11,13 +11,15 @@ import androidx.navigation.toRoute
|
||||
import de.medapp.wunddoku.data.repository.DocumentationRepository
|
||||
import de.medapp.wunddoku.data.repository.WoundCarerRepository
|
||||
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.WoundCarer
|
||||
import de.medapp.wunddoku.pdf.PdfExportError
|
||||
import de.medapp.wunddoku.pdf.PdfExportResult
|
||||
import de.medapp.wunddoku.pdf.PdfToImagesResult
|
||||
import de.medapp.wunddoku.pdf.toUserMessage
|
||||
import de.medapp.wunddoku.ui.navigation.Screen
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@@ -26,7 +28,10 @@ import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.debounce
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import java.io.File
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
data class DocumentationEditUiState(
|
||||
val documentation: Documentation? = null,
|
||||
@@ -87,6 +92,10 @@ data class DocumentationEditUiState(
|
||||
/** Analog [imagesToShare], nur für die Variante "als PDF teilen" - Gegenstück
|
||||
* [DocumentationEditViewModel.onPdfShareHandled]. */
|
||||
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
|
||||
|
||||
@@ -117,6 +126,10 @@ class DocumentationEditViewModel(
|
||||
private val woundCarerRepository: WoundCarerRepository,
|
||||
) : 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())
|
||||
val uiState: StateFlow<DocumentationEditUiState> = _uiState.asStateFlow()
|
||||
|
||||
@@ -133,7 +146,14 @@ class DocumentationEditViewModel(
|
||||
|
||||
private suspend fun loadDocumentation() {
|
||||
_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 {
|
||||
if (documentation != null) {
|
||||
it.copy(documentation = documentation, isLoading = false)
|
||||
@@ -153,11 +173,48 @@ class DocumentationEditViewModel(
|
||||
}
|
||||
|
||||
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.
|
||||
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 =
|
||||
* 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))
|
||||
}
|
||||
// 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.
|
||||
*/
|
||||
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 {
|
||||
_uiState.update { it.copy(photoImportInProgress = true, photoImportError = null) }
|
||||
pendingPhotoImports.incrementAndGet()
|
||||
try {
|
||||
photoImportMutex.withLock {
|
||||
var failureCount = 0
|
||||
var outOfMemoryCount = 0
|
||||
var outOfStorageCount = 0
|
||||
for (uri in uris) {
|
||||
try {
|
||||
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++
|
||||
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/
|
||||
// Dateipfade in Logs) - die Exception-Nachricht selbst genügt zur Diagnose.
|
||||
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 ->
|
||||
state.copy(
|
||||
documentation = refreshed ?: state.documentation,
|
||||
photoImportInProgress = false,
|
||||
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 {
|
||||
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() {
|
||||
@@ -273,8 +387,9 @@ class DocumentationEditViewModel(
|
||||
fun confirmDeletePhoto() {
|
||||
val photoId = _uiState.value.pendingDeletePhotoId ?: return
|
||||
viewModelScope.launch {
|
||||
repository.removePhoto(photoId)
|
||||
val refreshed = repository.getFullDocumentation(documentationId)
|
||||
val result = runCatchingCancellable { repository.removePhoto(photoId) }
|
||||
if (result.isFailure) reportSaveFailure(result.exceptionOrNull(), "Das Foto konnte nicht gelöscht werden.")
|
||||
val refreshed = runCatchingCancellable { repository.getFullDocumentation(documentationId) }.getOrNull()
|
||||
_uiState.update { state ->
|
||||
state.copy(
|
||||
documentation = refreshed ?: state.documentation,
|
||||
@@ -314,6 +429,17 @@ class DocumentationEditViewModel(
|
||||
}
|
||||
} catch (e: IllegalArgumentException) {
|
||||
_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() {
|
||||
val signatureId = _uiState.value.pendingDeleteSignatureId ?: return
|
||||
viewModelScope.launch {
|
||||
repository.removeSignature(signatureId)
|
||||
val refreshed = repository.getFullDocumentation(documentationId)
|
||||
val result = runCatchingCancellable { repository.removeSignature(signatureId) }
|
||||
if (result.isFailure) reportSaveFailure(result.exceptionOrNull(), "Die Signatur konnte nicht gelöscht werden.")
|
||||
val refreshed = runCatchingCancellable { repository.getFullDocumentation(documentationId) }.getOrNull()
|
||||
_uiState.update { state ->
|
||||
state.copy(
|
||||
documentation = refreshed ?: state.documentation,
|
||||
@@ -495,13 +622,6 @@ class DocumentationEditViewModel(
|
||||
_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.
|
||||
* Bewusst NICHT im zentralen UiState gehalten (kein `Bitmap`-Feld in
|
||||
@@ -545,9 +665,10 @@ class DocumentationEditViewModel(
|
||||
fun flushPendingChanges() {
|
||||
if (pendingFreeText.isEmpty()) return
|
||||
val toFlush = pendingFreeText.toMap()
|
||||
pendingFreeText.clear()
|
||||
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.PatientRepository
|
||||
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.PatientListItem
|
||||
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.toGermanDateDigits
|
||||
import de.medapp.wunddoku.ui.navigation.Screen
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
@@ -187,7 +189,7 @@ class PatientFormViewModel(
|
||||
fun useExistingPatient() {
|
||||
val existing = _uiState.value.duplicateWarning ?: return
|
||||
_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. */
|
||||
@@ -241,10 +243,37 @@ class PatientFormViewModel(
|
||||
createdAt = Instant.EPOCH, // wird von PatientRepository.upsert() korrekt gesetzt
|
||||
updatedAt = Instant.EPOCH,
|
||||
)
|
||||
val saved = repository.upsert(patient)
|
||||
_uiState.update { it.copy(validationError = null) }
|
||||
finish(saved.id)
|
||||
val saved = try {
|
||||
repository.upsert(patient)
|
||||
} 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) }
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -3,11 +3,14 @@
|
||||
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>-
|
||||
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">
|
||||
<files-path name="pdf_exports" path="pdf/" />
|
||||
<files-path name="pdf_images" path="pdf_images/" />
|
||||
<files-path name="photos" path="photos/" />
|
||||
<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>
|
||||
|
||||
@@ -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("???", "***"))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user