Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 })
|
||||
}
|
||||
}
|
||||
+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,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")
|
||||
}
|
||||
@@ -22,6 +22,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
|
||||
@@ -446,8 +447,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),
|
||||
) {
|
||||
|
||||
@@ -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,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,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