Compare commits

..

21 Commits

Author SHA1 Message Date
niklas.ortmann 4eaf1480a2 Dateien nach "/" hochladen 2026-10-09 15:48:30 +02:00
niklas.ortmann 4a29d653bc Merge pull request 'QA-002' (#16) from dev-QA002 into main
Reviewed-on: #16
2026-10-09 10:08:04 +02:00
niklas.ortmann 77f71973a7 QA-002
Room-Tests
2026-10-09 10:07:17 +02:00
niklas.ortmann 51370a9172 Merge pull request 'QA-001' (#15) from dev-QA001 into dev
Reviewed-on: #15
2026-10-09 10:02:08 +02:00
niklas.ortmann 26cc81388e Update PatientRepositoryTest.kt 2026-10-09 10:01:32 +02:00
niklas.ortmann dcfa11bef9 QA-001
- [x] Dateinamen-Tests vorhanden
- [x] Fortsetzen-Tests vorhanden
- [x] Blockstatus-Tests vorhanden
- [x] Repository-Tests vorhanden
2026-10-08 12:15:31 +02:00
niklas.ortmann 85600bfaed Merge pull request 'beta 1.2 release' (#14) from dev into main
Reviewed-on: #14
2026-10-08 00:22:57 +02:00
niklas.ortmann e6bc99d144 Update DocumentationEditScreen.kt 2026-10-08 00:22:06 +02:00
niklas.ortmann 2bc0d959e1 README.md aktualisiert 2026-10-07 11:49:41 +02:00
niklas.ortmann 42e4b5c78c README.md aktualisiert 2026-10-07 11:49:23 +02:00
niklas.ortmann b265f3fadb README.md aktualisiert 2026-10-07 11:49:01 +02:00
niklas.ortmann e8ccab4f00 README.md aktualisiert 2026-10-07 11:48:25 +02:00
niklas.ortmann b77f7f0f20 README.md aktualisiert 2026-10-07 11:48:14 +02:00
niklas.ortmann 70bdc84cfa README.md aktualisiert 2026-10-07 11:47:54 +02:00
niklas.ortmann 76bf0f16ec README.md aktualisiert 2026-10-07 11:47:42 +02:00
niklas.ortmann 52a95e2b54 README.md aktualisiert 2026-10-07 11:45:07 +02:00
niklas.ortmann cefecb86dd README.md aktualisiert 2026-10-07 11:44:59 +02:00
niklas.ortmann 08f24aadd5 Merge pull request 'kleine Inhaltliche Korrekturen' (#12) from dev-Inhalte2 into main
Reviewed-on: #12
2026-10-06 09:00:16 +02:00
niklas.ortmann f25596563c beta1.0-release 2026-10-03 00:47:14 +02:00
niklas.ortmann b5ef793513 Merge pull request 'Beta 1.0 Release' (#11) from dev into main
Reviewed-on: #11
2026-10-03 00:37:20 +02:00
niklas.ortmann 1fdcceb149 Merge pull request 'neue Boxinhalte nach Kundenwunsch' (#10) from dev-Inhalte2 into dev
Reviewed-on: #10
2026-10-03 00:35:27 +02:00
27 changed files with 4189 additions and 77 deletions
+37 -77
View File
@@ -7,7 +7,7 @@ Die App ermöglicht die Verwaltung von Patienten und Wunddokumentationen,
unterstützt Fotos und digitale Unterschriften und erzeugt aus unterstützt Fotos und digitale Unterschriften und erzeugt aus
abgeschlossenen Dokumentationen PDF-Dateien. abgeschlossenen Dokumentationen PDF-Dateien.
> **Projektstatus:** In Entwicklung > **Projektstatus:** beta1.1
------------------------------------------------------------------------ ------------------------------------------------------------------------
@@ -344,32 +344,6 @@ werden kann.
- JDK über Android Studio - JDK über Android Studio
- Android-Gerät oder Android Emulator - Android-Gerät oder Android Emulator
### Projekt öffnen
Repository klonen:
``` bash
git clone <repository-url>
cd VulneraDoc
```
Anschließend den Projektordner in Android Studio öffnen.
Android Studio führt beim ersten Öffnen einen Gradle-Sync durch und lädt
die benötigten Dependencies.
Danach:
``` text
Gradle Sync
↓
Build / Make Project
↓
Android-Gerät oder Emulator auswählen
↓
Run
```
------------------------------------------------------------------------ ------------------------------------------------------------------------
## Projektphasen ## Projektphasen
@@ -387,40 +361,40 @@ Run
### Phase 2 -- Projektgrundgerüst ### Phase 2 -- Projektgrundgerüst
- [x] Android-Projekt - [x] Android-Projekt
- [ ] Room-Datenbank - [x] Room-Datenbank
- [ ] Entities - [x] Entities
- [ ] DAOs - [x] DAOs
- [ ] Repositories - [x] Repositories
- [ ] Navigation - [x] Navigation
- [ ] ViewModels - [x] ViewModels
### Phase 3 -- Kernfunktionalität ### Phase 3 -- Kernfunktionalität
- [ ] Patientenverwaltung - [x] Patientenverwaltung
- [ ] Patientenliste - [x] Patientenliste
- [ ] Wunddokumentationsliste - [x] Wunddokumentationsliste
- [ ] Dokumentation erstellen - [x] Dokumentation erstellen
- [ ] Dokumentationsblöcke - [x] Dokumentationsblöcke
- [ ] Blocknavigation - [x] Blocknavigation
- [ ] Autosave - [x] Autosave
- [ ] Dokumentation bearbeiten - [x] Dokumentation bearbeiten
- [ ] Dokumentation fortsetzen - [x] Dokumentation fortsetzen
### Phase 4 -- Medien ### Phase 4 -- Medien
- [ ] Fotoimport - [x] Fotoimport
- [ ] Bildverwaltung - [x] Bildverwaltung
- [ ] Digitale Signatur - [x] Digitale Signatur
- [ ] Mehrere Signaturen - [x] Mehrere Signaturen
### Phase 5 -- PDF ### Phase 5 -- PDF
- [ ] PDF-Exporter - [x] PDF-Exporter
- [ ] Dokumentationsblöcke - [x] Dokumentationsblöcke
- [ ] Signaturen - [x] Signaturen
- [ ] Fotoanhang - [x] Fotoanhang
- [ ] PDF anzeigen - [x] PDF anzeigen
- [ ] PDF teilen - [x] PDF teilen
### Phase 6 -- Qualität und Stabilität ### Phase 6 -- Qualität und Stabilität
@@ -431,29 +405,15 @@ Run
- [ ] Tablet-Optimierung - [ ] Tablet-Optimierung
- [ ] Datenschutz-Review - [ ] Datenschutz-Review
------------------------------------------------------------------------ ### Phase 7 -- Multi-Device-Synchronisation
## Dokumentation - [ ] Geräteidentität und Pairing
- [ ] Automatische Geräteerkennung im WLAN
Die ausführliche technische Architektur befindet sich in der - [ ] Differenzabgleich / Sync-Protokoll
Projektdokumentation: - [ ] Patienten synchronisieren
- [ ] Wunddokumentationen synchronisieren
``` text - [ ] Fotos synchronisieren
docs/ - [ ] Konflikterkennung und Konfliktauflösung
└── Architekturplanung.md - [ ] Automatische Synchronisation
``` - [ ] Sync-Status und manueller Fallback
- [ ] Multi-Device- und Recovery-Tests
Dort werden unter anderem Datenmodell, Datenbankschema,
Navigationskonzept, Autosave, PDF-Erzeugung und Medienverwaltung
detailliert beschrieben.
------------------------------------------------------------------------
## Status
**VulneraDoc befindet sich aktuell in Entwicklung und ist nicht für den
produktiven Einsatz freigegeben.**
Insbesondere bei der Verarbeitung realer Patientendaten müssen vor einem
Produktiveinsatz die rechtlichen, datenschutzrechtlichen und
sicherheitstechnischen Anforderungen abschließend geprüft werden.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+37
View File
@@ -0,0 +1,37 @@
{
"version": 3,
"artifactType": {
"type": "APK",
"kind": "Directory"
},
"applicationId": "de.medapp.wunddoku",
"variantName": "release",
"elements": [
{
"type": "SINGLE",
"filters": [],
"attributes": [],
"versionCode": 1,
"versionName": "1.0",
"outputFile": "app-release.apk"
}
],
"elementType": "File",
"baselineProfiles": [
{
"minApi": 28,
"maxApi": 30,
"baselineProfiles": [
"baselineProfiles/1/app-release.dm"
]
},
{
"minApi": 31,
"maxApi": 2147483647,
"baselineProfiles": [
"baselineProfiles/0/app-release.dm"
]
}
],
"minSdkVersionForDexing": 26
}
@@ -0,0 +1,259 @@
package de.medapp.wunddoku.data.dao
import android.database.sqlite.SQLiteConstraintException
import de.medapp.wunddoku.testutil.RoomTestBase
import de.medapp.wunddoku.testutil.blockStateEntity
import de.medapp.wunddoku.testutil.checkItemStateEntity
import de.medapp.wunddoku.testutil.expectThrows
import de.medapp.wunddoku.testutil.runDbTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/** DAO-Tests für [BlockStateDao] und [CheckItemStateDao]. */
class BlockAndCheckItemDaoTest : RoomTestBase() {
// --- BlockStateDao -------------------------------------------------------------------
@Test
fun blockStates_insertAll_und_getById_liefern_alle_felder() = runDbTest {
seedDocumentation()
val block = blockStateEntity(title = "Größe & Tiefe", order = 3, updatedAt = 4_000L)
blockStateDao.insertAll(listOf(block))
assertEquals(block, blockStateDao.getById("b-1"))
assertNull(blockStateDao.getById("unbekannt"))
}
@Test
fun blockStates_getForDocumentation_sortiert_nach_blockOrder_und_filtert_nach_dokumentation() = runDbTest {
seedDocumentation(id = "d-1")
seedDocumentation(id = "d-2")
blockStateDao.insertAll(
listOf(
blockStateEntity(id = "b-3", documentationId = "d-1", blockDefinitionId = "c", order = 3),
blockStateEntity(id = "b-1", documentationId = "d-1", blockDefinitionId = "a", order = 1),
blockStateEntity(id = "b-2", documentationId = "d-1", blockDefinitionId = "b", order = 2),
blockStateEntity(id = "b-x", documentationId = "d-2", blockDefinitionId = "a", order = 1),
)
)
assertEquals(listOf("b-1", "b-2", "b-3"), blockStateDao.getForDocumentation("d-1").map { it.id })
assertEquals(listOf("b-x"), blockStateDao.getForDocumentation("d-2").map { it.id })
assertTrue(blockStateDao.getForDocumentation("unbekannt").isEmpty())
}
@Test
fun blockStates_gleiche_blockdefinition_in_derselben_dokumentation_ist_verboten() = runDbTest {
seedDocumentation()
blockStateDao.insertAll(listOf(blockStateEntity(id = "b-1", blockDefinitionId = "wundart")))
expectThrows<SQLiteConstraintException> {
blockStateDao.insertAll(listOf(blockStateEntity(id = "b-2", blockDefinitionId = "wundart")))
}
assertEquals(1, countRows("block_states"))
}
@Test
fun blockStates_gleiche_blockdefinition_in_verschiedenen_dokumentationen_ist_erlaubt() = runDbTest {
seedDocumentation(id = "d-1")
seedDocumentation(id = "d-2")
blockStateDao.insertAll(
listOf(
blockStateEntity(id = "b-1", documentationId = "d-1", blockDefinitionId = "wundart"),
blockStateEntity(id = "b-2", documentationId = "d-2", blockDefinitionId = "wundart"),
)
)
assertEquals(2, countRows("block_states"))
}
@Test
fun blockStates_mit_unbekannter_dokumentation_wirft() = runDbTest {
expectThrows<SQLiteConstraintException> {
blockStateDao.insertAll(listOf(blockStateEntity(documentationId = "gibt-es-nicht")))
}
assertEquals(0, countRows("block_states"))
}
@Test
fun blockStates_insertAll_mit_leerer_liste_ist_unkritisch() = runDbTest {
blockStateDao.insertAll(emptyList())
assertEquals(0, countRows("block_states"))
}
// --- CheckItemStateDao ---------------------------------------------------------------
private suspend fun seedBlock(id: String = "b-1", documentationId: String = "d-1", definitionId: String = "wundart") {
if (documentationDao.getById(documentationId) == null) seedDocumentation(id = documentationId)
blockStateDao.insertAll(listOf(blockStateEntity(id = id, documentationId = documentationId, blockDefinitionId = definitionId)))
}
@Test
fun checkItems_insertAll_und_getForBlockState_liefern_alle_felder_inklusive_standardwerten() = runDbTest {
seedBlock()
val plain = checkItemStateEntity(id = "c-1", definitionId = "plain", order = 1)
val full = checkItemStateEntity(
id = "c-2", definitionId = "full", order = 2, checked = true, isFreeText = true,
freeTextValue = "Text", isStaticHint = false, inputSpec = "SPEC",
)
checkItemStateDao.insertAll(listOf(plain, full))
val loaded = checkItemStateDao.getForBlockState("b-1")
assertEquals(listOf(plain, full), loaded)
assertFalse(loaded[0].checked)
assertFalse(loaded[0].isFreeText)
assertEquals("", loaded[0].freeTextValue)
assertFalse(loaded[0].isStaticHint)
assertEquals("", loaded[0].inputSpec)
}
@Test
fun checkItems_getForBlockState_sortiert_nach_itemOrder_und_filtert_nach_block() = runDbTest {
seedBlock(id = "b-1", definitionId = "a")
seedBlock(id = "b-2", definitionId = "b")
checkItemStateDao.insertAll(
listOf(
checkItemStateEntity(id = "c-3", blockStateId = "b-1", definitionId = "z", order = 3),
checkItemStateEntity(id = "c-1", blockStateId = "b-1", definitionId = "x", order = 1),
checkItemStateEntity(id = "c-2", blockStateId = "b-1", definitionId = "y", order = 2),
checkItemStateEntity(id = "c-x", blockStateId = "b-2", definitionId = "x", order = 1),
)
)
assertEquals(listOf("c-1", "c-2", "c-3"), checkItemStateDao.getForBlockState("b-1").map { it.id })
assertEquals(listOf("c-x"), checkItemStateDao.getForBlockState("b-2").map { it.id })
assertTrue(checkItemStateDao.getForBlockState("unbekannt").isEmpty())
}
@Test
fun checkItems_setChecked_setzt_und_entfernt_den_haken_ohne_andere_felder_zu_aendern() = runDbTest {
seedBlock()
val item = checkItemStateEntity(freeTextValue = "bleibt", inputSpec = "bleibt auch")
checkItemStateDao.insertAll(listOf(item))
checkItemStateDao.setChecked("c-1", true)
assertEquals(item.copy(checked = true), checkItemStateDao.getForBlockState("b-1").single())
checkItemStateDao.setChecked("c-1", false)
assertEquals(item, checkItemStateDao.getForBlockState("b-1").single())
}
@Test
fun checkItems_setChecked_betrifft_nur_die_angegebene_option() = runDbTest {
seedBlock()
checkItemStateDao.insertAll(
listOf(
checkItemStateEntity(id = "c-1", definitionId = "a", order = 1),
checkItemStateEntity(id = "c-2", definitionId = "b", order = 2),
)
)
checkItemStateDao.setChecked("c-1", true)
val byId = checkItemStateDao.getForBlockState("b-1").associateBy { it.id }
assertTrue(byId.getValue("c-1").checked)
assertFalse(byId.getValue("c-2").checked)
}
@Test
fun checkItems_setChecked_und_updateFreeTextValue_mit_unbekannter_id_sind_wirkungslos() = runDbTest {
seedBlock()
val item = checkItemStateEntity()
checkItemStateDao.insertAll(listOf(item))
checkItemStateDao.setChecked("unbekannt", true)
checkItemStateDao.updateFreeTextValue("unbekannt", "x")
assertEquals(listOf(item), checkItemStateDao.getForBlockState("b-1"))
}
@Test
fun checkItems_updateFreeTextValue_aendert_nur_den_text_und_nicht_den_haken() = runDbTest {
seedBlock()
checkItemStateDao.insertAll(listOf(checkItemStateEntity(isFreeText = true, checked = true)))
checkItemStateDao.updateFreeTextValue("c-1", "Rötung am Wundrand")
val loaded = checkItemStateDao.getForBlockState("b-1").single()
assertEquals("Rötung am Wundrand", loaded.freeTextValue)
assertTrue(loaded.checked)
assertTrue(loaded.isFreeText)
}
@Test
fun checkItems_freitext_laesst_sich_leeren() = runDbTest {
seedBlock()
checkItemStateDao.insertAll(listOf(checkItemStateEntity(isFreeText = true, freeTextValue = "alt")))
checkItemStateDao.updateFreeTextValue("c-1", "")
assertEquals("", checkItemStateDao.getForBlockState("b-1").single().freeTextValue)
}
@Test
fun checkItems_sonderzeichen_und_sql_fragmente_werden_als_reiner_text_gespeichert() = runDbTest {
seedBlock()
checkItemStateDao.insertAll(listOf(checkItemStateEntity(isFreeText = true)))
val text = "O'Brien \"Test\" ; DROP TABLE patients; -- Größe 3,5 × 2 cm\nzweite Zeile\t😀 100% _x_"
checkItemStateDao.updateFreeTextValue("c-1", text)
assertEquals(text, checkItemStateDao.getForBlockState("b-1").single().freeTextValue)
assertEquals(1, countRows("patients")) // Tabelle existiert weiterhin, der Seed-Patient ist unangetastet
}
@Test
fun checkItems_inputSpec_mit_steuerzeichen_der_codierung_bleibt_verlustfrei_erhalten() = runDbTest {
// InputSpecCodec nutzt \u001E, \u001F und \u001D als Trennzeichen.
seedBlock()
val spec = "tpl\u001EN\u001Flabel\u001F3\u001Dx\u001Ey"
checkItemStateDao.insertAll(listOf(checkItemStateEntity(inputSpec = spec)))
assertEquals(spec, checkItemStateDao.getForBlockState("b-1").single().inputSpec)
}
@Test
fun checkItems_gleiche_optionsdefinition_im_selben_block_ist_verboten() = runDbTest {
seedBlock()
checkItemStateDao.insertAll(listOf(checkItemStateEntity(id = "c-1", definitionId = "dup")))
expectThrows<SQLiteConstraintException> {
checkItemStateDao.insertAll(listOf(checkItemStateEntity(id = "c-2", definitionId = "dup")))
}
assertEquals(1, countRows("check_item_states"))
}
@Test
fun checkItems_gleiche_optionsdefinition_in_verschiedenen_bloecken_ist_erlaubt() = runDbTest {
seedBlock(id = "b-1", definitionId = "a")
seedBlock(id = "b-2", definitionId = "b")
checkItemStateDao.insertAll(
listOf(
checkItemStateEntity(id = "c-1", blockStateId = "b-1", definitionId = "same"),
checkItemStateEntity(id = "c-2", blockStateId = "b-2", definitionId = "same"),
)
)
assertEquals(2, countRows("check_item_states"))
}
@Test
fun checkItems_mit_unbekanntem_block_wirft() = runDbTest {
expectThrows<SQLiteConstraintException> {
checkItemStateDao.insertAll(listOf(checkItemStateEntity(blockStateId = "gibt-es-nicht")))
}
assertEquals(0, countRows("check_item_states"))
}
}
@@ -0,0 +1,222 @@
package de.medapp.wunddoku.data.dao
import android.database.sqlite.SQLiteConstraintException
import de.medapp.wunddoku.domain.model.DocumentationStatus
import de.medapp.wunddoku.testutil.RoomTestBase
import de.medapp.wunddoku.testutil.awaitMatching
import de.medapp.wunddoku.testutil.documentationEntity
import de.medapp.wunddoku.testutil.expectThrows
import de.medapp.wunddoku.testutil.runDbTest
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.first
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/** DAO-Tests für [DocumentationDao] (ohne Suche und Relationen, s. SearchQueryTest / DocumentationRelationsTest). */
class DocumentationDaoTest : RoomTestBase() {
// --- insert / getById ----------------------------------------------------------------
@Test
fun insert_und_getById_liefern_alle_felder_inklusive_snapshots() = runDbTest {
seedPatient()
val entity = documentationEntity(
status = DocumentationStatus.COMPLETED,
createdAt = 1_000L,
updatedAt = 2_000L,
pdfLastGeneratedAt = 3_000L,
catalogVersion = 3,
firstNameSnapshot = "Erika",
lastNameSnapshot = "Musterfrau",
birthDateSnapshot = "1975-12-01",
streetSnapshot = "Hauptstraße 5",
postalCodeSnapshot = "54321",
citySnapshot = "Beispielstadt",
)
documentationDao.insert(entity)
assertEquals(entity, documentationDao.getById("d-1"))
}
@Test
fun nullable_felder_bleiben_null() = runDbTest {
seedDocumentation()
val loaded = documentationDao.getById("d-1")!!
assertNull(loaded.continuedFromDocumentationId)
assertNull(loaded.pdfLastGeneratedAt)
}
@Test
fun getById_liefert_null_fuer_unbekannte_id() = runDbTest {
assertNull(documentationDao.getById("unbekannt"))
}
@Test
fun status_wird_als_enum_name_gespeichert_und_vollstaendig_zurueckgelesen() = runDbTest {
seedPatient()
DocumentationStatus.values().forEachIndexed { index, status ->
documentationDao.insert(documentationEntity(id = "d-$index", status = status))
}
DocumentationStatus.values().forEachIndexed { index, status ->
assertEquals(status, documentationDao.getById("d-$index")!!.status)
assertEquals(status.name, queryString("SELECT status FROM documentations WHERE id = 'd-$index'"))
}
}
// --- Foreign Keys --------------------------------------------------------------------
@Test
fun insert_mit_unbekanntem_patienten_wirft() = runDbTest {
expectThrows<SQLiteConstraintException> {
documentationDao.insert(documentationEntity(patientId = "gibt-es-nicht"))
}
assertEquals(0, countRows("documentations"))
}
@Test
fun insert_mit_unbekannter_fortsetzungsquelle_wirft() = runDbTest {
seedPatient()
expectThrows<SQLiteConstraintException> {
documentationDao.insert(documentationEntity(continuedFromDocumentationId = "gibt-es-nicht"))
}
assertEquals(0, countRows("documentations"))
}
@Test
fun fortsetzungsquelle_wird_beim_loeschen_der_quelle_auf_null_gesetzt() = runDbTest {
val source = seedDocumentation(id = "d-source")
documentationDao.insert(documentationEntity(id = "d-copy", continuedFromDocumentationId = "d-source"))
assertEquals("d-source", documentationDao.getById("d-copy")!!.continuedFromDocumentationId)
documentationDao.delete(source)
assertNull(documentationDao.getById("d-source"))
val copy = documentationDao.getById("d-copy")
assertNotNull("Die Fortsetzung darf nicht mitgelöscht werden", copy)
assertNull(copy!!.continuedFromDocumentationId)
}
// --- update / delete -----------------------------------------------------------------
@Test
fun update_aendert_nur_die_uebergebene_dokumentation() = runDbTest {
val first = seedDocumentation(id = "d-1")
val second = seedDocumentation(id = "d-2")
documentationDao.update(first.copy(status = DocumentationStatus.COMPLETED, updatedAt = 9_000L, pdfLastGeneratedAt = 9_500L))
val changed = documentationDao.getById("d-1")!!
assertEquals(DocumentationStatus.COMPLETED, changed.status)
assertEquals(9_000L, changed.updatedAt)
assertEquals(9_500L, changed.pdfLastGeneratedAt)
assertEquals(second, documentationDao.getById("d-2"))
}
@Test
fun update_einer_unbekannten_id_legt_nichts_an() = runDbTest {
seedPatient()
documentationDao.update(documentationEntity(id = "geist"))
assertNull(documentationDao.getById("geist"))
}
@Test
fun delete_entfernt_nur_die_uebergebene_dokumentation_und_laesst_den_patienten_bestehen() = runDbTest {
val first = seedDocumentation(id = "d-1")
seedDocumentation(id = "d-2")
documentationDao.delete(first)
assertNull(documentationDao.getById("d-1"))
assertNotNull(documentationDao.getById("d-2"))
assertNotNull(patientDao.getById("p-1"))
}
// --- observeAll / countForPatient ----------------------------------------------------
@Test
fun observeAll_sortiert_nach_erstelldatum_absteigend() = runDbTest {
seedDocumentation(id = "d-alt", createdAt = 1_000L)
seedDocumentation(id = "d-neu", createdAt = 3_000L)
seedDocumentation(id = "d-mitte", createdAt = 2_000L)
val ids = documentationDao.observeAll().first().map { it.id }
assertEquals(listOf("d-neu", "d-mitte", "d-alt"), ids)
}
@Test
fun observeAll_liefert_leere_liste_ohne_dokumentationen() = runDbTest {
assertTrue(documentationDao.observeAll().first().isEmpty())
}
@Test
fun observeAll_aktualisiert_sich_bei_insert_update_und_delete() = runDbTest {
seedPatient()
val afterInsert = async { documentationDao.observeAll().awaitMatching { it.size == 1 } }
val entity = documentationEntity(id = "d-1")
documentationDao.insert(entity)
assertEquals("d-1", afterInsert.await().single().id)
val afterUpdate = async {
documentationDao.observeAll().awaitMatching { list -> list.singleOrNull()?.status == DocumentationStatus.COMPLETED }
}
documentationDao.update(entity.copy(status = DocumentationStatus.COMPLETED))
afterUpdate.await()
val afterDelete = async { documentationDao.observeAll().awaitMatching { it.isEmpty() } }
documentationDao.delete(entity)
assertTrue(afterDelete.await().isEmpty())
}
@Test
fun countForPatient_zaehlt_nur_dokumentationen_des_jeweiligen_patienten() = runDbTest {
seedDocumentation(id = "d-1", patientId = "p-1")
seedDocumentation(id = "d-2", patientId = "p-1")
seedDocumentation(id = "d-3", patientId = "p-2")
assertEquals(2, documentationDao.countForPatient("p-1"))
assertEquals(1, documentationDao.countForPatient("p-2"))
assertEquals(0, documentationDao.countForPatient("unbekannt"))
}
// --- getLatestForPatient -------------------------------------------------------------
@Test
fun getLatestForPatient_liefert_die_zuletzt_erstellte_dokumentation_des_patienten() = runDbTest {
seedDocumentation(id = "d-alt", patientId = "p-1", createdAt = 1_000L)
seedDocumentation(id = "d-neu", patientId = "p-1", createdAt = 3_000L)
seedDocumentation(id = "d-mitte", patientId = "p-1", createdAt = 2_000L)
assertEquals("d-neu", documentationDao.getLatestForPatient("p-1")?.documentation?.id)
}
@Test
fun getLatestForPatient_ignoriert_dokumentationen_anderer_patienten() = runDbTest {
seedDocumentation(id = "d-1", patientId = "p-1", createdAt = 1_000L)
seedDocumentation(id = "d-2", patientId = "p-2", createdAt = 9_000L)
assertEquals("d-1", documentationDao.getLatestForPatient("p-1")?.documentation?.id)
assertEquals("d-2", documentationDao.getLatestForPatient("p-2")?.documentation?.id)
}
@Test
fun getLatestForPatient_liefert_null_ohne_dokumentation() = runDbTest {
seedPatient()
assertNull(documentationDao.getLatestForPatient("p-1"))
assertNull(documentationDao.getLatestForPatient("unbekannt"))
}
}
@@ -0,0 +1,229 @@
package de.medapp.wunddoku.data.dao
import de.medapp.wunddoku.data.entity.relations.DocumentationWithDetails
import de.medapp.wunddoku.testutil.RoomTestBase
import de.medapp.wunddoku.testutil.blockStateEntity
import de.medapp.wunddoku.testutil.checkItemStateEntity
import de.medapp.wunddoku.testutil.photoEntity
import de.medapp.wunddoku.testutil.runDbTest
import de.medapp.wunddoku.testutil.signatureEntity
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Relationstests (QA-002): [DocumentationWithDetails] (Dokumentation -> Blöcke -> Optionen,
* Fotos, Signaturen) sowie Cascade-/Restrict-Verhalten der Foreign Keys.
*/
class DocumentationRelationsTest : RoomTestBase() {
/**
* Dokumentation [docId] mit 2 Blöcken ("<prefix>-b1" order 2, "<prefix>-b2" order 1),
* 3 Optionen (b1: c1, c2 / b2: c3), 2 Fotos und 2 Signaturen.
*/
private suspend fun seedWithChildren(docId: String, createdAt: Long, prefix: String) {
seedDocumentation(id = docId, patientId = "p-1", createdAt = createdAt)
blockStateDao.insertAll(
listOf(
blockStateEntity(id = "$prefix-b1", documentationId = docId, blockDefinitionId = "wundart", order = 2),
blockStateEntity(id = "$prefix-b2", documentationId = docId, blockDefinitionId = "groesse", order = 1),
)
)
checkItemStateDao.insertAll(
listOf(
checkItemStateEntity(id = "$prefix-c1", blockStateId = "$prefix-b1", definitionId = "a", order = 1),
checkItemStateEntity(id = "$prefix-c2", blockStateId = "$prefix-b1", definitionId = "b", order = 2),
checkItemStateEntity(id = "$prefix-c3", blockStateId = "$prefix-b2", definitionId = "a", order = 1),
)
)
photoDao.insert(photoEntity(id = "$prefix-ph1", documentationId = docId, orderIndex = 0))
photoDao.insert(photoEntity(id = "$prefix-ph2", documentationId = docId, orderIndex = 1))
signatureDao.insert(signatureEntity(id = "$prefix-s1", documentationId = docId, orderIndex = 0))
signatureDao.insert(signatureEntity(id = "$prefix-s2", documentationId = docId, orderIndex = 1))
}
private fun DocumentationWithDetails.itemIdsByBlock(): Map<String, Set<String>> =
blocks.associate { it.blockState.id to it.checkItems.map { item -> item.id }.toSet() }
// --- Laden ---------------------------------------------------------------------------
@Test
fun getFullDocumentation_liefert_dokumentation_mit_allen_kindern() = runDbTest {
seedWithChildren("d-a", 1_000L, "a")
val full = documentationDao.getFullDocumentation("d-a")!!
assertEquals("d-a", full.documentation.id)
assertEquals(setOf("a-b1", "a-b2"), full.blocks.map { it.blockState.id }.toSet())
assertEquals(setOf("a-ph1", "a-ph2"), full.photos.map { it.id }.toSet())
assertEquals(setOf("a-s1", "a-s2"), full.signatures.map { it.id }.toSet())
}
@Test
fun jede_option_haengt_am_richtigen_block() = runDbTest {
seedWithChildren("d-a", 1_000L, "a")
val full = documentationDao.getFullDocumentation("d-a")!!
assertEquals(
mapOf("a-b1" to setOf("a-c1", "a-c2"), "a-b2" to setOf("a-c3")),
full.itemIdsByBlock(),
)
}
@Test
fun relation_enthaelt_die_vollstaendigen_entities_nicht_nur_ids() = runDbTest {
seedWithChildren("d-a", 1_000L, "a")
val full = documentationDao.getFullDocumentation("d-a")!!
assertEquals(documentationDao.getById("d-a"), full.documentation)
assertEquals(blockStateDao.getById("a-b1"), full.blocks.single { it.blockState.id == "a-b1" }.blockState)
assertEquals(photoDao.getById("a-ph1"), full.photos.single { it.id == "a-ph1" })
assertEquals(signatureDao.getById("a-s1"), full.signatures.single { it.id == "a-s1" })
assertEquals(
checkItemStateDao.getForBlockState("a-b1"),
full.blocks.single { it.blockState.id == "a-b1" }.checkItems.sortedBy { it.itemOrder },
)
}
@Test
fun relation_vermischt_keine_daten_verschiedener_dokumentationen() = runDbTest {
seedWithChildren("d-a", 1_000L, "a")
seedWithChildren("d-b", 2_000L, "b")
val a = documentationDao.getFullDocumentation("d-a")!!
val b = documentationDao.getFullDocumentation("d-b")!!
assertTrue(a.blocks.all { it.blockState.documentationId == "d-a" })
assertTrue(a.photos.all { it.documentationId == "d-a" })
assertTrue(a.signatures.all { it.documentationId == "d-a" })
assertEquals(setOf("a-c1", "a-c2", "a-c3"), a.itemIdsByBlock().values.flatten().toSet())
assertEquals(setOf("b-c1", "b-c2", "b-c3"), b.itemIdsByBlock().values.flatten().toSet())
}
@Test
fun dokumentation_ohne_kinder_liefert_leere_listen_statt_null() = runDbTest {
seedDocumentation(id = "d-leer")
val full = documentationDao.getFullDocumentation("d-leer")!!
assertTrue(full.blocks.isEmpty())
assertTrue(full.photos.isEmpty())
assertTrue(full.signatures.isEmpty())
}
@Test
fun block_ohne_optionen_liefert_leere_optionsliste() = runDbTest {
seedDocumentation()
blockStateDao.insertAll(listOf(blockStateEntity()))
val block = documentationDao.getFullDocumentation("d-1")!!.blocks.single()
assertTrue(block.checkItems.isEmpty())
}
@Test
fun getFullDocumentation_liefert_null_fuer_unbekannte_id() = runDbTest {
assertNull(documentationDao.getFullDocumentation("unbekannt"))
}
@Test
fun getLatestForPatient_laedt_auch_die_kinder_der_neuesten_dokumentation() = runDbTest {
seedWithChildren("d-a", 1_000L, "a")
seedWithChildren("d-b", 2_000L, "b")
val latest = documentationDao.getLatestForPatient("p-1")!!
assertEquals("d-b", latest.documentation.id)
assertEquals(setOf("b-b1", "b-b2"), latest.blocks.map { it.blockState.id }.toSet())
assertEquals(setOf("b-c1", "b-c2", "b-c3"), latest.itemIdsByBlock().values.flatten().toSet())
assertEquals(2, latest.photos.size)
assertEquals(2, latest.signatures.size)
}
@Test
fun grosse_dokumentation_wird_vollstaendig_und_korrekt_zugeordnet_geladen() = runDbTest {
seedDocumentation()
val blocks = (1..25).map { b ->
blockStateEntity(id = "b-$b", blockDefinitionId = "block_$b", title = "Block $b", order = b)
}
blockStateDao.insertAll(blocks)
val items = (1..25).flatMap { b ->
(1..20).map { i ->
checkItemStateEntity(id = "c-$b-$i", blockStateId = "b-$b", definitionId = "item_$i", order = i)
}
}
checkItemStateDao.insertAll(items)
val full = documentationDao.getFullDocumentation("d-1")!!
assertEquals(25, full.blocks.size)
assertEquals(500, full.blocks.sumOf { it.checkItems.size })
for (block in full.blocks) {
assertEquals(20, block.checkItems.size)
assertTrue(block.checkItems.all { it.blockStateId == block.blockState.id })
}
}
// --- Cascade / Restrict --------------------------------------------------------------
@Test
fun foreign_keys_sind_in_der_testdatenbank_aktiviert() = runDbTest {
assertEquals(1, queryInt("PRAGMA foreign_keys"))
}
@Test
fun dokumentation_loeschen_entfernt_bloecke_optionen_fotos_und_signaturen_per_cascade() = runDbTest {
seedWithChildren("d-a", 1_000L, "a")
documentationDao.delete(documentationDao.getById("d-a")!!)
assertEquals(0, countRows("documentations"))
assertEquals(0, countRows("block_states"))
assertEquals(0, countRows("check_item_states"))
assertEquals(0, countRows("photos"))
assertEquals(0, countRows("signatures"))
}
@Test
fun dokumentation_loeschen_laesst_patient_und_andere_dokumentationen_unangetastet() = runDbTest {
seedWithChildren("d-a", 1_000L, "a")
seedWithChildren("d-b", 2_000L, "b")
documentationDao.delete(documentationDao.getById("d-a")!!)
assertNotNull(patientDao.getById("p-1"))
val remaining = documentationDao.getFullDocumentation("d-b")!!
assertEquals(2, remaining.blocks.size)
assertEquals(3, remaining.itemIdsByBlock().values.sumOf { it.size })
assertEquals(2, remaining.photos.size)
assertEquals(2, remaining.signatures.size)
assertEquals(2, countRows("block_states"))
assertEquals(3, countRows("check_item_states"))
}
@Test
fun block_loeschen_entfernt_seine_optionen_per_cascade_aber_nicht_die_anderer_bloecke() = runDbTest {
seedWithChildren("d-a", 1_000L, "a")
execSql("DELETE FROM block_states WHERE id = 'a-b1'")
assertEquals(listOf("a-c3"), checkItemStateDao.getForBlockState("a-b2").map { it.id })
assertTrue(checkItemStateDao.getForBlockState("a-b1").isEmpty())
assertEquals(1, countRows("check_item_states"))
}
@Test
fun patient_mit_dokumentation_kann_nicht_geloescht_werden_und_kinder_bleiben_erhalten() = runDbTest {
seedWithChildren("d-a", 1_000L, "a")
val failed = runCatching { patientDao.delete(patientDao.getById("p-1")!!) }
assertTrue(failed.isFailure)
assertNotNull(documentationDao.getFullDocumentation("d-a"))
assertEquals(3, countRows("check_item_states"))
}
}
@@ -0,0 +1,232 @@
package de.medapp.wunddoku.data.dao
import android.database.sqlite.SQLiteConstraintException
import de.medapp.wunddoku.testutil.RoomTestBase
import de.medapp.wunddoku.testutil.awaitMatching
import de.medapp.wunddoku.testutil.expectThrows
import de.medapp.wunddoku.testutil.patientEntity
import de.medapp.wunddoku.testutil.runDbTest
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.first
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.Test
/** DAO-Tests für [PatientDao]. */
class PatientDaoTest : RoomTestBase() {
// --- insert / getById ----------------------------------------------------------------
@Test
fun insert_und_getById_liefern_alle_felder_zurueck() = runDbTest {
val entity = patientEntity(phone2 = "0987 654321", phone3 = "0555 123")
patientDao.insert(entity)
assertEquals(entity, patientDao.getById("p-1"))
}
@Test
fun optionale_telefonnummern_bleiben_null() = runDbTest {
patientDao.insert(patientEntity(phone2 = null, phone3 = null))
val loaded = patientDao.getById("p-1")!!
assertNull(loaded.phone2)
assertNull(loaded.phone3)
}
@Test
fun umlaute_und_sonderzeichen_bleiben_unveraendert() = runDbTest {
val entity = patientEntity(
firstName = "Jörg-Åke", lastName = "O'Brien-Müller", street = "Straße \"des\" 17. Juni 1/2", city = "Köln-Ehrenfeld",
)
patientDao.insert(entity)
assertEquals(entity, patientDao.getById("p-1"))
}
@Test
fun getById_liefert_null_fuer_unbekannte_id() = runDbTest {
assertNull(patientDao.getById("unbekannt"))
}
@Test
fun insert_mit_vorhandener_id_wirft_und_laesst_den_bestehenden_datensatz_unveraendert() = runDbTest {
val original = patientEntity(lastName = "Original")
patientDao.insert(original)
expectThrows<SQLiteConstraintException> { patientDao.insert(patientEntity(lastName = "Duplikat")) }
assertEquals(original, patientDao.getById("p-1"))
}
@Test
fun doppelte_stammdaten_mit_unterschiedlicher_id_sind_auf_db_ebene_erlaubt() = runDbTest {
// Duplikatschutz liegt bewusst in der Anwendung (findExisting), nicht in einem UNIQUE-Index.
patientDao.insert(patientEntity(id = "p-1"))
patientDao.insert(patientEntity(id = "p-2"))
assertEquals(2, countRows("patients"))
}
// --- update / delete -----------------------------------------------------------------
@Test
fun update_ueberschreibt_die_felder_des_datensatzes() = runDbTest {
patientDao.insert(patientEntity())
val changed = patientEntity(lastName = "Musterfrau", city = "Neustadt", phone2 = "0111", updatedAt = 2_000L)
patientDao.update(changed)
assertEquals(changed, patientDao.getById("p-1"))
}
@Test
fun update_einer_unbekannten_id_legt_keinen_datensatz_an() = runDbTest {
patientDao.update(patientEntity(id = "geist"))
assertNull(patientDao.getById("geist"))
assertEquals(0, countRows("patients"))
}
@Test
fun delete_entfernt_nur_den_uebergebenen_patienten() = runDbTest {
patientDao.insert(patientEntity(id = "p-1"))
patientDao.insert(patientEntity(id = "p-2"))
patientDao.delete(patientEntity(id = "p-1"))
assertNull(patientDao.getById("p-1"))
assertNotNull(patientDao.getById("p-2"))
}
@Test
fun delete_eines_unbekannten_patienten_ist_wirkungslos() = runDbTest {
patientDao.insert(patientEntity())
patientDao.delete(patientEntity(id = "unbekannt"))
assertEquals(1, countRows("patients"))
}
@Test
fun delete_wird_durch_vorhandene_dokumentation_verhindert_restrict() = runDbTest {
seedDocumentation()
expectThrows<SQLiteConstraintException> { patientDao.delete(patientEntity()) }
assertNotNull(patientDao.getById("p-1"))
assertEquals(1, countRows("documentations"))
}
// --- findExisting --------------------------------------------------------------------
@Test
fun findExisting_trifft_bei_gleichem_vornamen_nachnamen_und_geburtsdatum() = runDbTest {
patientDao.insert(patientEntity())
assertEquals("p-1", patientDao.findExisting("Max", "Mustermann", "1980-03-14")?.id)
}
@Test
fun findExisting_trifft_nicht_bei_abweichendem_feld() = runDbTest {
patientDao.insert(patientEntity())
assertNull(patientDao.findExisting("Moritz", "Mustermann", "1980-03-14"))
assertNull(patientDao.findExisting("Max", "Musterfrau", "1980-03-14"))
assertNull(patientDao.findExisting("Max", "Mustermann", "1980-03-15"))
}
@Test
fun findExisting_ist_ein_exakter_abgleich_und_unterscheidet_gross_und_kleinschreibung() = runDbTest {
// Dokumentiert das aktuelle Verhalten (SQL "="): "max" findet "Max" NICHT.
patientDao.insert(patientEntity())
assertNull(patientDao.findExisting("max", "mustermann", "1980-03-14"))
}
@Test
fun findExisting_liefert_bei_mehreren_treffern_genau_einen() = runDbTest {
patientDao.insert(patientEntity(id = "p-1"))
patientDao.insert(patientEntity(id = "p-2"))
val found = patientDao.findExisting("Max", "Mustermann", "1980-03-14")
assertTrue(found?.id == "p-1" || found?.id == "p-2")
}
// --- hasDocumentations ---------------------------------------------------------------
@Test
fun hasDocumentations_ist_false_ohne_und_true_mit_dokumentation() = runDbTest {
seedPatient("p-1")
seedPatient("p-2", lastName = "Musterfrau")
seedDocumentation(id = "d-1", patientId = "p-1")
assertTrue(patientDao.hasDocumentations("p-1"))
assertFalse(patientDao.hasDocumentations("p-2"))
assertFalse(patientDao.hasDocumentations("unbekannt"))
}
// --- observeAllWithLastDocumentation -------------------------------------------------
@Test
fun observeAllWithLastDocumentation_sortiert_nach_nachname_dann_vorname() = runDbTest {
seedPatient("p-1", firstName = "Max", lastName = "Müller")
seedPatient("p-2", firstName = "Anna", lastName = "Müller")
seedPatient("p-3", firstName = "Zoe", lastName = "Adler")
val result = patientDao.observeAllWithLastDocumentation().first()
assertEquals(listOf("p-3", "p-2", "p-1"), result.map { it.patient.id })
}
@Test
fun lastDocumentationAt_ist_das_maximale_erstelldatum_unabhaengig_von_der_einfuegereihenfolge() = runDbTest {
seedPatient("p-1")
seedDocumentation(id = "d-1", patientId = "p-1", createdAt = 3_000L)
seedDocumentation(id = "d-2", patientId = "p-1", createdAt = 1_000L)
seedDocumentation(id = "d-3", patientId = "p-1", createdAt = 2_000L)
val result = patientDao.observeAllWithLastDocumentation().first().single()
assertEquals(3_000L, result.lastDocumentationAt)
}
@Test
fun lastDocumentationAt_ist_null_ohne_dokumentation_und_beruecksichtigt_nur_eigene_dokumentationen() = runDbTest {
seedPatient("p-1")
seedPatient("p-2", firstName = "Erika", lastName = "Musterfrau")
seedDocumentation(id = "d-1", patientId = "p-1", createdAt = 5_000L)
val byId = patientDao.observeAllWithLastDocumentation().first().associateBy { it.patient.id }
assertEquals(5_000L, byId.getValue("p-1").lastDocumentationAt)
assertNull(byId.getValue("p-2").lastDocumentationAt)
}
@Test
fun observeAllWithLastDocumentation_liefert_leere_liste_ohne_patienten() = runDbTest {
assertTrue(patientDao.observeAllWithLastDocumentation().first().isEmpty())
}
@Test
fun observeAllWithLastDocumentation_aktualisiert_sich_bei_aenderungen() = runDbTest {
seedPatient("p-1")
val waiting = async {
patientDao.observeAllWithLastDocumentation().awaitMatching { list ->
list.size == 2 && list.any { it.patient.id == "p-1" && it.lastDocumentationAt == 9_000L }
}
}
seedPatient("p-2", firstName = "Erika", lastName = "Musterfrau")
seedDocumentation(id = "d-1", patientId = "p-1", createdAt = 9_000L)
assertEquals(2, waiting.await().size)
}
}
@@ -0,0 +1,168 @@
package de.medapp.wunddoku.data.dao
import android.database.sqlite.SQLiteConstraintException
import de.medapp.wunddoku.testutil.RoomTestBase
import de.medapp.wunddoku.testutil.expectThrows
import de.medapp.wunddoku.testutil.photoEntity
import de.medapp.wunddoku.testutil.runDbTest
import de.medapp.wunddoku.testutil.signatureEntity
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/** DAO-Tests für [PhotoDao] und [SignatureDao] (beide nach demselben Muster aufgebaut). */
class PhotoAndSignatureDaoTest : RoomTestBase() {
// --- PhotoDao ------------------------------------------------------------------------
@Test
fun photo_insert_und_getById_liefern_alle_felder() = runDbTest {
seedDocumentation()
val photo = photoEntity(fileName = "wunde_2026-08-23.jpg", orderIndex = 2, createdAt = 5_000L)
photoDao.insert(photo)
assertEquals(photo, photoDao.getById("ph-1"))
assertNull(photoDao.getById("unbekannt"))
}
@Test
fun photo_getForDocumentation_sortiert_nach_orderIndex_und_filtert_nach_dokumentation() = runDbTest {
seedDocumentation(id = "d-1")
seedDocumentation(id = "d-2")
photoDao.insert(photoEntity(id = "ph-3", documentationId = "d-1", orderIndex = 2))
photoDao.insert(photoEntity(id = "ph-1", documentationId = "d-1", orderIndex = 0))
photoDao.insert(photoEntity(id = "ph-2", documentationId = "d-1", orderIndex = 1))
photoDao.insert(photoEntity(id = "ph-x", documentationId = "d-2", orderIndex = 0))
assertEquals(listOf("ph-1", "ph-2", "ph-3"), photoDao.getForDocumentation("d-1").map { it.id })
assertEquals(listOf("ph-x"), photoDao.getForDocumentation("d-2").map { it.id })
assertTrue(photoDao.getForDocumentation("unbekannt").isEmpty())
}
@Test
fun photo_getMaxOrderIndex_ist_minus_eins_ohne_fotos_sonst_der_hoechste_wert() = runDbTest {
seedDocumentation(id = "d-1")
seedDocumentation(id = "d-2")
assertEquals(-1, photoDao.getMaxOrderIndex("d-1"))
photoDao.insert(photoEntity(id = "ph-1", documentationId = "d-1", orderIndex = 0))
photoDao.insert(photoEntity(id = "ph-2", documentationId = "d-1", orderIndex = 4))
photoDao.insert(photoEntity(id = "ph-3", documentationId = "d-2", orderIndex = 9))
assertEquals(4, photoDao.getMaxOrderIndex("d-1"))
assertEquals(9, photoDao.getMaxOrderIndex("d-2"))
assertEquals(-1, photoDao.getMaxOrderIndex("unbekannt"))
}
@Test
fun photo_getMaxOrderIndex_bleibt_nach_dem_loeschen_eines_mittleren_fotos_korrekt() = runDbTest {
seedDocumentation()
photoDao.insert(photoEntity(id = "ph-1", orderIndex = 0))
photoDao.insert(photoEntity(id = "ph-2", orderIndex = 1))
photoDao.insert(photoEntity(id = "ph-3", orderIndex = 2))
photoDao.delete(photoEntity(id = "ph-2", orderIndex = 1))
assertEquals(2, photoDao.getMaxOrderIndex("d-1"))
assertEquals(listOf(0, 2), photoDao.getForDocumentation("d-1").map { it.orderIndex })
}
@Test
fun photo_delete_entfernt_nur_das_angegebene_foto() = runDbTest {
seedDocumentation()
photoDao.insert(photoEntity(id = "ph-1", orderIndex = 0))
photoDao.insert(photoEntity(id = "ph-2", orderIndex = 1))
photoDao.delete(photoEntity(id = "ph-1"))
assertNull(photoDao.getById("ph-1"))
assertEquals("ph-2", photoDao.getById("ph-2")?.id)
}
@Test
fun photo_mit_gleichem_orderIndex_ist_erlaubt_kein_unique_index() = runDbTest {
seedDocumentation()
photoDao.insert(photoEntity(id = "ph-1", orderIndex = 0))
photoDao.insert(photoEntity(id = "ph-2", orderIndex = 0))
assertEquals(2, photoDao.getForDocumentation("d-1").size)
}
@Test
fun photo_mit_vorhandener_id_oder_unbekannter_dokumentation_wirft() = runDbTest {
seedDocumentation()
photoDao.insert(photoEntity(id = "ph-1"))
expectThrows<SQLiteConstraintException> { photoDao.insert(photoEntity(id = "ph-1", fileName = "andere.jpg")) }
expectThrows<SQLiteConstraintException> { photoDao.insert(photoEntity(id = "ph-2", documentationId = "gibt-es-nicht")) }
assertEquals(1, countRows("photos"))
}
// --- SignatureDao --------------------------------------------------------------------
@Test
fun signature_insert_und_getById_liefern_alle_felder() = runDbTest {
seedDocumentation()
val signature = signatureEntity(signerName = "Dr. Åsa Müller-Öz", fileName = "sig_1.png", orderIndex = 1, createdAt = 7_000L)
signatureDao.insert(signature)
assertEquals(signature, signatureDao.getById("s-1"))
assertNull(signatureDao.getById("unbekannt"))
}
@Test
fun signature_getForDocumentation_sortiert_nach_orderIndex_und_filtert_nach_dokumentation() = runDbTest {
seedDocumentation(id = "d-1")
seedDocumentation(id = "d-2")
signatureDao.insert(signatureEntity(id = "s-3", documentationId = "d-1", orderIndex = 2))
signatureDao.insert(signatureEntity(id = "s-1", documentationId = "d-1", orderIndex = 0))
signatureDao.insert(signatureEntity(id = "s-2", documentationId = "d-1", orderIndex = 1))
signatureDao.insert(signatureEntity(id = "s-x", documentationId = "d-2", orderIndex = 0))
assertEquals(listOf("s-1", "s-2", "s-3"), signatureDao.getForDocumentation("d-1").map { it.id })
assertEquals(listOf("s-x"), signatureDao.getForDocumentation("d-2").map { it.id })
assertTrue(signatureDao.getForDocumentation("unbekannt").isEmpty())
}
@Test
fun signature_getMaxOrderIndex_ist_minus_eins_ohne_signaturen_sonst_der_hoechste_wert() = runDbTest {
seedDocumentation(id = "d-1")
seedDocumentation(id = "d-2")
assertEquals(-1, signatureDao.getMaxOrderIndex("d-1"))
signatureDao.insert(signatureEntity(id = "s-1", documentationId = "d-1", orderIndex = 0))
signatureDao.insert(signatureEntity(id = "s-2", documentationId = "d-1", orderIndex = 3))
signatureDao.insert(signatureEntity(id = "s-3", documentationId = "d-2", orderIndex = 7))
assertEquals(3, signatureDao.getMaxOrderIndex("d-1"))
assertEquals(7, signatureDao.getMaxOrderIndex("d-2"))
}
@Test
fun signature_delete_entfernt_nur_die_angegebene_signatur() = runDbTest {
seedDocumentation()
signatureDao.insert(signatureEntity(id = "s-1", orderIndex = 0))
signatureDao.insert(signatureEntity(id = "s-2", orderIndex = 1))
signatureDao.delete(signatureEntity(id = "s-1"))
assertNull(signatureDao.getById("s-1"))
assertEquals("s-2", signatureDao.getById("s-2")?.id)
}
@Test
fun signature_mit_vorhandener_id_oder_unbekannter_dokumentation_wirft() = runDbTest {
seedDocumentation()
signatureDao.insert(signatureEntity(id = "s-1"))
expectThrows<SQLiteConstraintException> { signatureDao.insert(signatureEntity(id = "s-1", signerName = "Anderer")) }
expectThrows<SQLiteConstraintException> { signatureDao.insert(signatureEntity(id = "s-2", documentationId = "gibt-es-nicht")) }
assertEquals(1, countRows("signatures"))
}
}
@@ -0,0 +1,229 @@
package de.medapp.wunddoku.data.dao
import de.medapp.wunddoku.testutil.RoomTestBase
import de.medapp.wunddoku.testutil.awaitMatching
import de.medapp.wunddoku.testutil.documentationEntity
import de.medapp.wunddoku.testutil.runDbTest
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.first
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Suchtests (QA-002): [DocumentationDao.search] und [PatientDao.searchWithLastDocumentation].
*
* Szenario (Erstelldatum in Klammern):
* - p-1 Max Mustermann, 1980-03-14: d-1 (1000), d-2 (3000)
* - p-2 Erika Musterfrau, 1975-12-01: d-3 (2000)
* - p-3 Hans Beispiel, 1990-07-30: d-4 (500)
* - p-4 Zora Zimmer, 2001-01-01: keine Dokumentation
*/
class SearchQueryTest : RoomTestBase() {
private suspend fun seedDoc(id: String, patientId: String, createdAt: Long, first: String, last: String, birth: String) {
documentationDao.insert(
documentationEntity(
id = id, patientId = patientId, createdAt = createdAt,
firstNameSnapshot = first, lastNameSnapshot = last, birthDateSnapshot = birth,
)
)
}
private suspend fun seedScenario() {
seedPatient("p-1", "Max", "Mustermann", "1980-03-14")
seedPatient("p-2", "Erika", "Musterfrau", "1975-12-01")
seedPatient("p-3", "Hans", "Beispiel", "1990-07-30")
seedPatient("p-4", "Zora", "Zimmer", "2001-01-01")
seedDoc("d-1", "p-1", 1_000L, "Max", "Mustermann", "1980-03-14")
seedDoc("d-2", "p-1", 3_000L, "Max", "Mustermann", "1980-03-14")
seedDoc("d-3", "p-2", 2_000L, "Erika", "Musterfrau", "1975-12-01")
seedDoc("d-4", "p-3", 500L, "Hans", "Beispiel", "1990-07-30")
}
private suspend fun searchDocs(query: String) = documentationDao.search(query).first().map { it.id }
// --- Dokumentationssuche -------------------------------------------------------------
@Test
fun dokumentationssuche_findet_ueber_den_nachnamen_neueste_zuerst() = runDbTest {
seedScenario()
assertEquals(listOf("d-2", "d-3", "d-1"), searchDocs("Muster"))
assertEquals(listOf("d-2", "d-1"), searchDocs("Mustermann"))
}
@Test
fun dokumentationssuche_findet_ueber_den_vornamen_ohne_beachtung_der_gross_und_kleinschreibung() = runDbTest {
seedScenario()
assertEquals(listOf("d-3"), searchDocs("erika"))
assertEquals(listOf("d-3"), searchDocs("ERIKA"))
}
@Test
fun dokumentationssuche_findet_ueber_das_geburtsdatum_auch_teilweise() = runDbTest {
seedScenario()
assertEquals(listOf("d-4"), searchDocs("1990-07"))
assertEquals(listOf("d-2", "d-1"), searchDocs("1980"))
assertEquals(listOf("d-3"), searchDocs("12-01"))
}
@Test
fun dokumentationssuche_ist_ein_teilstring_abgleich() = runDbTest {
seedScenario()
assertEquals(listOf("d-2", "d-1"), searchDocs("term"))
}
@Test
fun dokumentationssuche_ohne_treffer_liefert_leere_liste() = runDbTest {
seedScenario()
assertTrue(searchDocs("Zzz").isEmpty())
}
@Test
fun dokumentationssuche_liefert_keine_dubletten_wenn_mehrere_spalten_treffen() = runDbTest {
seedScenario()
// "e" trifft bei p-2 gleichzeitig Vor- und Nachnamen (Erika / Musterfrau).
val ids = searchDocs("e")
assertEquals(listOf("d-2", "d-3", "d-1", "d-4"), ids)
assertEquals(ids.size, ids.toSet().size)
}
@Test
fun dokumentationssuche_findet_keine_patienten_ohne_dokumentation() = runDbTest {
seedScenario()
assertTrue(searchDocs("Zora").isEmpty())
}
@Test
fun dokumentationssuche_mit_leerem_text_liefert_alle_dokumentationen_der_schutz_davor_liegt_im_repository() = runDbTest {
seedScenario()
assertEquals(listOf("d-2", "d-3", "d-1", "d-4"), searchDocs(""))
}
@Test
fun dokumentationssuche_sucht_gegen_den_aktuellen_namen_und_nicht_gegen_den_snapshot() = runDbTest {
seedScenario()
patientDao.update(patientDao.getById("p-1")!!.copy(lastName = "Neuname"))
assertEquals(listOf("d-2", "d-1"), searchDocs("Neuname"))
assertTrue(searchDocs("Mustermann").isEmpty())
// Die gelieferten Zeilen zeigen weiterhin den eingefrorenen Namen.
val hits = documentationDao.search("Neuname").first()
assertTrue(hits.all { it.patientLastNameSnapshot == "Mustermann" })
}
@Test
fun dokumentationssuche_behandelt_sql_sonderzeichen_als_normalen_text() = runDbTest {
seedScenario()
assertTrue(searchDocs("'; DROP TABLE patients; --").isEmpty())
assertTrue(searchDocs("\"").isEmpty())
assertTrue(searchDocs("O'Brien").isEmpty())
assertEquals(4, countRows("patients"))
}
@Test
fun dokumentationssuche_aktualisiert_sich_bei_neuen_dokumentationen_und_namensaenderungen() = runDbTest {
seedScenario()
val afterNewDoc = async { documentationDao.search("Muster").awaitMatching { it.size == 4 } }
seedDoc("d-5", "p-1", 4_000L, "Max", "Mustermann", "1980-03-14")
assertEquals("d-5", afterNewDoc.await().first().id)
val afterRename = async { documentationDao.search("Neuname").awaitMatching { it.size == 3 } }
patientDao.update(patientDao.getById("p-1")!!.copy(lastName = "Neuname"))
assertEquals(listOf("d-5", "d-2", "d-1"), afterRename.await().map { it.id })
}
// --- Patientensuche ------------------------------------------------------------------
private suspend fun searchPatients(query: String) = patientDao.searchWithLastDocumentation(query).first()
@Test
fun patientensuche_sortiert_treffer_nach_nachname_und_liefert_letztes_dokumentationsdatum() = runDbTest {
seedScenario()
val result = searchPatients("Muster")
assertEquals(listOf("p-2", "p-1"), result.map { it.patient.id }) // Musterfrau vor Mustermann
assertEquals(listOf(2_000L, 3_000L), result.map { it.lastDocumentationAt })
}
@Test
fun patientensuche_findet_ueber_vorname_nachname_und_geburtsdatum() = runDbTest {
seedScenario()
assertEquals(listOf("p-3"), searchPatients("han").map { it.patient.id })
assertEquals(listOf("p-3"), searchPatients("beispiel").map { it.patient.id })
assertEquals(listOf("p-2"), searchPatients("1975").map { it.patient.id })
assertTrue(searchPatients("Zzz").isEmpty())
}
@Test
fun patientensuche_findet_auch_patienten_ohne_dokumentation_mit_null_datum() = runDbTest {
seedScenario()
val result = searchPatients("Zora").single()
assertEquals("p-4", result.patient.id)
assertNull(result.lastDocumentationAt)
}
@Test
fun patientensuche_liefert_keine_dubletten_trotz_mehrerer_dokumentationen_und_treffer_spalten() = runDbTest {
seedScenario()
val ids = searchPatients("e").map { it.patient.id }
assertEquals(ids.size, ids.toSet().size)
assertEquals(listOf("p-3", "p-2", "p-1", "p-4"), ids) // Beispiel, Musterfrau, Mustermann, Zimmer
}
@Test
fun patientensuche_mit_leerem_text_liefert_alle_patienten_sortiert() = runDbTest {
seedScenario()
assertEquals(listOf("p-3", "p-2", "p-1", "p-4"), searchPatients("").map { it.patient.id })
}
@Test
fun patientensuche_sortiert_bei_gleichem_nachnamen_nach_vornamen() = runDbTest {
seedPatient("p-1", "Max", "Müller", "1980-01-01")
seedPatient("p-2", "Anna", "Müller", "1981-01-01")
assertEquals(listOf("p-2", "p-1"), searchPatients("Müller").map { it.patient.id })
}
@Test
fun patientensuche_behandelt_sql_sonderzeichen_als_normalen_text() = runDbTest {
seedScenario()
assertTrue(searchPatients("'; DROP TABLE patients; --").isEmpty())
assertEquals(4, countRows("patients"))
}
@Test
fun patientensuche_aktualisiert_sich_bei_neuen_patienten_und_dokumentationen() = runDbTest {
seedScenario()
val afterNewPatient = async { patientDao.searchWithLastDocumentation("Muster").awaitMatching { it.size == 3 } }
seedPatient("p-5", "Karl", "Musterkind", "2010-05-05")
assertEquals(3, afterNewPatient.await().size)
val afterNewDoc = async {
patientDao.searchWithLastDocumentation("Musterkind").awaitMatching { list -> list.singleOrNull()?.lastDocumentationAt == 8_000L }
}
seedDoc("d-9", "p-5", 8_000L, "Karl", "Musterkind", "2010-05-05")
afterNewDoc.await()
}
}
@@ -0,0 +1,128 @@
package de.medapp.wunddoku.data.dao
import de.medapp.wunddoku.testutil.RoomTestBase
import de.medapp.wunddoku.testutil.runDbTest
import de.medapp.wunddoku.testutil.woundCarerEntity
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/** DAO-Tests für [WoundCarerDao] (Wundversorger-Register). */
class WoundCarerDaoTest : RoomTestBase() {
// --- insertOrUpdate ------------------------------------------------------------------
@Test
fun insertOrUpdate_legt_neuen_eintrag_an() = runDbTest {
val carer = woundCarerEntity(updatedAt = 5_000L)
woundCarerDao.insertOrUpdate(carer)
assertEquals(carer, woundCarerDao.getMostRecentlyUsed())
}
@Test
fun insertOrUpdate_ersetzt_vorhandenen_eintrag_mit_gleicher_id_vollstaendig() = runDbTest {
woundCarerDao.insertOrUpdate(woundCarerEntity(id = "w-1", name = "Anna Schmidt", signatureFileName = "alt.png", updatedAt = 1_000L))
woundCarerDao.insertOrUpdate(woundCarerEntity(id = "w-1", name = "Anna Schmidt-Neu", signatureFileName = "neu.png", updatedAt = 2_000L))
assertEquals(1, countRows("wound_carers"))
val loaded = woundCarerDao.getMostRecentlyUsed()!!
assertEquals("Anna Schmidt-Neu", loaded.name)
assertEquals("neu.png", loaded.signatureFileName)
assertEquals(2_000L, loaded.updatedAt)
}
@Test
fun insertOrUpdate_mit_verschiedenen_ids_und_gleichem_namen_legt_zwei_eintraege_an() = runDbTest {
// Namens-Eindeutigkeit stellt das Repository (findByName) sicher, nicht die Datenbank.
woundCarerDao.insertOrUpdate(woundCarerEntity(id = "w-1", name = "Anna"))
woundCarerDao.insertOrUpdate(woundCarerEntity(id = "w-2", name = "Anna"))
assertEquals(2, countRows("wound_carers"))
}
// --- findByName ----------------------------------------------------------------------
@Test
fun findByName_ist_ein_exakter_abgleich_ohne_beachtung_der_gross_und_kleinschreibung() = runDbTest {
woundCarerDao.insertOrUpdate(woundCarerEntity(id = "w-1", name = "Anna Schmidt"))
assertEquals("w-1", woundCarerDao.findByName("Anna Schmidt")?.id)
assertEquals("w-1", woundCarerDao.findByName("anna schmidt")?.id)
assertEquals("w-1", woundCarerDao.findByName("ANNA SCHMIDT")?.id)
}
@Test
fun findByName_trifft_nicht_bei_teilstring_oder_praefix() = runDbTest {
woundCarerDao.insertOrUpdate(woundCarerEntity(name = "Anna Schmidt"))
assertNull(woundCarerDao.findByName("Anna"))
assertNull(woundCarerDao.findByName("Schmidt"))
assertNull(woundCarerDao.findByName("Anna Schmidt "))
assertNull(woundCarerDao.findByName("unbekannt"))
}
// --- searchByNamePrefix --------------------------------------------------------------
@Test
fun searchByNamePrefix_liefert_praefix_treffer_ohne_beachtung_der_gross_und_kleinschreibung() = runDbTest {
woundCarerDao.insertOrUpdate(woundCarerEntity(id = "w-1", name = "Anna Schmidt", updatedAt = 1_000L))
woundCarerDao.insertOrUpdate(woundCarerEntity(id = "w-2", name = "Andreas Müller", updatedAt = 2_000L))
woundCarerDao.insertOrUpdate(woundCarerEntity(id = "w-3", name = "Bernd Beispiel", updatedAt = 3_000L))
assertEquals(listOf("w-2", "w-1"), woundCarerDao.searchByNamePrefix("an", 10).map { it.id })
assertEquals(listOf("w-2", "w-1"), woundCarerDao.searchByNamePrefix("AN", 10).map { it.id })
assertEquals(listOf("w-3"), woundCarerDao.searchByNamePrefix("Bern", 10).map { it.id })
}
@Test
fun searchByNamePrefix_ist_kein_teilstring_abgleich() = runDbTest {
woundCarerDao.insertOrUpdate(woundCarerEntity(name = "Anna Schmidt"))
assertTrue(woundCarerDao.searchByNamePrefix("Schmidt", 10).isEmpty())
assertTrue(woundCarerDao.searchByNamePrefix("nna", 10).isEmpty())
}
@Test
fun searchByNamePrefix_sortiert_nach_letzter_verwendung_absteigend_und_beachtet_das_limit() = runDbTest {
woundCarerDao.insertOrUpdate(woundCarerEntity(id = "w-1", name = "Anna A", updatedAt = 1_000L))
woundCarerDao.insertOrUpdate(woundCarerEntity(id = "w-2", name = "Anna B", updatedAt = 4_000L))
woundCarerDao.insertOrUpdate(woundCarerEntity(id = "w-3", name = "Anna C", updatedAt = 3_000L))
woundCarerDao.insertOrUpdate(woundCarerEntity(id = "w-4", name = "Anna D", updatedAt = 2_000L))
assertEquals(listOf("w-2", "w-3", "w-4", "w-1"), woundCarerDao.searchByNamePrefix("Anna", 10).map { it.id })
assertEquals(listOf("w-2", "w-3"), woundCarerDao.searchByNamePrefix("Anna", 2).map { it.id })
assertTrue(woundCarerDao.searchByNamePrefix("Anna", 0).isEmpty())
}
@Test
fun searchByNamePrefix_mit_leerem_praefix_liefert_alle_eintraege_der_schutz_davor_liegt_im_repository() = runDbTest {
woundCarerDao.insertOrUpdate(woundCarerEntity(id = "w-1", name = "Anna"))
woundCarerDao.insertOrUpdate(woundCarerEntity(id = "w-2", name = "Bernd", updatedAt = 2_000L))
assertEquals(2, woundCarerDao.searchByNamePrefix("", 10).size)
}
// --- getMostRecentlyUsed -------------------------------------------------------------
@Test
fun getMostRecentlyUsed_ist_null_bei_leerer_tabelle() = runDbTest {
assertNull(woundCarerDao.getMostRecentlyUsed())
}
@Test
fun getMostRecentlyUsed_liefert_den_eintrag_mit_dem_hoechsten_updatedAt() = runDbTest {
woundCarerDao.insertOrUpdate(woundCarerEntity(id = "w-1", name = "Anna", updatedAt = 1_000L))
woundCarerDao.insertOrUpdate(woundCarerEntity(id = "w-2", name = "Bernd", updatedAt = 3_000L))
woundCarerDao.insertOrUpdate(woundCarerEntity(id = "w-3", name = "Clara", updatedAt = 2_000L))
assertEquals("w-2", woundCarerDao.getMostRecentlyUsed()?.id)
woundCarerDao.insertOrUpdate(woundCarerEntity(id = "w-1", name = "Anna", updatedAt = 9_000L))
assertEquals("w-1", woundCarerDao.getMostRecentlyUsed()?.id)
}
}
@@ -0,0 +1,222 @@
package de.medapp.wunddoku.data.database
import android.database.sqlite.SQLiteConstraintException
import androidx.room.withTransaction
import de.medapp.wunddoku.testutil.RoomTestBase
import de.medapp.wunddoku.testutil.blockStateEntity
import de.medapp.wunddoku.testutil.checkItemStateEntity
import de.medapp.wunddoku.testutil.documentationEntity
import de.medapp.wunddoku.testutil.expectThrows
import de.medapp.wunddoku.testutil.photoEntity
import de.medapp.wunddoku.testutil.runDbTest
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Test
/**
* Transaction-Tests (QA-002): Room-Transaktionen (`db.withTransaction`) müssen Mehrtabellen-
* Schreibvorgänge - wie in DocumentationRepository.createEmpty()/continueDocumentation() -
* atomar machen.
*
* Hinweis: In-Memory-Datenbanken haben nur eine Verbindung. Deshalb wird innerhalb einer
* Transaktion ausschließlich über DAOs gelesen, nie über Roh-SQL des Test-Threads.
*/
class DatabaseTransactionTest : RoomTestBase() {
// --- Commit --------------------------------------------------------------------------
@Test
fun commit_macht_alle_schreibvorgaenge_sichtbar() = runDbTest {
seedPatient()
db.withTransaction {
documentationDao.insert(documentationEntity())
blockStateDao.insertAll(listOf(blockStateEntity()))
checkItemStateDao.insertAll(listOf(checkItemStateEntity()))
photoDao.insert(photoEntity())
}
assertEquals(1, countRows("documentations"))
assertEquals(1, countRows("block_states"))
assertEquals(1, countRows("check_item_states"))
assertEquals(1, countRows("photos"))
}
@Test
fun transaktion_gibt_das_ergebnis_des_blocks_zurueck() = runDbTest {
val result = db.withTransaction {
seedPatient()
42
}
assertEquals(42, result)
assertNotNull(patientDao.getById("p-1"))
}
@Test
fun innerhalb_der_transaktion_sind_die_eigenen_schreibvorgaenge_bereits_lesbar() = runDbTest {
seedPatient()
db.withTransaction {
documentationDao.insert(documentationEntity())
assertNotNull(documentationDao.getById("d-1"))
blockStateDao.insertAll(listOf(blockStateEntity()))
assertEquals(1, blockStateDao.getForDocumentation("d-1").size)
}
}
// --- Rollback ------------------------------------------------------------------------
@Test
fun exception_im_block_verwirft_alle_schreibvorgaenge_und_wird_weitergereicht() = runDbTest {
seedPatient()
val error = expectThrows<IllegalStateException> {
db.withTransaction {
documentationDao.insert(documentationEntity())
blockStateDao.insertAll(listOf(blockStateEntity()))
checkItemStateDao.insertAll(listOf(checkItemStateEntity()))
error("Abbruch mitten in der Transaktion")
}
}
assertEquals("Abbruch mitten in der Transaktion", error.message)
assertNull(documentationDao.getById("d-1"))
assertEquals(0, countRows("block_states"))
assertEquals(0, countRows("check_item_states"))
assertNotNull("Daten vor der Transaktion bleiben bestehen", patientDao.getById("p-1"))
}
@Test
fun constraint_verletzung_in_einem_spaeteren_schritt_verwirft_auch_die_frueheren_inserts() = runDbTest {
seedPatient()
expectThrows<SQLiteConstraintException> {
db.withTransaction {
documentationDao.insert(documentationEntity())
photoDao.insert(photoEntity(documentationId = "gibt-es-nicht")) // FK-Verletzung
}
}
assertNull(documentationDao.getById("d-1"))
assertEquals(0, countRows("photos"))
}
@Test
fun insertAll_ist_atomar_ein_fehlerhafter_eintrag_verwirft_die_ganze_liste() = runDbTest {
seedDocumentation()
expectThrows<SQLiteConstraintException> {
blockStateDao.insertAll(
listOf(
blockStateEntity(id = "b-1", blockDefinitionId = "wundart"),
blockStateEntity(id = "b-2", blockDefinitionId = "groesse"),
blockStateEntity(id = "b-3", blockDefinitionId = "wundart"), // verletzt UNIQUE
)
)
}
assertEquals(0, countRows("block_states"))
}
@Test
fun rollback_nimmt_ein_cascade_loeschen_wieder_zurueck() = runDbTest {
val doc = seedDocumentation()
blockStateDao.insertAll(listOf(blockStateEntity()))
checkItemStateDao.insertAll(listOf(checkItemStateEntity()))
photoDao.insert(photoEntity())
expectThrows<IllegalStateException> {
db.withTransaction {
documentationDao.delete(doc)
assertNull(documentationDao.getById("d-1"))
error("Abbruch nach dem Löschen")
}
}
assertNotNull(documentationDao.getById("d-1"))
assertEquals(1, countRows("block_states"))
assertEquals(1, countRows("check_item_states"))
assertEquals(1, countRows("photos"))
}
@Test
fun datenbank_bleibt_nach_einer_fehlgeschlagenen_transaktion_voll_nutzbar() = runDbTest {
seedPatient()
expectThrows<IllegalStateException> {
db.withTransaction {
documentationDao.insert(documentationEntity(id = "d-verloren"))
error("Abbruch")
}
}
db.withTransaction { documentationDao.insert(documentationEntity(id = "d-ok")) }
documentationDao.insert(documentationEntity(id = "d-direkt"))
assertNull(documentationDao.getById("d-verloren"))
assertNotNull(documentationDao.getById("d-ok"))
assertNotNull(documentationDao.getById("d-direkt"))
}
// --- Verschachtelung -----------------------------------------------------------------
@Test
fun verschachtelte_transaktionen_commiten_gemeinsam() = runDbTest {
seedPatient()
db.withTransaction {
documentationDao.insert(documentationEntity(id = "d-aussen"))
db.withTransaction { documentationDao.insert(documentationEntity(id = "d-innen")) }
}
assertEquals(2, countRows("documentations"))
}
@Test
fun fehler_in_der_inneren_transaktion_rollt_auch_die_aeussere_zurueck_selbst_wenn_er_gefangen_wird() = runDbTest {
// SQLite/Android: Wird eine verschachtelte Transaktion nicht als erfolgreich beendet,
// wird die äußere ebenfalls zurückgerollt (SQLiteDatabase.beginTransaction-Doku).
seedPatient()
db.withTransaction {
documentationDao.insert(documentationEntity(id = "d-aussen"))
try {
db.withTransaction {
documentationDao.insert(documentationEntity(id = "d-innen"))
error("Fehler innen")
}
} catch (e: IllegalStateException) {
// bewusst gefangen: "e" ist unbenutzt, der Fehler soll die äußere Transaktion nicht abbrechen
}
}
assertEquals(0, countRows("documentations"))
}
// --- Nebenläufigkeit -----------------------------------------------------------------
@Test
fun parallele_transaktionen_werden_serialisiert_und_verlieren_keine_schreibvorgaenge() = runDbTest {
seedDocumentation()
coroutineScope {
listOf("a", "b", "c").map { prefix ->
async(Dispatchers.Default) {
repeat(20) { i ->
db.withTransaction {
photoDao.insert(photoEntity(id = "$prefix-$i", fileName = "$prefix$i.jpg", orderIndex = i))
}
}
}
}.awaitAll()
}
assertEquals(60, countRows("photos"))
assertEquals(60, photoDao.getForDocumentation("d-1").size)
}
}
@@ -0,0 +1,378 @@
package de.medapp.wunddoku.data.repository
import android.net.Uri
import androidx.test.ext.junit.runners.AndroidJUnit4
import de.medapp.wunddoku.data.entity.BlockStateEntity
import de.medapp.wunddoku.data.entity.CheckItemStateEntity
import de.medapp.wunddoku.data.entity.DocumentationEntity
import de.medapp.wunddoku.domain.catalog.DocumentationCatalog
import de.medapp.wunddoku.domain.model.CheckItem
import de.medapp.wunddoku.domain.model.Documentation
import de.medapp.wunddoku.domain.model.DocumentationStatus
import de.medapp.wunddoku.testutil.DuplicateCall
import de.medapp.wunddoku.testutil.TestEnvironment
import de.medapp.wunddoku.testutil.expectThrows
import de.medapp.wunddoku.testutil.firstPlainItem
import de.medapp.wunddoku.testutil.runDbTest
import de.medapp.wunddoku.testutil.signatureBitmap
import kotlinx.coroutines.flow.first
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import java.io.IOException
import java.time.LocalDate
/**
* Fortsetzen-Tests: "Dokumentation fortsetzen" erzeugt aus dem Stand einer vorhandenen
* Dokumentation eine NEUE; die Quelle bleibt unverändert.
*/
@RunWith(AndroidJUnit4::class)
class ContinueDocumentationTest {
private lateinit var env: TestEnvironment
private val repo get() = env.documentationRepository
@Before
fun setUp() {
env = TestEnvironment()
}
@After
fun tearDown() {
env.close()
}
/** Alle Items eines Dokuments als Map definitionId (inkl. Blockbezug) -> Item. */
private fun Documentation.itemsByKey(): Map<String, CheckItem> =
blocks.flatMap { block -> block.items.map { "${block.definitionId}/${it.definitionId}" to it } }.toMap()
/** Bereitet eine Quelle mit einigen Eingaben vor und liefert sie frisch geladen zurück. */
private suspend fun prepareSourceWithInput(): Documentation {
env.insertPatient()
val created = repo.createEmpty("patient-1")
val firstBlock = created.blocks.first()
repo.setChecked(firstBlock.firstPlainItem().checkItemStateId, true)
val freeText = firstBlock.items.last()
repo.setChecked(freeText.checkItemStateId, true)
repo.updateFreeText(freeText.checkItemStateId, "Rötung am Wundrand")
return repo.getFullDocumentation(created.id)!!
}
// --- Inhalt wird übernommen ----------------------------------------------------------
@Test
fun fortsetzen_uebernimmt_bloecke_und_optionen_inklusive_haken_und_freitext() = runDbTest {
val source = prepareSourceWithInput()
val copy = repo.continueDocumentation(source.id)
assertEquals(source.blocks.size, copy.blocks.size)
for (sourceBlock in source.blocks) {
val copyBlock = copy.blocks.single { it.definitionId == sourceBlock.definitionId }
assertEquals(sourceBlock.title, copyBlock.title)
assertEquals(sourceBlock.order, copyBlock.order)
assertEquals(sourceBlock.isEdited, copyBlock.isEdited)
}
// Option für Option: gleiche Inhalte (Label, Reihenfolge, Haken, Freitext, Hinweis, strukturierte Eingabe).
val sourceItems = source.itemsByKey()
val copyItems = copy.itemsByKey()
assertEquals(sourceItems.keys, copyItems.keys)
for ((key, sourceItem) in sourceItems) {
val copyItem = copyItems.getValue(key)
assertEquals(key, sourceItem.label, copyItem.label)
assertEquals(key, sourceItem.order, copyItem.order)
assertEquals(key, sourceItem.checked, copyItem.checked)
assertEquals(key, sourceItem.isFreeText, copyItem.isFreeText)
assertEquals(key, sourceItem.freeTextValue, copyItem.freeTextValue)
assertEquals(key, sourceItem.isStaticHint, copyItem.isStaticHint)
assertEquals(key, sourceItem.inputSpec, copyItem.inputSpec)
}
assertEquals(1, copy.blocks.count { it.isEdited })
assertEquals("Rötung am Wundrand", copy.blocks.first().items.last().freeTextValue)
}
@Test
fun fortsetzen_erzeugt_ausschliesslich_neue_ids() = runDbTest {
val source = prepareSourceWithInput()
val copy = repo.continueDocumentation(source.id)
assertNotEquals(source.id, copy.id)
val sourceBlockIds = source.blocks.map { it.blockStateId }.toSet()
val sourceItemIds = source.blocks.flatMap { b -> b.items.map { it.checkItemStateId } }.toSet()
assertTrue(copy.blocks.none { it.blockStateId in sourceBlockIds })
assertTrue(copy.blocks.flatMap { b -> b.items.map { it.checkItemStateId } }.none { it in sourceItemIds })
}
@Test
fun fortsetzen_setzt_metadaten_der_neuen_dokumentation_korrekt() = runDbTest {
val source = prepareSourceWithInput()
env.setStatus(source.id, DocumentationStatus.COMPLETED)
val copy = repo.continueDocumentation(source.id)
assertEquals(DocumentationStatus.IN_PROGRESS, copy.status)
assertEquals(source.id, copy.continuedFromDocumentationId)
assertEquals(source.patientId, copy.patientId)
assertEquals(copy.createdAt, copy.updatedAt)
assertEquals(DocumentationStatus.COMPLETED, repo.getFullDocumentation(source.id)!!.status)
assertEquals(2, env.db.documentationDao().countForPatient("patient-1"))
assertTrue(env.patientRepository.hasDocumentations("patient-1"))
}
// --- Quelle bleibt unverändert / Unabhängigkeit --------------------------------------
@Test
fun fortsetzen_laesst_die_quelldokumentation_unveraendert() = runDbTest {
val source = prepareSourceWithInput()
repo.continueDocumentation(source.id)
val sourceAfter = repo.getFullDocumentation(source.id)!!
assertEquals(source.status, sourceAfter.status)
assertEquals(source.createdAt, sourceAfter.createdAt)
assertEquals(source.updatedAt, sourceAfter.updatedAt)
assertNull(sourceAfter.continuedFromDocumentationId)
assertEquals(source.blocks, sourceAfter.blocks)
assertEquals(source.photos, sourceAfter.photos)
assertEquals(source.signatures, sourceAfter.signatures)
}
@Test
fun aenderungen_an_der_kopie_veraendern_die_quelle_nicht_und_umgekehrt() = runDbTest {
val source = prepareSourceWithInput()
val copy = repo.continueDocumentation(source.id)
// Kopie: den in der Quelle gesetzten Haken entfernen und Freitext ändern.
val copyFirstBlock = copy.blocks.first()
repo.setChecked(copyFirstBlock.firstPlainItem().checkItemStateId, false)
repo.updateFreeText(copyFirstBlock.items.last().checkItemStateId, "Neuer Text")
val sourceAfter = repo.getFullDocumentation(source.id)!!
assertTrue(sourceAfter.blocks.first().firstPlainItem().checked)
assertEquals("Rötung am Wundrand", sourceAfter.blocks.first().items.last().freeTextValue)
// Quelle: weiteren Haken in einem anderen Block setzen.
val otherSourceBlock = source.blocks[1]
repo.setChecked(otherSourceBlock.firstPlainItem().checkItemStateId, true)
val copyAfter = repo.getFullDocumentation(copy.id)!!
assertFalse(copyAfter.blocks.single { it.definitionId == otherSourceBlock.definitionId }.isEdited)
assertEquals("Neuer Text", copyAfter.blocks.first().items.last().freeTextValue)
}
// --- Patienten-Snapshot --------------------------------------------------------------
@Test
fun fortsetzen_bildet_den_patienten_snapshot_frisch_aus_dem_aktuellen_patienten() = runDbTest {
val source = prepareSourceWithInput()
val patientDao = env.db.patientDao()
patientDao.update(
patientDao.getById("patient-1")!!.copy(
lastName = "Musterfrau", street = "Neue Straße 7", postalCode = "99999", city = "Neustadt",
)
)
val copy = repo.continueDocumentation(source.id)
assertEquals("Musterfrau", copy.patientSnapshot.lastName)
assertEquals("Neue Straße 7", copy.patientSnapshot.street)
assertEquals("99999", copy.patientSnapshot.postalCode)
assertEquals("Neustadt", copy.patientSnapshot.city)
assertEquals(LocalDate.of(1980, 3, 14), copy.patientSnapshot.birthDate)
// Die Quelle behält ihren damaligen Stand.
val sourceSnapshot = repo.getFullDocumentation(source.id)!!.patientSnapshot
assertEquals("Mustermann", sourceSnapshot.lastName)
assertEquals("Musterstraße 1", sourceSnapshot.street)
assertEquals("Musterstadt", sourceSnapshot.city)
}
// --- Historische Snapshots / Katalogversion ------------------------------------------
@Test
fun fortsetzen_bewahrt_historische_titel_labels_und_katalogversion() = runDbTest {
env.insertPatient()
val now = 5_000L
val historicDocId = "historic-doc"
env.db.documentationDao().insert(
DocumentationEntity(
id = historicDocId, patientId = "patient-1", status = DocumentationStatus.COMPLETED,
createdAt = now, updatedAt = now, continuedFromDocumentationId = null, pdfLastGeneratedAt = null,
catalogVersion = 1,
patientFirstNameSnapshot = "Max", patientLastNameSnapshot = "Mustermann",
patientBirthDateSnapshot = "1980-03-14", patientStreetSnapshot = "Musterstraße 1",
patientPostalCodeSnapshot = "12345", patientCitySnapshot = "Musterstadt",
)
)
env.db.blockStateDao().insertAll(
listOf(BlockStateEntity("hist-block", historicDocId, "gibt_es_nicht_mehr", "Alter Titel", 1, now))
)
env.db.checkItemStateDao().insertAll(
listOf(
CheckItemStateEntity("hist-item", "hist-block", "alter_eintrag", "Alter Eintrag", 1, checked = true),
)
)
assertNotEquals("Voraussetzung: aktuelle Katalogversion weicht ab", 1, DocumentationCatalog.CATALOG_VERSION)
val copy = repo.continueDocumentation(historicDocId)
assertEquals(1, copy.catalogVersion)
val block = copy.blocks.single()
assertEquals("gibt_es_nicht_mehr", block.definitionId)
assertEquals("Alter Titel", block.title)
assertEquals("Alter Eintrag", block.items.single().label)
assertTrue(block.items.single().checked)
}
// --- Unterschriften ------------------------------------------------------------------
@Test
fun fortsetzen_kopiert_keine_unterschriften() = runDbTest {
val source = prepareSourceWithInput()
repo.saveSignature(source.id, "Anna Schmidt", signatureBitmap())
val copy = repo.continueDocumentation(source.id)
assertTrue(copy.signatures.isEmpty())
assertEquals(1, repo.getFullDocumentation(source.id)!!.signatures.size)
assertEquals(1, env.signatureStorage.saved.size) // keine zusätzliche Datei angelegt
}
// --- Fotos ---------------------------------------------------------------------------
@Test
fun fortsetzen_kopiert_fotos_standardmaessig_als_eigenstaendige_dateien() = runDbTest {
val source = prepareSourceWithInput()
val p1 = repo.addPhoto(source.id, Uri.parse("content://test/1"))
val p2 = repo.addPhoto(source.id, Uri.parse("content://test/2"))
val copy = repo.continueDocumentation(source.id)
assertEquals(listOf(0, 1), copy.photos.map { it.orderIndex })
assertTrue(copy.photos.none { it.id == p1.id || it.id == p2.id })
assertTrue("Dateien müssen physisch eigenständig sein", copy.photos.none { it.fileName == p1.fileName || it.fileName == p2.fileName })
assertTrue(copy.photos.all { it.documentationId == copy.id })
assertEquals(
setOf(
DuplicateCall(source.id, p1.fileName, copy.id),
DuplicateCall(source.id, p2.fileName, copy.id),
),
env.photoStorage.duplicated.toSet(),
)
assertEquals(2, env.photoStorage.duplicated.size)
// Quelle behält ihre Fotos.
assertEquals(listOf(p1.fileName, p2.fileName), repo.getFullDocumentation(source.id)!!.photos.map { it.fileName })
}
@Test
fun fortsetzen_mit_copyPhotos_false_uebernimmt_keine_fotos() = runDbTest {
val source = prepareSourceWithInput()
repo.addPhoto(source.id, Uri.parse("content://test/1"))
val copy = repo.continueDocumentation(source.id, copyPhotos = false)
assertTrue(copy.photos.isEmpty())
assertTrue(env.photoStorage.duplicated.isEmpty())
}
@Test
fun foto_loeschen_in_der_kopie_betrifft_die_quelle_nicht() = runDbTest {
val source = prepareSourceWithInput()
repo.addPhoto(source.id, Uri.parse("content://test/1"))
val copy = repo.continueDocumentation(source.id)
repo.removePhoto(copy.photos.single().id)
assertTrue(repo.getFullDocumentation(copy.id)!!.photos.isEmpty())
assertEquals(1, repo.getFullDocumentation(source.id)!!.photos.size)
}
// --- Fehlerfälle / Atomarität --------------------------------------------------------
@Test
fun fortsetzen_einer_unbekannten_dokumentation_wirft_und_legt_nichts_an() = runDbTest {
env.insertPatient()
val error = expectThrows<IllegalStateException> { repo.continueDocumentation("unbekannt") }
assertTrue(error.message.orEmpty().contains("unbekannt"))
assertTrue(env.db.documentationDao().observeAll().first().isEmpty())
}
@Test
fun fortsetzen_ist_atomar_ein_fehler_beim_foto_kopieren_hinterlaesst_keine_halbe_dokumentation() = runDbTest {
val source = prepareSourceWithInput()
repo.addPhoto(source.id, Uri.parse("content://test/1"))
repo.addPhoto(source.id, Uri.parse("content://test/2"))
env.photoStorage.failOnDuplicateCall = 2 // zweites Foto schlägt fehl
expectThrows<IOException> { repo.continueDocumentation(source.id) }
val all = env.db.documentationDao().observeAll().first()
assertEquals(listOf(source.id), all.map { it.id })
assertEquals(1, env.db.documentationDao().countForPatient("patient-1"))
}
@Test
fun loeschen_der_quelle_laesst_die_fortgesetzte_dokumentation_bestehen() = runDbTest {
val source = prepareSourceWithInput()
repo.addPhoto(source.id, Uri.parse("content://test/1"))
val copy = repo.continueDocumentation(source.id)
repo.delete(source.id)
val copyAfter = repo.getFullDocumentation(copy.id)!!
assertNull("Verweis auf die gelöschte Quelle wird auf null gesetzt", copyAfter.continuedFromDocumentationId)
assertEquals(copy.blocks.size, copyAfter.blocks.size)
assertEquals(1, copyAfter.photos.size)
assertEquals(listOf(source.id), env.photoStorage.deletedAll) // nur der Ordner der Quelle
}
@Test
fun mehrfaches_fortsetzen_bildet_eine_kette() = runDbTest {
val first = prepareSourceWithInput()
val second = repo.continueDocumentation(first.id)
val third = repo.continueDocumentation(second.id)
assertEquals(first.id, second.continuedFromDocumentationId)
assertEquals(second.id, third.continuedFromDocumentationId)
assertEquals(1, third.blocks.count { it.isEdited })
assertEquals(3, env.db.documentationDao().countForPatient("patient-1"))
}
// --- continueLatestForPatient --------------------------------------------------------
@Test
fun continueLatestForPatient_liefert_null_wenn_der_patient_noch_keine_dokumentation_hat() = runDbTest {
env.insertPatient()
assertNull(repo.continueLatestForPatient("patient-1"))
assertTrue(env.db.documentationDao().observeAll().first().isEmpty())
}
@Test
fun continueLatestForPatient_setzt_die_zuletzt_erstellte_dokumentation_des_patienten_fort() = runDbTest {
env.insertPatient()
env.insertPatient(id = "patient-2", firstName = "Erika", lastName = "Musterfrau")
val older = repo.createEmpty("patient-1")
val newer = repo.createEmpty("patient-1")
val otherPatientsDoc = repo.createEmpty("patient-2")
env.setCreatedAt(older.id, 1_000L)
env.setCreatedAt(newer.id, 2_000L)
env.setCreatedAt(otherPatientsDoc.id, 3_000L) // neuer, aber anderer Patient
repo.setChecked(newer.blocks.first().firstPlainItem().checkItemStateId, true)
val copy = repo.continueLatestForPatient("patient-1")!!
assertEquals(newer.id, copy.continuedFromDocumentationId)
assertEquals("patient-1", copy.patientId)
assertEquals(1, copy.blocks.count { it.isEdited })
}
}
@@ -0,0 +1,545 @@
package de.medapp.wunddoku.data.repository
import android.net.Uri
import androidx.test.ext.junit.runners.AndroidJUnit4
import de.medapp.wunddoku.domain.catalog.DocumentationCatalog
import de.medapp.wunddoku.domain.model.DocumentationStatus
import de.medapp.wunddoku.domain.model.InputSpecCodec
import de.medapp.wunddoku.pdf.PdfExportError
import de.medapp.wunddoku.pdf.PdfExportResult
import de.medapp.wunddoku.pdf.PdfToImagesResult
import de.medapp.wunddoku.testutil.TestEnvironment
import de.medapp.wunddoku.testutil.blankBitmap
import de.medapp.wunddoku.testutil.expectThrows
import de.medapp.wunddoku.testutil.firstPlainItem
import de.medapp.wunddoku.testutil.runDbTest
import de.medapp.wunddoku.testutil.signatureBitmap
import kotlinx.coroutines.flow.first
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import java.io.File
import java.time.LocalDate
import java.time.ZoneId
/**
* Repository-Tests für [DocumentationRepository] gegen eine echte In-Memory-Room-Datenbank.
* Dateispeicher und PDF-Erzeugung sind durch Fakes ersetzt (s. testutil/Fakes.kt).
* "Fortsetzen" wird separat in [ContinueDocumentationTest] getestet.
*/
@RunWith(AndroidJUnit4::class)
class DocumentationRepositoryTest {
private lateinit var env: TestEnvironment
private val repo get() = env.documentationRepository
@Before
fun setUp() {
env = TestEnvironment()
}
@After
fun tearDown() {
env.close()
}
// --- createEmpty ---------------------------------------------------------------------
@Test
fun createEmpty_legt_neue_dokumentation_im_status_in_bearbeitung_an() = runDbTest {
env.insertPatient()
val doc = repo.createEmpty("patient-1")
assertEquals("patient-1", doc.patientId)
assertEquals(DocumentationStatus.IN_PROGRESS, doc.status)
assertNull(doc.continuedFromDocumentationId)
assertEquals(DocumentationCatalog.CATALOG_VERSION, doc.catalogVersion)
assertEquals(doc.createdAt, doc.updatedAt)
assertTrue(doc.photos.isEmpty())
assertTrue(doc.signatures.isEmpty())
assertEquals(doc.id, repo.getFullDocumentation(doc.id)?.id)
}
@Test
fun createEmpty_uebernimmt_den_patienten_snapshot() = runDbTest {
env.insertPatient(
firstName = "Erika", lastName = "Musterfrau", birthDate = "1975-12-01",
street = "Hauptstraße 5", postalCode = "54321", city = "Beispielstadt",
)
val snapshot = repo.createEmpty("patient-1").patientSnapshot
assertEquals("Erika", snapshot.firstName)
assertEquals("Musterfrau", snapshot.lastName)
assertEquals(LocalDate.of(1975, 12, 1), snapshot.birthDate)
assertEquals("Hauptstraße 5", snapshot.street)
assertEquals("54321", snapshot.postalCode)
assertEquals("Beispielstadt", snapshot.city)
}
@Test
fun createEmpty_erzeugt_pro_katalogblock_einen_block_mit_titel_und_reihenfolge_snapshot() = runDbTest {
env.insertPatient()
val doc = repo.createEmpty("patient-1")
assertEquals(DocumentationCatalog.blocks.size, doc.blocks.size)
for (def in DocumentationCatalog.blocks) {
val block = doc.blocks.single { it.definitionId == def.id }
assertEquals(def.title, block.title)
assertEquals(def.order, block.order)
}
}
@Test
fun createEmpty_haengt_pro_block_die_generische_freitext_option_als_letztes_element_an() = runDbTest {
env.insertPatient()
val doc = repo.createEmpty("patient-1")
for (def in DocumentationCatalog.blocks) {
val block = doc.blocks.single { it.definitionId == def.id }
assertEquals(def.items.size + 1, block.items.size)
val generic = block.items.last()
assertEquals(def.freeTextDefinitionId, generic.definitionId)
assertEquals(DocumentationCatalog.FREE_TEXT_LABEL, generic.label)
assertEquals((def.items.maxOfOrNull { it.order } ?: 0) + 1, generic.order)
assertTrue(generic.isFreeText)
assertFalse(generic.isStaticHint)
assertFalse(generic.checked)
assertEquals("", generic.freeTextValue)
}
}
@Test
fun createEmpty_uebernimmt_katalogeintraege_1_zu_1_und_hinweistexte_starten_angehakt() = runDbTest {
env.insertPatient()
val doc = repo.createEmpty("patient-1")
for (def in DocumentationCatalog.blocks) {
val block = doc.blocks.single { it.definitionId == def.id }
for (itemDef in def.items) {
val state = block.items.single { it.definitionId == itemDef.id }
assertEquals(itemDef.label, state.label)
assertEquals(itemDef.order, state.order)
assertEquals(itemDef.isFreeText, state.isFreeText)
assertEquals(itemDef.isStaticHint, state.isStaticHint)
// Nur Hinweistexte starten angehakt, alles andere unangehakt/leer.
assertEquals(itemDef.isStaticHint, state.checked)
assertEquals("", state.freeTextValue)
val expectedSpec = itemDef.inputSpec?.let(InputSpecCodec::encode) ?: ""
assertEquals(expectedSpec, state.inputSpec)
}
}
}
@Test
fun createEmpty_ergibt_keinen_bearbeiteten_block_auch_nicht_durch_hinweistexte() = runDbTest {
env.insertPatient()
val doc = repo.createEmpty("patient-1")
assertTrue(doc.blocks.none { it.isEdited })
}
@Test
fun createEmpty_vergibt_fuer_jede_dokumentation_neue_ids() = runDbTest {
env.insertPatient()
val first = repo.createEmpty("patient-1")
val second = repo.createEmpty("patient-1")
assertTrue(first.id != second.id)
val allBlockIds = (first.blocks + second.blocks).map { it.blockStateId }
assertEquals(allBlockIds.size, allBlockIds.toSet().size)
assertEquals(2, env.db.documentationDao().countForPatient("patient-1"))
}
@Test
fun createEmpty_mit_unbekanntem_patienten_wirft_und_legt_nichts_an() = runDbTest {
val error = expectThrows<IllegalStateException> { repo.createEmpty("gibt-es-nicht") }
assertTrue(error.message.orEmpty().contains("gibt-es-nicht"))
assertTrue(env.db.documentationDao().observeAll().first().isEmpty())
}
// --- Lesen / Liste / Suche -----------------------------------------------------------
@Test
fun getFullDocumentation_liefert_null_fuer_unbekannte_id() = runDbTest {
assertNull(repo.getFullDocumentation("unbekannt"))
}
@Test
fun observeAll_liefert_neueste_dokumentation_zuerst_mit_snapshot_namen() = runDbTest {
env.insertPatient()
val older = repo.createEmpty("patient-1")
val newer = repo.createEmpty("patient-1")
env.setCreatedAt(older.id, 1_000L)
env.setCreatedAt(newer.id, 2_000L)
val list = repo.observeAll().first()
assertEquals(listOf(newer.id, older.id), list.map { it.id })
assertEquals("Max Mustermann", list.first().patientFullName)
assertEquals(DocumentationStatus.IN_PROGRESS, list.first().status)
}
@Test
fun search_sucht_gegen_den_aktuellen_patientennamen_zeigt_aber_den_snapshot_namen() = runDbTest {
env.insertPatient()
val doc = repo.createEmpty("patient-1")
val patientDao = env.db.patientDao()
patientDao.update(patientDao.getById("patient-1")!!.copy(lastName = "Musterfrau"))
val found = repo.search("Musterfrau").first()
assertEquals(listOf(doc.id), found.map { it.id })
assertEquals("Max Mustermann", found.single().patientFullName)
assertTrue(repo.search("Mustermann").first().isEmpty())
}
@Test
fun search_findet_auch_ueber_vornamen_und_geburtsdatum() = runDbTest {
env.insertPatient()
val doc = repo.createEmpty("patient-1")
assertEquals(listOf(doc.id), repo.search("Ma").first().map { it.id })
assertEquals(listOf(doc.id), repo.search("1980-03").first().map { it.id })
assertTrue(repo.search("Zzz").first().isEmpty())
}
// --- Autosave / Blockstatus ----------------------------------------------------------
@Test
fun setChecked_persistiert_und_macht_den_block_bearbeitet() = runDbTest {
env.insertPatient()
val doc = repo.createEmpty("patient-1")
val block = doc.blocks.first()
val item = block.firstPlainItem()
repo.setChecked(item.checkItemStateId, true)
val reloaded = repo.getFullDocumentation(doc.id)!!
val reloadedBlock = reloaded.blocks.single { it.blockStateId == block.blockStateId }
assertTrue(reloadedBlock.items.single { it.checkItemStateId == item.checkItemStateId }.checked)
assertTrue(reloadedBlock.isEdited)
assertEquals(1, reloaded.blocks.count { it.isEdited })
}
@Test
fun setChecked_false_nimmt_den_bearbeitet_status_wieder_zurueck() = runDbTest {
env.insertPatient()
val doc = repo.createEmpty("patient-1")
val block = doc.blocks.first()
val item = block.firstPlainItem()
repo.setChecked(item.checkItemStateId, true)
repo.setChecked(item.checkItemStateId, false)
val reloaded = repo.getFullDocumentation(doc.id)!!
assertTrue(reloaded.blocks.none { it.isEdited })
}
@Test
fun freitext_wird_gespeichert_und_zaehlt_erst_mit_haken_als_bearbeitung() = runDbTest {
env.insertPatient()
val doc = repo.createEmpty("patient-1")
val block = doc.blocks.first()
val freeText = block.items.last()
val text = "Größe 3,5 × 2 cm\nzweite Zeile – Rötung"
repo.updateFreeText(freeText.checkItemStateId, text)
val textOnly = repo.getFullDocumentation(doc.id)!!
assertEquals(text, textOnly.blocks.single { it.blockStateId == block.blockStateId }.items.last().freeTextValue)
assertTrue("Freitext ohne Haken darf den Block nicht bearbeitet machen", textOnly.blocks.none { it.isEdited })
repo.setChecked(freeText.checkItemStateId, true)
val checked = repo.getFullDocumentation(doc.id)!!
val reloadedBlock = checked.blocks.single { it.blockStateId == block.blockStateId }
assertTrue(reloadedBlock.items.last().checked)
assertEquals(text, reloadedBlock.items.last().freeTextValue)
assertTrue(reloadedBlock.isEdited)
}
@Test
fun aenderungen_in_einer_dokumentation_beruehren_eine_andere_nicht() = runDbTest {
env.insertPatient()
val first = repo.createEmpty("patient-1")
val second = repo.createEmpty("patient-1")
repo.setChecked(first.blocks.first().firstPlainItem().checkItemStateId, true)
assertTrue(repo.getFullDocumentation(second.id)!!.blocks.none { it.isEdited })
}
// --- Fotos ---------------------------------------------------------------------------
@Test
fun addPhoto_vergibt_fortlaufenden_orderIndex_und_speichert_den_storage_dateinamen() = runDbTest {
env.insertPatient()
val doc = repo.createEmpty("patient-1")
val uri1 = Uri.parse("content://test/1")
val uri2 = Uri.parse("content://test/2")
val p1 = repo.addPhoto(doc.id, uri1)
val p2 = repo.addPhoto(doc.id, uri2)
assertEquals(0, p1.orderIndex)
assertEquals(1, p2.orderIndex)
assertEquals(listOf(doc.id to uri1, doc.id to uri2), env.photoStorage.imported)
val photos = repo.getFullDocumentation(doc.id)!!.photos
assertEquals(listOf(p1.fileName, p2.fileName), photos.map { it.fileName })
assertEquals(listOf(0, 1), photos.map { it.orderIndex })
}
@Test
fun removePhoto_loescht_db_zeile_und_datei_und_vergibt_orderIndex_nicht_neu() = runDbTest {
env.insertPatient()
val doc = repo.createEmpty("patient-1")
val p1 = repo.addPhoto(doc.id, Uri.parse("content://test/1"))
val p2 = repo.addPhoto(doc.id, Uri.parse("content://test/2"))
repo.removePhoto(p1.id)
assertEquals(listOf(p2.id), repo.getFullDocumentation(doc.id)!!.photos.map { it.id })
assertEquals(listOf(doc.id to p1.fileName), env.photoStorage.deleted)
val p3 = repo.addPhoto(doc.id, Uri.parse("content://test/3"))
assertEquals(2, p3.orderIndex)
}
@Test
fun removePhoto_mit_unbekannter_id_tut_nichts() = runDbTest {
env.insertPatient()
repo.createEmpty("patient-1")
repo.removePhoto("unbekannt")
assertTrue(env.photoStorage.deleted.isEmpty())
}
@Test
fun removePhoto_entfernt_die_db_zeile_auch_wenn_die_datei_nicht_geloescht_werden_kann() = runDbTest {
env.insertPatient()
val doc = repo.createEmpty("patient-1")
val photo = repo.addPhoto(doc.id, Uri.parse("content://test/1"))
env.photoStorage.failOnDelete = true
repo.removePhoto(photo.id)
assertTrue(repo.getFullDocumentation(doc.id)!!.photos.isEmpty())
}
// --- Signaturen ----------------------------------------------------------------------
@Test
fun saveSignature_trimmt_den_namen_und_vergibt_fortlaufenden_orderIndex() = runDbTest {
env.insertPatient()
val doc = repo.createEmpty("patient-1")
val first = repo.saveSignature(doc.id, " Anna Schmidt ", signatureBitmap())
val second = repo.saveSignature(doc.id, "Bernd Beispiel", signatureBitmap())
assertEquals("Anna Schmidt", first.signerName)
assertEquals(0, first.orderIndex)
assertEquals(1, second.orderIndex)
val signatures = repo.getFullDocumentation(doc.id)!!.signatures
assertEquals(listOf("Anna Schmidt", "Bernd Beispiel"), signatures.map { it.signerName })
assertEquals(listOf(first.fileName, second.fileName), signatures.map { it.fileName })
assertEquals(listOf(doc.id to first.fileName, doc.id to second.fileName), env.signatureStorage.saved)
}
@Test
fun saveSignature_aktualisiert_das_wundversorger_register() = runDbTest {
env.insertPatient()
val doc = repo.createEmpty("patient-1")
repo.saveSignature(doc.id, "Anna Schmidt", signatureBitmap())
assertEquals("Anna Schmidt", env.woundCarerRepository.getMostRecentlyUsed()?.name)
assertEquals(1, env.woundCarerSignatureStorage.saved.size)
}
@Test
fun saveSignature_mit_leerem_namen_wirft_und_hinterlaesst_keine_datei() = runDbTest {
env.insertPatient()
val doc = repo.createEmpty("patient-1")
expectThrows<IllegalArgumentException> { repo.saveSignature(doc.id, " ", signatureBitmap()) }
assertTrue(env.signatureStorage.saved.isEmpty())
assertTrue(env.db.signatureDao().getForDocumentation(doc.id).isEmpty())
assertNull(env.woundCarerRepository.getMostRecentlyUsed())
}
@Test
fun saveSignature_mit_leerer_zeichnung_wirft_und_hinterlaesst_keine_datei() = runDbTest {
env.insertPatient()
val doc = repo.createEmpty("patient-1")
expectThrows<IllegalArgumentException> { repo.saveSignature(doc.id, "Anna Schmidt", blankBitmap()) }
assertTrue(env.signatureStorage.saved.isEmpty())
assertTrue(env.db.signatureDao().getForDocumentation(doc.id).isEmpty())
assertNull(env.woundCarerRepository.getMostRecentlyUsed())
}
@Test
fun removeSignature_loescht_db_zeile_und_datei() = runDbTest {
env.insertPatient()
val doc = repo.createEmpty("patient-1")
val signature = repo.saveSignature(doc.id, "Anna Schmidt", signatureBitmap())
repo.removeSignature(signature.id)
assertTrue(repo.getFullDocumentation(doc.id)!!.signatures.isEmpty())
assertEquals(listOf(doc.id to signature.fileName), env.signatureStorage.deleted)
}
@Test
fun removeSignature_mit_unbekannter_id_tut_nichts_und_toleriert_storage_fehler() = runDbTest {
env.insertPatient()
val doc = repo.createEmpty("patient-1")
repo.removeSignature("unbekannt")
assertTrue(env.signatureStorage.deleted.isEmpty())
val signature = repo.saveSignature(doc.id, "Anna Schmidt", signatureBitmap())
env.signatureStorage.failOnDelete = true
repo.removeSignature(signature.id)
assertTrue(repo.getFullDocumentation(doc.id)!!.signatures.isEmpty())
}
// --- Löschen -------------------------------------------------------------------------
@Test
fun delete_entfernt_dokumentation_samt_bloecken_fotos_und_signaturen_sowie_dateiordner() = runDbTest {
env.insertPatient()
val doc = repo.createEmpty("patient-1")
repo.addPhoto(doc.id, Uri.parse("content://test/1"))
repo.saveSignature(doc.id, "Anna Schmidt", signatureBitmap())
val blockStateIds = doc.blocks.map { it.blockStateId }
repo.delete(doc.id)
assertNull(repo.getFullDocumentation(doc.id))
assertTrue(env.db.blockStateDao().getForDocumentation(doc.id).isEmpty())
assertTrue(env.db.photoDao().getForDocumentation(doc.id).isEmpty())
assertTrue(env.db.signatureDao().getForDocumentation(doc.id).isEmpty())
for (blockStateId in blockStateIds) {
assertTrue(env.db.checkItemStateDao().getForBlockState(blockStateId).isEmpty())
}
assertEquals(listOf(doc.id), env.photoStorage.deletedAll)
assertEquals(listOf(doc.id), env.signatureStorage.deletedAll)
assertNotNull("Der Patient darf nicht mitgelöscht werden", env.patientRepository.getById("patient-1"))
}
@Test
fun delete_beruehrt_das_wundversorger_register_nicht() = runDbTest {
env.insertPatient()
val doc = repo.createEmpty("patient-1")
repo.saveSignature(doc.id, "Anna Schmidt", signatureBitmap())
repo.delete(doc.id)
assertEquals("Anna Schmidt", env.woundCarerRepository.getMostRecentlyUsed()?.name)
assertTrue(env.woundCarerSignatureStorage.deletedAll.isEmpty())
assertTrue(env.woundCarerSignatureStorage.deleted.isEmpty())
}
@Test
fun delete_mit_unbekannter_id_tut_nichts() = runDbTest {
repo.delete("unbekannt")
assertTrue(env.photoStorage.deletedAll.isEmpty())
assertTrue(env.signatureStorage.deletedAll.isEmpty())
}
@Test
fun delete_entfernt_die_dokumentation_auch_wenn_dateiordner_nicht_geloescht_werden_koennen() = runDbTest {
env.insertPatient()
val doc = repo.createEmpty("patient-1")
env.photoStorage.failOnDeleteAll = true
env.signatureStorage.failOnDeleteAll = true
repo.delete(doc.id)
assertNull(repo.getFullDocumentation(doc.id))
}
// --- PDF-Export ----------------------------------------------------------------------
@Test
fun exportPdf_fuer_unbekannte_dokumentation_liefert_fachlichen_fehler_ohne_exporter_aufruf() = runDbTest {
val result = repo.exportPdf("unbekannt")
assertEquals(PdfExportResult.Failure(PdfExportError.DocumentationNotFound), result)
assertTrue(env.pdfExporter.models.isEmpty())
}
@Test
fun exportPdf_uebergibt_nur_bearbeitete_bloecke_und_den_schemakonformen_dateinamen() = runDbTest {
env.insertPatient()
val doc = repo.createEmpty("patient-1")
val block = doc.blocks.first()
val item = block.firstPlainItem()
repo.setChecked(item.checkItemStateId, true)
val result = repo.exportPdf(doc.id)
assertTrue(result is PdfExportResult.Success)
val model = env.pdfExporter.models.single()
assertEquals(doc.id, model.documentationId)
assertEquals(listOf(block.title), model.blocks.map { it.title })
assertTrue(model.blocks.single().items.any { it.label == item.label })
val createdDate = doc.createdAt.atZone(ZoneId.systemDefault()).toLocalDate()
assertEquals("${createdDate}_Max_Mustermann_1980-03-14.pdf", model.fileName)
}
@Test
fun exportPdf_verwendet_den_snapshot_und_nicht_den_aktuellen_patientendatensatz() = runDbTest {
env.insertPatient()
val doc = repo.createEmpty("patient-1")
val patientDao = env.db.patientDao()
patientDao.update(patientDao.getById("patient-1")!!.copy(lastName = "Musterfrau", city = "Neustadt"))
repo.exportPdf(doc.id)
val model = env.pdfExporter.models.single()
assertEquals("Max Mustermann", model.patientFullName)
assertEquals("Musterstraße 1, 12345 Musterstadt", model.patientDisplayAddress)
}
@Test
fun exportPdf_reicht_das_exporter_ergebnis_unveraendert_durch() = runDbTest {
env.insertPatient()
val doc = repo.createEmpty("patient-1")
val failure = PdfExportResult.Failure(PdfExportError.WriteFailed("Speicher voll"))
env.pdfExporter.result = { failure }
assertEquals(failure, repo.exportPdf(doc.id))
}
@Test
fun convertPdfToImages_delegiert_an_den_converter() = runDbTest {
val pdf = File("/fake/pdfs/test.pdf")
env.pdfToImagesConverter.result = PdfToImagesResult.Failure.FileNotFound
val result = repo.convertPdfToImages("doc-1", pdf)
assertEquals(PdfToImagesResult.Failure.FileNotFound, result)
assertEquals(listOf("doc-1" to pdf), env.pdfToImagesConverter.calls)
}
}
@@ -0,0 +1,223 @@
package de.medapp.wunddoku.data.repository
import android.database.sqlite.SQLiteConstraintException
import androidx.test.ext.junit.runners.AndroidJUnit4
import de.medapp.wunddoku.domain.model.Patient
import de.medapp.wunddoku.testutil.TestEnvironment
import de.medapp.wunddoku.testutil.expectThrows
import de.medapp.wunddoku.testutil.runDbTest
import kotlinx.coroutines.flow.first
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import java.time.Instant
import java.time.LocalDate
import java.util.UUID
/** Repository-Tests für [PatientRepository] gegen eine echte In-Memory-Room-Datenbank. */
@RunWith(AndroidJUnit4::class)
class PatientRepositoryTest {
private lateinit var env: TestEnvironment
private val repo get() = env.patientRepository
@Before
fun setUp() {
env = TestEnvironment()
}
@After
fun tearDown() {
env.close()
}
private fun patient(
id: String = "p-1",
firstName: String = "Max",
lastName: String = "Mustermann",
birthDate: LocalDate = LocalDate.of(1980, 3, 14),
) = Patient(
id = id,
firstName = firstName,
lastName = lastName,
birthDate = birthDate,
street = "Musterstraße 1",
postalCode = "12345",
city = "Musterstadt",
phone1 = "0123 456789",
phone2 = "0987 654321",
phone3 = null,
createdAt = Instant.EPOCH,
updatedAt = Instant.EPOCH,
)
// --- upsert / getById ----------------------------------------------------------------
@Test
fun upsert_legt_neuen_patienten_an_und_setzt_zeitstempel_zentral() = runDbTest {
val before = Instant.now().toEpochMilli()
val saved = repo.upsert(patient())
assertEquals(saved.createdAt, saved.updatedAt)
assertTrue(saved.createdAt.toEpochMilli() >= before)
val loaded = repo.getById("p-1")!!
assertEquals("Max", loaded.firstName)
assertEquals("Mustermann", loaded.lastName)
assertEquals(LocalDate.of(1980, 3, 14), loaded.birthDate)
assertEquals("Musterstraße 1", loaded.street)
assertEquals("12345", loaded.postalCode)
assertEquals("Musterstadt", loaded.city)
assertEquals("0123 456789", loaded.phone1)
assertEquals("0987 654321", loaded.phone2)
assertNull(loaded.phone3)
// Die Datenbank speichert Millisekunden, upsert() liefert Instant.now() mit höherer Genauigkeit.
assertEquals(saved.createdAt.toEpochMilli(), loaded.createdAt.toEpochMilli())
}
@Test
fun upsert_aktualisiert_bestehenden_patienten_und_behaelt_createdAt() = runDbTest {
val first = repo.upsert(patient())
// createdAt im übergebenen Objekt wird bewusst ignoriert (Instant.EPOCH aus dem Helper).
val second = repo.upsert(patient(lastName = "Musterfrau"))
assertEquals(first.createdAt.toEpochMilli(), second.createdAt.toEpochMilli())
assertTrue(second.updatedAt.toEpochMilli() >= first.updatedAt.toEpochMilli())
val loaded = repo.getById("p-1")!!
assertEquals("Musterfrau", loaded.lastName)
assertEquals(first.createdAt.toEpochMilli(), loaded.createdAt.toEpochMilli())
}
@Test
fun upsert_erzeugt_bei_unterschiedlichen_ids_getrennte_datensaetze() = runDbTest {
repo.upsert(patient(id = "p-1"))
repo.upsert(patient(id = "p-2", firstName = "Erika", lastName = "Musterfrau"))
assertEquals("Max", repo.getById("p-1")!!.firstName)
assertEquals("Erika", repo.getById("p-2")!!.firstName)
}
@Test
fun getById_liefert_null_fuer_unbekannte_id() = runDbTest {
assertNull(repo.getById("unbekannt"))
}
@Test
fun newId_liefert_gueltige_und_eindeutige_uuids() {
val a = repo.newId()
val b = repo.newId()
assertEquals(a, UUID.fromString(a).toString())
assertTrue(a != b)
}
// --- findExisting --------------------------------------------------------------------
@Test
fun findExisting_trifft_nur_bei_gleichem_namen_und_geburtsdatum() = runDbTest {
repo.upsert(patient())
assertEquals("p-1", repo.findExisting("Max", "Mustermann", LocalDate.of(1980, 3, 14))?.id)
assertNull(repo.findExisting("Max", "Mustermann", LocalDate.of(1980, 3, 15)))
assertNull(repo.findExisting("Moritz", "Mustermann", LocalDate.of(1980, 3, 14)))
assertNull(repo.findExisting("Max", "Musterfrau", LocalDate.of(1980, 3, 14)))
}
// --- Dokumentationen / Löschen -------------------------------------------------------
@Test
fun hasDocumentations_ist_erst_nach_dem_anlegen_einer_dokumentation_true() = runDbTest {
repo.upsert(patient())
assertFalse(repo.hasDocumentations("p-1"))
env.documentationRepository.createEmpty("p-1")
assertTrue(repo.hasDocumentations("p-1"))
}
@Test
fun delete_entfernt_patienten_ohne_dokumentation() = runDbTest {
repo.upsert(patient())
repo.delete("p-1")
assertNull(repo.getById("p-1"))
}
@Test
fun delete_wird_bei_vorhandener_dokumentation_von_der_datenbank_verhindert() = runDbTest {
repo.upsert(patient())
val doc = env.documentationRepository.createEmpty("p-1")
expectThrows<SQLiteConstraintException> { repo.delete("p-1") }
assertNotNull(repo.getById("p-1"))
assertNotNull(env.documentationRepository.getFullDocumentation(doc.id))
}
@Test
fun delete_mit_unbekannter_id_tut_nichts() = runDbTest {
repo.upsert(patient())
repo.delete("unbekannt")
assertNotNull(repo.getById("p-1"))
}
// --- Liste / Suche -------------------------------------------------------------------
@Test
fun observeAll_sortiert_nach_nachname_dann_vorname() = runDbTest {
repo.upsert(patient(id = "p-1", firstName = "Max", lastName = "Müller"))
repo.upsert(patient(id = "p-2", firstName = "Anna", lastName = "Müller"))
repo.upsert(patient(id = "p-3", firstName = "Zoe", lastName = "Adler"))
val list = repo.observeAll().first()
assertEquals(listOf("p-3", "p-2", "p-1"), list.map { it.id })
assertEquals("Zoe Adler", list.first().fullName)
}
@Test
fun observeAll_zeigt_datum_der_letzten_dokumentation_oder_null() = runDbTest {
repo.upsert(patient(id = "p-1"))
repo.upsert(patient(id = "p-2", firstName = "Erika", lastName = "Musterfrau"))
val older = env.documentationRepository.createEmpty("p-1")
val newer = env.documentationRepository.createEmpty("p-1")
env.setCreatedAt(older.id, 1_000L)
env.setCreatedAt(newer.id, 2_000L)
val byId = repo.observeAll().first().associateBy { it.id }
assertEquals(Instant.ofEpochMilli(2_000L), byId.getValue("p-1").lastDocumentationAt)
assertNull(byId.getValue("p-2").lastDocumentationAt)
}
@Test
fun search_findet_ueber_nachname_vorname_und_geburtsdatum() = runDbTest {
repo.upsert(patient(id = "p-1", firstName = "Max", lastName = "Mustermann"))
repo.upsert(patient(id = "p-2", firstName = "Erika", lastName = "Musterfrau", birthDate = LocalDate.of(1975, 12, 1)))
// Beide Nachnamen beginnen mit "Muster": sortiert nach Nachname -> Musterfrau vor Mustermann.
assertEquals(listOf("p-2", "p-1"), repo.search("Muster").first().map { it.id })
assertEquals(listOf("p-1"), repo.search("Max").first().map { it.id })
assertEquals(listOf("p-2"), repo.search("Erika").first().map { it.id })
assertEquals(listOf("p-2"), repo.search("1975-12").first().map { it.id })
assertTrue(repo.search("Zzz").first().isEmpty())
}
@Test
fun search_ist_unabhaengig_von_gross_und_kleinschreibung() = runDbTest {
repo.upsert(patient())
assertEquals(listOf("p-1"), repo.search("mustermann").first().map { it.id })
assertEquals(listOf("p-1"), repo.search("MAX").first().map { it.id })
}
}
@@ -0,0 +1,156 @@
package de.medapp.wunddoku.data.repository
import androidx.test.ext.junit.runners.AndroidJUnit4
import de.medapp.wunddoku.testutil.TestEnvironment
import de.medapp.wunddoku.testutil.runDbTest
import de.medapp.wunddoku.testutil.signatureBitmap
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
/** Repository-Tests für das Wundversorger-Register ([WoundCarerRepository]). */
@RunWith(AndroidJUnit4::class)
class WoundCarerRepositoryTest {
private lateinit var env: TestEnvironment
private val repo get() = env.woundCarerRepository
private val storage get() = env.woundCarerSignatureStorage
@Before
fun setUp() {
env = TestEnvironment()
}
@After
fun tearDown() {
env.close()
}
/** updatedAt basiert auf System.currentTimeMillis() - kurze Pause für eindeutige Reihenfolge. */
private fun tick() = Thread.sleep(5)
@Test
fun getMostRecentlyUsed_ist_null_solange_nie_eine_signatur_gespeichert_wurde() = runDbTest {
assertNull(repo.getMostRecentlyUsed())
}
@Test
fun recordSignatureUsage_legt_neuen_eintrag_mit_eigener_signaturdatei_an() = runDbTest {
repo.recordSignatureUsage("Anna Schmidt", signatureBitmap())
val carer = repo.getMostRecentlyUsed()!!
assertEquals("Anna Schmidt", carer.name)
assertEquals(1, storage.saved.size)
assertEquals(carer.id to carer.signatureFileName, storage.saved.single())
}
@Test
fun recordSignatureUsage_aktualisiert_bekannten_namen_case_insensitiv_und_ersetzt_die_signatur() = runDbTest {
repo.recordSignatureUsage("Anna Schmidt", signatureBitmap())
val first = repo.getMostRecentlyUsed()!!
tick()
repo.recordSignatureUsage("anna schmidt", signatureBitmap())
val second = repo.getMostRecentlyUsed()!!
assertEquals(first.id, second.id)
assertEquals("anna schmidt", second.name) // zuletzt getippte Schreibweise
assertNotEquals(first.signatureFileName, second.signatureFileName)
assertTrue(second.updatedAt >= first.updatedAt)
assertEquals(1, repo.search("a").size)
// Alte Signaturdatei wurde entfernt, genau eine Datei bleibt zugeordnet.
assertEquals(listOf(first.id to first.signatureFileName), storage.deleted)
}
@Test
fun recordSignatureUsage_ignoriert_fehler_beim_loeschen_der_alten_signaturdatei() = runDbTest {
repo.recordSignatureUsage("Anna Schmidt", signatureBitmap())
storage.failOnDelete = true
tick()
repo.recordSignatureUsage("Anna Schmidt", signatureBitmap())
assertEquals(1, repo.search("Anna").size)
}
@Test
fun getMostRecentlyUsed_liefert_den_zuletzt_aktualisierten_wundversorger() = runDbTest {
repo.recordSignatureUsage("Anna Schmidt", signatureBitmap())
tick()
repo.recordSignatureUsage("Bernd Beispiel", signatureBitmap())
assertEquals("Bernd Beispiel", repo.getMostRecentlyUsed()?.name)
tick()
repo.recordSignatureUsage("Anna Schmidt", signatureBitmap())
assertEquals("Anna Schmidt", repo.getMostRecentlyUsed()?.name)
}
// --- search --------------------------------------------------------------------------
@Test
fun search_liefert_praefix_treffer_case_insensitiv_neueste_zuerst() = runDbTest {
repo.recordSignatureUsage("Anna Schmidt", signatureBitmap())
tick()
repo.recordSignatureUsage("Andreas Müller", signatureBitmap())
tick()
repo.recordSignatureUsage("Bernd Beispiel", signatureBitmap())
val result = repo.search("an")
assertEquals(listOf("Andreas Müller", "Anna Schmidt"), result.map { it.name })
}
@Test
fun search_trimmt_die_eingabe_und_liefert_bei_leerer_eingabe_nichts() = runDbTest {
repo.recordSignatureUsage("Anna Schmidt", signatureBitmap())
assertEquals(listOf("Anna Schmidt"), repo.search(" Ann ").map { it.name })
assertTrue(repo.search("").isEmpty())
assertTrue(repo.search(" ").isEmpty())
}
@Test
fun search_ist_ein_praefix_und_kein_teilstring_abgleich() = runDbTest {
repo.recordSignatureUsage("Anna Schmidt", signatureBitmap())
assertTrue(repo.search("Schmidt").isEmpty())
}
@Test
fun search_beachtet_das_limit() = runDbTest {
for (name in listOf("Anna A", "Anna B", "Anna C", "Anna D")) {
repo.recordSignatureUsage(name, signatureBitmap())
tick()
}
assertEquals(2, repo.search("Anna", limit = 2).size)
assertEquals(4, repo.search("Anna").size) // Standardlimit 5 deckt alle vier ab
assertEquals(listOf("Anna D", "Anna C"), repo.search("Anna", limit = 2).map { it.name })
}
// --- loadSignatureBitmap -------------------------------------------------------------
@Test
fun loadSignatureBitmap_delegiert_an_den_wundversorger_speicher() = runDbTest {
val bitmap = signatureBitmap()
storage.decodeResult = bitmap
val result = repo.loadSignatureBitmap("carer-1", "sig.png")
assertEquals(bitmap, result)
assertEquals(listOf("carer-1" to "sig.png"), storage.decodeCalls)
}
@Test
fun loadSignatureBitmap_liefert_null_wenn_die_datei_fehlt() = runDbTest {
storage.decodeResult = null
assertNull(repo.loadSignatureBitmap("carer-1", "fehlt.png"))
}
}
@@ -0,0 +1,154 @@
package de.medapp.wunddoku.testutil
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.data.entity.PatientEntity
import de.medapp.wunddoku.data.entity.PhotoEntity
import de.medapp.wunddoku.data.entity.SignatureEntity
import de.medapp.wunddoku.data.entity.WoundCarerEntity
import de.medapp.wunddoku.domain.model.DocumentationStatus
/** Builder für Room-Entities mit sinnvollen Standardwerten - Tests überschreiben nur, was sie brauchen. */
fun patientEntity(
id: String = "p-1",
firstName: String = "Max",
lastName: String = "Mustermann",
birthDate: String = "1980-03-14",
street: String = "Musterstraße 1",
postalCode: String = "12345",
city: String = "Musterstadt",
phone1: String = "0123 456789",
phone2: String? = null,
phone3: String? = null,
createdAt: Long = 1_000L,
updatedAt: Long = createdAt,
) = PatientEntity(
id = id,
firstName = firstName,
lastName = lastName,
birthDate = birthDate,
street = street,
postalCode = postalCode,
city = city,
phone1 = phone1,
phone2 = phone2,
phone3 = phone3,
createdAt = createdAt,
updatedAt = updatedAt,
)
fun documentationEntity(
id: String = "d-1",
patientId: String = "p-1",
status: DocumentationStatus = DocumentationStatus.IN_PROGRESS,
createdAt: Long = 1_000L,
updatedAt: Long = createdAt,
continuedFromDocumentationId: String? = null,
pdfLastGeneratedAt: Long? = null,
catalogVersion: Int = 4,
firstNameSnapshot: String = "Max",
lastNameSnapshot: String = "Mustermann",
birthDateSnapshot: String = "1980-03-14",
streetSnapshot: String = "Musterstraße 1",
postalCodeSnapshot: String = "12345",
citySnapshot: String = "Musterstadt",
) = DocumentationEntity(
id = id,
patientId = patientId,
status = status,
createdAt = createdAt,
updatedAt = updatedAt,
continuedFromDocumentationId = continuedFromDocumentationId,
pdfLastGeneratedAt = pdfLastGeneratedAt,
catalogVersion = catalogVersion,
patientFirstNameSnapshot = firstNameSnapshot,
patientLastNameSnapshot = lastNameSnapshot,
patientBirthDateSnapshot = birthDateSnapshot,
patientStreetSnapshot = streetSnapshot,
patientPostalCodeSnapshot = postalCodeSnapshot,
patientCitySnapshot = citySnapshot,
)
fun blockStateEntity(
id: String = "b-1",
documentationId: String = "d-1",
blockDefinitionId: String = "wundart",
title: String = "Wundart",
order: Int = 1,
updatedAt: Long = 1_000L,
) = BlockStateEntity(
id = id,
documentationId = documentationId,
blockDefinitionId = blockDefinitionId,
titleSnapshot = title,
blockOrder = order,
updatedAt = updatedAt,
)
fun checkItemStateEntity(
id: String = "c-1",
blockStateId: String = "b-1",
definitionId: String = "item_1",
label: String = "Eintrag 1",
order: Int = 1,
checked: Boolean = false,
isFreeText: Boolean = false,
freeTextValue: String = "",
isStaticHint: Boolean = false,
inputSpec: String = "",
) = CheckItemStateEntity(
id = id,
blockStateId = blockStateId,
checkItemDefinitionId = definitionId,
labelSnapshot = label,
itemOrder = order,
checked = checked,
isFreeText = isFreeText,
freeTextValue = freeTextValue,
isStaticHint = isStaticHint,
inputSpec = inputSpec,
)
fun photoEntity(
id: String = "ph-1",
documentationId: String = "d-1",
fileName: String = "foto.jpg",
orderIndex: Int = 0,
createdAt: Long = 1_000L,
) = PhotoEntity(
id = id,
documentationId = documentationId,
fileName = fileName,
orderIndex = orderIndex,
createdAt = createdAt,
)
fun signatureEntity(
id: String = "s-1",
documentationId: String = "d-1",
signerName: String = "Anna Schmidt",
fileName: String = "unterschrift.png",
orderIndex: Int = 0,
createdAt: Long = 1_000L,
) = SignatureEntity(
id = id,
documentationId = documentationId,
signerName = signerName,
fileName = fileName,
orderIndex = orderIndex,
createdAt = createdAt,
)
fun woundCarerEntity(
id: String = "w-1",
name: String = "Anna Schmidt",
signatureFileName: String = "w-sig.png",
updatedAt: Long = 1_000L,
) = WoundCarerEntity(
id = id,
name = name,
signatureFileName = signatureFileName,
updatedAt = updatedAt,
)
@@ -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,107 @@
package de.medapp.wunddoku.testutil
import androidx.room.Room
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import de.medapp.wunddoku.data.dao.BlockStateDao
import de.medapp.wunddoku.data.dao.CheckItemStateDao
import de.medapp.wunddoku.data.dao.DocumentationDao
import de.medapp.wunddoku.data.dao.PatientDao
import de.medapp.wunddoku.data.dao.PhotoDao
import de.medapp.wunddoku.data.dao.SignatureDao
import de.medapp.wunddoku.data.dao.WoundCarerDao
import de.medapp.wunddoku.data.database.AppDatabase
import de.medapp.wunddoku.data.entity.DocumentationEntity
import de.medapp.wunddoku.data.entity.PatientEntity
import de.medapp.wunddoku.domain.model.DocumentationStatus
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withTimeout
import org.junit.After
import org.junit.Before
import org.junit.runner.RunWith
/**
* Basisklasse für alle Room-Tests (QA-002): echte In-Memory-Datenbank mit demselben Schema,
* denselben Foreign Keys und denselben Indizes wie in Produktion. Jeder Test bekommt eine
* frische, leere Datenbank.
*/
@RunWith(AndroidJUnit4::class)
abstract class RoomTestBase {
protected lateinit var db: AppDatabase
protected val patientDao: PatientDao get() = db.patientDao()
protected val documentationDao: DocumentationDao get() = db.documentationDao()
protected val blockStateDao: BlockStateDao get() = db.blockStateDao()
protected val checkItemStateDao: CheckItemStateDao get() = db.checkItemStateDao()
protected val photoDao: PhotoDao get() = db.photoDao()
protected val signatureDao: SignatureDao get() = db.signatureDao()
protected val woundCarerDao: WoundCarerDao get() = db.woundCarerDao()
@Before
fun createDatabase() {
db = Room.inMemoryDatabaseBuilder(
InstrumentationRegistry.getInstrumentation().targetContext,
AppDatabase::class.java,
).build()
}
@After
fun closeDatabase() {
db.close()
}
// --- Seed-Helfer ---------------------------------------------------------------------
protected suspend fun seedPatient(
id: String = "p-1",
firstName: String = "Max",
lastName: String = "Mustermann",
birthDate: String = "1980-03-14",
): PatientEntity {
val entity = patientEntity(id = id, firstName = firstName, lastName = lastName, birthDate = birthDate)
patientDao.insert(entity)
return entity
}
/** Legt eine Dokumentation an; fehlt der Patient, wird er mit Standarddaten angelegt. */
protected suspend fun seedDocumentation(
id: String = "d-1",
patientId: String = "p-1",
createdAt: Long = 1_000L,
status: DocumentationStatus = DocumentationStatus.IN_PROGRESS,
): DocumentationEntity {
if (patientDao.getById(patientId) == null) seedPatient(id = patientId)
val entity = documentationEntity(id = id, patientId = patientId, createdAt = createdAt, status = status)
documentationDao.insert(entity)
return entity
}
// --- Roh-SQL-Helfer (unabhängig von DAO-Logik prüfen, was wirklich in der DB steht) ----
protected fun queryInt(sql: String): Int =
db.openHelper.readableDatabase.query(sql).use { cursor ->
cursor.moveToFirst()
cursor.getInt(0)
}
protected fun queryString(sql: String): String =
db.openHelper.readableDatabase.query(sql).use { cursor ->
cursor.moveToFirst()
cursor.getString(0)
}
protected fun countRows(table: String): Int = queryInt("SELECT COUNT(*) FROM $table")
protected fun execSql(sql: String) {
db.openHelper.writableDatabase.execSQL(sql)
}
}
/**
* Wartet (mit Timeout), bis ein Flow einen Wert liefert, der [predicate] erfüllt.
* Robust für Reaktivitäts-Tests, egal ob Änderung vor oder nach dem ersten Emit passiert.
*/
suspend fun <T> Flow<T>.awaitMatching(timeoutMs: Long = 5_000L, predicate: (T) -> Boolean): T =
withTimeout(timeoutMs) { first { predicate(it) } }
@@ -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.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
@@ -446,8 +447,28 @@ private fun DocumentationEditContent(
onDeleteSignatureRequested: (signatureId: String) -> Unit, onDeleteSignatureRequested: (signatureId: String) -> Unit,
loadSignatureBitmap: suspend (documentationId: String, fileName: String) -> Bitmap?, loadSignatureBitmap: suspend (documentationId: String, fileName: String) -> Bitmap?,
) { ) {
val listState = rememberLazyListState()
// Änderungswunsch: Beim Aufklappen eines Blocks dorthin scrollen, damit sein erster Eintrag
// sichtbar wird - wichtig bei langen Formularen, wenn ein weiter unten liegender Block
// aufgeklappt wird und sein Inhalt sonst unterhalb des sichtbaren Bereichs läge. Reagiert
// NUR auf tatsächliches Aufklappen (expandedBlockId wechselt zu einer konkreten Block-ID),
// nicht auf das Zuklappen (expandedBlockId wird null) - dort gibt es nichts, wohin gescrollt
// werden müsste. Da jeder Block GENAU EIN LazyColumn-Element ist (Kopfzeile + bei Bedarf
// aufgeklappter Inhalt gemeinsam, s. [BlockAccordionItem]) und die Block-Liste die ERSTEN
// Elemente der LazyColumn sind, landet der Block-Index 1:1 auf dem passenden
// LazyColumn-Index - der Block erscheint dadurch ganz oben im sichtbaren Bereich, sein
// erster Eintrag direkt darunter.
LaunchedEffect(expandedBlockId) {
val targetIndex = blocks.indexOfFirst { it.blockStateId == expandedBlockId }
if (targetIndex >= 0) {
listState.animateScrollToItem(targetIndex)
}
}
LazyColumn( LazyColumn(
modifier = modifier, modifier = modifier,
state = listState,
contentPadding = PaddingValues(16.dp), contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp),
) { ) {
@@ -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("???", "***"))
}
}