Compare commits
27 Commits
58f1e6fb82
...
beta1.5
| Author | SHA1 | Date | |
|---|---|---|---|
| e0b1d71c29 | |||
| dff64be964 | |||
| 7d48b4c838 | |||
| af8fa9bf9e | |||
| 97481a8c7f | |||
| fdbb5d9970 | |||
| 91a7552656 | |||
| 2ae71bcd2e | |||
| d8b405ef4e | |||
| 829118233b | |||
| c5fcf27367 | |||
| 09e7224c0a | |||
| 34cd20786b | |||
| bf9fcf7b0b | |||
| 4eaf1480a2 | |||
| 4a29d653bc | |||
| 77f71973a7 | |||
| 85600bfaed | |||
| 42e4b5c78c | |||
| b265f3fadb | |||
| e8ccab4f00 | |||
| 76bf0f16ec | |||
| 52a95e2b54 | |||
| 08f24aadd5 | |||
| 732c5b73c0 | |||
| f25596563c | |||
| b5ef793513 |
@@ -7,7 +7,7 @@ Die App ermöglicht die Verwaltung von Patienten und Wunddokumentationen,
|
||||
unterstützt Fotos und digitale Unterschriften und erzeugt aus
|
||||
abgeschlossenen Dokumentationen PDF-Dateien.
|
||||
|
||||
> **Projektstatus:** beta1.1
|
||||
> **Projektstatus:** beta1.4
|
||||
|
||||
------------------------------------------------------------------------
|
||||
|
||||
@@ -398,12 +398,12 @@ werden kann.
|
||||
|
||||
### Phase 6 -- Qualität und Stabilität
|
||||
|
||||
- [ ] Unit Tests
|
||||
- [ ] Room Tests
|
||||
- [ ] UI Tests
|
||||
- [ ] Fehlerbehandlung
|
||||
- [x] Unit Tests
|
||||
- [x] Room Tests
|
||||
- [x] UI Tests
|
||||
- [x] Fehlerbehandlung
|
||||
- [ ] Tablet-Optimierung
|
||||
- [ ] Datenschutz-Review
|
||||
- [x] Datenschutz-Review
|
||||
|
||||
### Phase 7 -- Multi-Device-Synchronisation
|
||||
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -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,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,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) } }
|
||||
@@ -28,11 +28,11 @@ import de.medapp.wunddoku.domain.model.InputSpec
|
||||
* Abschnitt 2.3b.
|
||||
*
|
||||
* [CATALOG_VERSION] wird beim Erstellen einer neuen Dokumentation als rein informatives
|
||||
* Metadatum in Documentation.catalogVersion übernommen. Auf 5 erhöht (Decubitus als einfache Checkbox, Umbau "Empfohlene Wundversorgung").
|
||||
* Metadatum in Documentation.catalogVersion übernommen. Auf 6 erhöht (Wundart/Wundgrund geteilt, Wundrandschutz neu geordnet, Granudacyn und Freitext entkoppelt).
|
||||
*/
|
||||
object DocumentationCatalog {
|
||||
|
||||
const val CATALOG_VERSION: Int = 5
|
||||
const val CATALOG_VERSION: Int = 6
|
||||
|
||||
/**
|
||||
* Label der automatisch an jeden Block angehängten Freitext-Option. Zentral hier definiert,
|
||||
@@ -65,30 +65,31 @@ object DocumentationCatalog {
|
||||
CheckItemDefinition(id = "wundart_ulcus_cruris_arteriosum", label = "Ulcus Cruris art.", order = 6),
|
||||
CheckItemDefinition(id = "wundart_ulcus_cruris_venosum_arteriosum", label = "Ulcus Cruris ven.-art.", order = 7),
|
||||
CheckItemDefinition(id = "wundart_diabetisches_fusssyndrom", label = "Diab. Fuß Syndrom", order = 8),
|
||||
CheckItemDefinition(id = "wundart_post_operative_wundheilungsstoerung", label = "Post-operative Wundheilungsstörung / Dehiszenz", order = 9),
|
||||
CheckItemDefinition(id = "wundart_schuerfwunde", label = "Schürfwunde", order = 10),
|
||||
CheckItemDefinition(id = "wundart_intertrigo", label = "Intertrigo / Wunde nach Intertrigo", order = 11),
|
||||
CheckItemDefinition(id = "wundart_iad", label = "IAD (Inkontinenz-assoziierte Dermatitis) / Wunde nach IAD", order = 12),
|
||||
CheckItemDefinition(id = "wundart_amputationswunde", label = "Amputationswunde", order = 13),
|
||||
CheckItemDefinition(id = "wundart_spalthautentnahmestelle", label = "Spalthautentnahmestelle", order = 14),
|
||||
CheckItemDefinition(id = "wundart_post_operative_wundheilungsstoerung", label = "Post-operative Wundheilungsstörung", order = 9),
|
||||
CheckItemDefinition(id = "wundart_dehiszenz", label = "Dehiszenz", order = 10),
|
||||
CheckItemDefinition(id = "wundart_schuerfwunde", label = "Schürfwunde", order = 11),
|
||||
CheckItemDefinition(id = "wundart_intertrigo", label = "Intertrigo / Wunde nach Intertrigo", order = 12),
|
||||
CheckItemDefinition(id = "wundart_iad", label = "IAD (Inkontinenz-assoziierte Dermatitis) / Wunde nach IAD", order = 13),
|
||||
CheckItemDefinition(id = "wundart_amputationswunde", label = "Amputationswunde", order = 14),
|
||||
CheckItemDefinition(id = "wundart_spalthautentnahmestelle", label = "Spalthautentnahmestelle", order = 15),
|
||||
CheckItemDefinition(
|
||||
id = "wundart_transplantatverlust",
|
||||
label = "Wunde nach Transplantatverlust",
|
||||
order = 15,
|
||||
order = 16,
|
||||
inputSpec = InputSpec(
|
||||
pdfTemplate = "Wunde nach Transplantatverlust, Spalthautdeckung ca. {0}%",
|
||||
fields = listOf(InputFieldDef.Number(label = "Spalthautdeckung ca. (%)", maxLength = 3)),
|
||||
),
|
||||
),
|
||||
CheckItemDefinition(id = "wundart_sekundaer_heilende_wunde", label = "Sekundär heilende Wunde", order = 16),
|
||||
CheckItemDefinition(id = "wundart_tumorwunde", label = "Tumorwunde", order = 17),
|
||||
CheckItemDefinition(id = "wundart_wunde_nach_abszess", label = "Wunde nach Abszess", order = 18),
|
||||
CheckItemDefinition(id = "wundart_verbrennung_verbruehung", label = "Verbrennung / Verbrühung", order = 19),
|
||||
CheckItemDefinition(id = "wundart_platzwunde", label = "Platzwunde", order = 20),
|
||||
CheckItemDefinition(id = "wundart_schnittwunde", label = "Schnittwunde", order = 21),
|
||||
CheckItemDefinition(id = "wundart_bisswunde", label = "Bisswunde", order = 22),
|
||||
CheckItemDefinition(id = "wundart_chemisch_veraetzung", label = "Chemisch hervorgerufene Wunde / Verätzung", order = 23),
|
||||
CheckItemDefinition(id = "wundart_strahlenschaden", label = "Strahlenschaden", order = 24),
|
||||
CheckItemDefinition(id = "wundart_sekundaer_heilende_wunde", label = "Sekundär heilende Wunde", order = 17),
|
||||
CheckItemDefinition(id = "wundart_tumorwunde", label = "Tumorwunde", order = 18),
|
||||
CheckItemDefinition(id = "wundart_wunde_nach_abszess", label = "Wunde nach Abszess", order = 19),
|
||||
CheckItemDefinition(id = "wundart_verbrennung_verbruehung", label = "Verbrennung / Verbrühung", order = 20),
|
||||
CheckItemDefinition(id = "wundart_platzwunde", label = "Platzwunde", order = 21),
|
||||
CheckItemDefinition(id = "wundart_schnittwunde", label = "Schnittwunde", order = 22),
|
||||
CheckItemDefinition(id = "wundart_bisswunde", label = "Bisswunde", order = 23),
|
||||
CheckItemDefinition(id = "wundart_chemisch_veraetzung", label = "Chemisch hervorgerufene Wunde / Verätzung", order = 24),
|
||||
CheckItemDefinition(id = "wundart_strahlenschaden", label = "Strahlenschaden", order = 25),
|
||||
),
|
||||
),
|
||||
BlockDefinition(
|
||||
@@ -96,7 +97,7 @@ object DocumentationCatalog {
|
||||
title = "Lokalisation",
|
||||
order = 2,
|
||||
items = listOf(
|
||||
CheckItemDefinition(id = "lokalisation_multiple_wunden", label = "multiple Wunden im Bereich", order = 1),
|
||||
CheckItemDefinition(id = "lokalisation_multiple_wunden", label = "Multiple Wunden im Bereich", order = 1),
|
||||
),
|
||||
),
|
||||
BlockDefinition(
|
||||
@@ -120,9 +121,13 @@ object DocumentationCatalog {
|
||||
),
|
||||
CheckItemDefinition(id = "groesse_freitext_masse", label = "Freitext für die Maße", order = 3, isFreeText = true),
|
||||
CheckItemDefinition(id = "groesse_multiple_wunden_max", label = "Multiple Wunden mit max. (cm)", order = 4, isFreeText = true),
|
||||
CheckItemDefinition(id = "groesse_hinweis_areal", label = "Zu versorgendes Areal", order = 5, isStaticHint = true),
|
||||
CheckItemDefinition(id = "groesse_areal", label = "Zu versorgendes Areal", order = 6),
|
||||
CheckItemDefinition(id = "groesse_freitext_areal", label = "Freitext für zu versorgendes Areal", order = 7, isFreeText = true),
|
||||
// Bewusst OHNE separate Hinweis-Überschrift (anders als bei "Maße" oben): Die
|
||||
// Checkbox selbst trägt mit "Zu versorgendes Areal" bereits den fachlich
|
||||
// gewünschten Namen - eine zusätzliche, wortgleiche Überschrift direkt darüber
|
||||
// würde im PDF als zwei identische, nicht unterscheidbare Zeilen hintereinander
|
||||
// erscheinen (Hinweistext und Checkbox-Label werden dort gleich dargestellt).
|
||||
CheckItemDefinition(id = "groesse_areal", label = "Zu versorgendes Areal", order = 5),
|
||||
CheckItemDefinition(id = "groesse_freitext_areal", label = "Freitext für zu versorgendes Areal", order = 6, isFreeText = true),
|
||||
),
|
||||
),
|
||||
BlockDefinition(
|
||||
@@ -165,24 +170,25 @@ object DocumentationCatalog {
|
||||
items = listOf(
|
||||
CheckItemDefinition(id = "wundgrund_wie_ausgestanzt", label = "Wie ausgestanzt", order = 1),
|
||||
CheckItemDefinition(id = "wundgrund_kraterfoermig", label = "Kraterförmig", order = 2),
|
||||
CheckItemDefinition(id = "wundgrund_unregelmaessig_landkartenartig", label = "Unregelmäßig (landkartenartig/amorph)", order = 3),
|
||||
CheckItemDefinition(id = "wundgrund_linear_spaltfoermig", label = "Linear/spaltförmig", order = 4),
|
||||
CheckItemDefinition(id = "wundgrund_rund", label = "Rund", order = 5),
|
||||
CheckItemDefinition(id = "wundgrund_oval_elliptisch", label = "Oval/elliptisch", order = 6),
|
||||
CheckItemDefinition(id = "wundgrund_quadrangulaer", label = "Quadrangulär", order = 7),
|
||||
CheckItemDefinition(id = "wundgrund_eben", label = "Eben", order = 8),
|
||||
CheckItemDefinition(id = "wundgrund_auf_niveau", label = "Auf Niveau", order = 9),
|
||||
CheckItemDefinition(id = "wundgrund_ueber_niveau", label = "Über Niveau", order = 10),
|
||||
CheckItemDefinition(id = "wundgrund_glaenzend", label = "Glänzend", order = 11),
|
||||
CheckItemDefinition(id = "wundgrund_grob", label = "Grob", order = 12),
|
||||
CheckItemDefinition(id = "wundgrund_fein", label = "Fein", order = 13),
|
||||
CheckItemDefinition(id = "wundgrund_glatt", label = "Glatt", order = 14),
|
||||
CheckItemDefinition(id = "wundgrund_rot", label = "Rot", order = 15),
|
||||
CheckItemDefinition(id = "wundgrund_blass", label = "Blass", order = 16),
|
||||
CheckItemDefinition(id = "wundgrund_knochen_sehnen", label = "Knochen/Sehnen", order = 17),
|
||||
CheckItemDefinition(id = "wundgrund_haemorrhagie", label = "Hämorrhagie", order = 18),
|
||||
CheckItemDefinition(id = "wundgrund_nicht_kaum_einsehbar", label = "Nicht/kaum einsehbar", order = 19),
|
||||
CheckItemDefinition(id = "wundgrund_erscheinung_erscheinung", label = "Erscheinung", order = 20, isFreeText = true),
|
||||
CheckItemDefinition(id = "wundgrund_unregelmaessig", label = "Unregelmäßig", order = 3),
|
||||
CheckItemDefinition(id = "wundgrund_landkartenartig_amorph", label = "Landkartenartig/amorph", order = 4),
|
||||
CheckItemDefinition(id = "wundgrund_linear_spaltfoermig", label = "Linear/spaltförmig", order = 5),
|
||||
CheckItemDefinition(id = "wundgrund_rund", label = "Rund", order = 6),
|
||||
CheckItemDefinition(id = "wundgrund_oval_elliptisch", label = "Oval/elliptisch", order = 7),
|
||||
CheckItemDefinition(id = "wundgrund_quadrangulaer", label = "Quadrangulär", order = 8),
|
||||
CheckItemDefinition(id = "wundgrund_eben", label = "Eben", order = 9),
|
||||
CheckItemDefinition(id = "wundgrund_auf_niveau", label = "Auf Niveau", order = 10),
|
||||
CheckItemDefinition(id = "wundgrund_ueber_niveau", label = "Über Niveau", order = 11),
|
||||
CheckItemDefinition(id = "wundgrund_glaenzend", label = "Glänzend", order = 12),
|
||||
CheckItemDefinition(id = "wundgrund_grob", label = "Grob", order = 13),
|
||||
CheckItemDefinition(id = "wundgrund_fein", label = "Fein", order = 14),
|
||||
CheckItemDefinition(id = "wundgrund_glatt", label = "Glatt", order = 15),
|
||||
CheckItemDefinition(id = "wundgrund_rot", label = "Rot", order = 16),
|
||||
CheckItemDefinition(id = "wundgrund_blass", label = "Blass", order = 17),
|
||||
CheckItemDefinition(id = "wundgrund_knochen_sehnen", label = "Knochen/Sehnen", order = 18),
|
||||
CheckItemDefinition(id = "wundgrund_haemorrhagie", label = "Hämorrhagie", order = 19),
|
||||
CheckItemDefinition(id = "wundgrund_nicht_kaum_einsehbar", label = "Nicht/kaum einsehbar", order = 20),
|
||||
CheckItemDefinition(id = "wundgrund_erscheinung_erscheinung", label = "Erscheinung", order = 21, isFreeText = true),
|
||||
),
|
||||
),
|
||||
BlockDefinition(
|
||||
@@ -344,8 +350,8 @@ object DocumentationCatalog {
|
||||
CheckItemDefinition(id = "vitalwerte_arteria_dorsalis_pedis_links", label = "Arteria dorsalis pedis links", order = 2),
|
||||
CheckItemDefinition(id = "vitalwerte_arteria_tibialis_posterior_rechts", label = "Arteria tibialis posterior rechts", order = 3),
|
||||
CheckItemDefinition(id = "vitalwerte_arteria_dorsalis_pedis_rechts", label = "Arteria dorsalis pedis rechts", order = 4),
|
||||
CheckItemDefinition(id = "vitalwerte_per_doppler_taschendoppler", label = "per Doppler (Taschendoppler)", order = 5),
|
||||
CheckItemDefinition(id = "vitalwerte_per_palpation", label = "per Palpation", order = 6),
|
||||
CheckItemDefinition(id = "vitalwerte_per_doppler_taschendoppler", label = "Per Doppler (Taschendoppler)", order = 5),
|
||||
CheckItemDefinition(id = "vitalwerte_per_palpation", label = "Per Palpation", order = 6),
|
||||
CheckItemDefinition(id = "vitalwerte_darstellbar", label = "Darstellbar", order = 7),
|
||||
CheckItemDefinition(id = "vitalwerte_abgeschwaecht_veraendert", label = "Abgeschwächt/verändert", order = 8),
|
||||
CheckItemDefinition(id = "vitalwerte_nicht_darstellbar", label = "Nicht darstellbar", order = 9),
|
||||
@@ -498,61 +504,61 @@ object DocumentationCatalog {
|
||||
CheckItemDefinition(id = "empfohlene_wundversorgung_sterile_kompresse", label = "Sterile Kompresse", order = 2),
|
||||
CheckItemDefinition(id = "empfohlene_wundversorgung_nacl", label = "NaCl 0,9%", order = 3),
|
||||
CheckItemDefinition(id = "empfohlene_wundversorgung_octenisept", label = "Octenisept", order = 4),
|
||||
// Granudacyn: Checkbox + direkt folgendes Freitextfeld, das nur bei angehakter
|
||||
// Checkbox aktiv ist (= vorhandenes isFreeText-Verhalten, s. FreeTextCheckItemRow).
|
||||
CheckItemDefinition(id = "empfohlene_wundversorgung_granudacyn", label = "Granudacyn", order = 5, isFreeText = true),
|
||||
// Granudacyn: einfache Checkbox. Der Freitext ist ein eigener, separat
|
||||
// anzuhakender Punkt direkt darunter (Bezeichnung "Freitext" wird in PDF/JPG
|
||||
// und, sobald angehakt, auch in der App nicht angezeigt).
|
||||
CheckItemDefinition(id = "empfohlene_wundversorgung_granudacyn", label = "Granudacyn", order = 5),
|
||||
CheckItemDefinition(id = "empfohlene_wundversorgung_granudacyn_freitext", label = FREE_TEXT_LABEL, order = 6, isFreeText = true),
|
||||
CheckItemDefinition(
|
||||
id = "empfohlene_wundversorgung_getraenkte_kompresse",
|
||||
label = "Getränkte Kompresse",
|
||||
order = 6,
|
||||
order = 7,
|
||||
inputSpec = InputSpec(
|
||||
pdfTemplate = "Getränkte Kompresse für ca. {0} Min. auflegen",
|
||||
fields = listOf(InputFieldDef.Choice(label = "Minuten", options = MINUTE_1_TO_15_OPTIONS)),
|
||||
),
|
||||
),
|
||||
CheckItemDefinition(id = "empfohlene_wundversorgung_tupfend", label = "Tupfend", order = 7),
|
||||
CheckItemDefinition(id = "empfohlene_wundversorgung_wischend", label = "Wischend", order = 8),
|
||||
CheckItemDefinition(id = "empfohlene_wundversorgung_nachspuelen_nacl", label = "Nachspülen mit NaCl 0,9%", order = 9),
|
||||
CheckItemDefinition(id = "empfohlene_wundversorgung_umgebung_trocken_tupfen", label = "Umgebung trocken tupfen", order = 10),
|
||||
CheckItemDefinition(id = "empfohlene_wundversorgung_primaer_abdecken_austamponieren", label = "Primär Wunde abdecken/austamponieren", order = 11),
|
||||
CheckItemDefinition(id = "empfohlene_wundversorgung_ueber_wundrand_hinaus", label = "Über Wundrand hinaus", order = 12),
|
||||
CheckItemDefinition(id = "empfohlene_wundversorgung_nicht_ueber_wundrand_mit", label = "Nicht über Wundrand mit", order = 13),
|
||||
CheckItemDefinition(id = "empfohlene_wundversorgung_nicht_ueber_wundrand_mit_freitext", label = FREE_TEXT_LABEL, order = 14, isFreeText = true),
|
||||
CheckItemDefinition(id = "empfohlene_wundversorgung_hinweis_wundrandschutz", label = "Wundrandschutz:", order = 15, isStaticHint = true),
|
||||
CheckItemDefinition(id = "empfohlene_wundversorgung_wundrandschutz_ja", label = "Ja", order = 16),
|
||||
CheckItemDefinition(id = "empfohlene_wundversorgung_wundrandschutz_nein", label = "Nein", order = 17),
|
||||
CheckItemDefinition(id = "empfohlene_wundversorgung_wundrandschutz_freitext", label = FREE_TEXT_LABEL, order = 18, isFreeText = true),
|
||||
CheckItemDefinition(id = "empfohlene_wundversorgung_tupfend", label = "Tupfend", order = 8),
|
||||
CheckItemDefinition(id = "empfohlene_wundversorgung_wischend", label = "Wischend", order = 9),
|
||||
CheckItemDefinition(id = "empfohlene_wundversorgung_nachspuelen_nacl", label = "Nachspülen mit NaCl 0,9%", order = 10),
|
||||
CheckItemDefinition(id = "empfohlene_wundversorgung_umgebung_trocken_tupfen", label = "Umgebung trocken tupfen", order = 11),
|
||||
CheckItemDefinition(id = "empfohlene_wundversorgung_primaer_abdecken_austamponieren", label = "Primär Wunde abdecken/austamponieren", order = 12),
|
||||
CheckItemDefinition(id = "empfohlene_wundversorgung_ueber_wundrand_hinaus", label = "Über Wundrand hinaus", order = 13),
|
||||
CheckItemDefinition(id = "empfohlene_wundversorgung_nicht_ueber_wundrand_mit", label = "Nicht über Wundrand mit", order = 14),
|
||||
CheckItemDefinition(id = "empfohlene_wundversorgung_nicht_ueber_wundrand_mit_freitext", label = FREE_TEXT_LABEL, order = 15, isFreeText = true),
|
||||
// Wundrandschutz: EINE Checkbox mit eigenem Freitextfeld (kein Ja/Nein mehr).
|
||||
CheckItemDefinition(id = "empfohlene_wundversorgung_wundrandschutz", label = "Wundrandschutz", order = 16, isFreeText = true),
|
||||
// "Wie oft pro Woche": eigene Checkbox; die Auswahl (1-7x) ist erst nach dem
|
||||
// Anhaken aktiv (InputSpec.requiresOwnCheckbox).
|
||||
CheckItemDefinition(
|
||||
id = "empfohlene_wundversorgung_wundrandschutz_tage_pro_woche",
|
||||
label = "Wie oft pro Woche",
|
||||
order = 19,
|
||||
order = 17,
|
||||
inputSpec = InputSpec(
|
||||
pdfTemplate = "{0}x pro Woche",
|
||||
fields = listOf(InputFieldDef.Choice(label = "Tage pro Woche", options = WEEKDAY_COUNT_OPTIONS)),
|
||||
requiresOwnCheckbox = true,
|
||||
),
|
||||
),
|
||||
CheckItemDefinition(id = "empfohlene_wundversorgung_hinweis_sekundaere_wundauflage", label = "Sekundäre Wundauflage:", order = 20, isStaticHint = true),
|
||||
CheckItemDefinition(id = "empfohlene_wundversorgung_sekundaer_sterile_kompresse", label = "Sterile Kompresse", order = 21),
|
||||
CheckItemDefinition(id = "empfohlene_wundversorgung_saugkompresse", label = "Saugkompresse", order = 22),
|
||||
CheckItemDefinition(id = "empfohlene_wundversorgung_sonstige_wundauflage", label = "Sonstiges (Wundauflage)", order = 23, isFreeText = true),
|
||||
CheckItemDefinition(id = "empfohlene_wundversorgung_hinweis_fixieren_mit", label = "Fixieren mit:", order = 24, isStaticHint = true),
|
||||
CheckItemDefinition(id = "empfohlene_wundversorgung_mullbinde", label = "Mullbinde", order = 25),
|
||||
CheckItemDefinition(id = "empfohlene_wundversorgung_klebevlies", label = "Klebevlies", order = 26),
|
||||
CheckItemDefinition(id = "empfohlene_wundversorgung_folienverband", label = "Folienverband", order = 27),
|
||||
CheckItemDefinition(id = "empfohlene_wundversorgung_schlauchverband", label = "Schlauchverband", order = 28),
|
||||
CheckItemDefinition(id = "empfohlene_wundversorgung_kohaesive_fixierbinde", label = "Kohäsive Fixierbinde", order = 29),
|
||||
CheckItemDefinition(id = "empfohlene_wundversorgung_kohaesive_fixierbinde_freitext", label = FREE_TEXT_LABEL, order = 30, isFreeText = true),
|
||||
CheckItemDefinition(id = "empfohlene_wundversorgung_kompressionsverband_system", label = "Kompressionsverband / System", order = 31),
|
||||
CheckItemDefinition(id = "empfohlene_wundversorgung_kompressionsverband_system_freitext", label = FREE_TEXT_LABEL, order = 32, isFreeText = true),
|
||||
CheckItemDefinition(id = "empfohlene_wundversorgung_wechselintervall", label = "Wechselintervall", order = 33, isFreeText = true),
|
||||
CheckItemDefinition(id = "empfohlene_wundversorgung_hinweis_bedarf", label = "+ bei Bedarf", order = 34, isStaticHint = true),
|
||||
CheckItemDefinition(id = "empfohlene_wundversorgung_hinweis_sekundaere_wundauflage", label = "Sekundäre Wundauflage:", order = 18, isStaticHint = true),
|
||||
CheckItemDefinition(id = "empfohlene_wundversorgung_sekundaer_sterile_kompresse", label = "Sterile Kompresse", order = 19),
|
||||
CheckItemDefinition(id = "empfohlene_wundversorgung_saugkompresse", label = "Saugkompresse", order = 20),
|
||||
CheckItemDefinition(id = "empfohlene_wundversorgung_sonstige_wundauflage", label = "Sonstiges (Wundauflage)", order = 21, isFreeText = true),
|
||||
CheckItemDefinition(id = "empfohlene_wundversorgung_hinweis_fixieren_mit", label = "Fixieren mit:", order = 22, isStaticHint = true),
|
||||
CheckItemDefinition(id = "empfohlene_wundversorgung_mullbinde", label = "Mullbinde", order = 23),
|
||||
CheckItemDefinition(id = "empfohlene_wundversorgung_klebevlies", label = "Klebevlies", order = 24),
|
||||
CheckItemDefinition(id = "empfohlene_wundversorgung_folienverband", label = "Folienverband", order = 25),
|
||||
CheckItemDefinition(id = "empfohlene_wundversorgung_schlauchverband", label = "Schlauchverband", order = 26),
|
||||
CheckItemDefinition(id = "empfohlene_wundversorgung_kohaesive_fixierbinde", label = "Kohäsive Fixierbinde", order = 27),
|
||||
CheckItemDefinition(id = "empfohlene_wundversorgung_kohaesive_fixierbinde_freitext", label = FREE_TEXT_LABEL, order = 28, isFreeText = true),
|
||||
CheckItemDefinition(id = "empfohlene_wundversorgung_kompressionsverband_system", label = "Kompressionsverband / System", order = 29),
|
||||
CheckItemDefinition(id = "empfohlene_wundversorgung_kompressionsverband_system_freitext", label = FREE_TEXT_LABEL, order = 30, isFreeText = true),
|
||||
CheckItemDefinition(id = "empfohlene_wundversorgung_wechselintervall", label = "Wechselintervall", order = 31, isFreeText = true),
|
||||
CheckItemDefinition(id = "empfohlene_wundversorgung_hinweis_bedarf", label = "+ bei Bedarf", order = 32, isStaticHint = true),
|
||||
CheckItemDefinition(
|
||||
id = "empfohlene_wundversorgung_hinweis_abklaerung",
|
||||
label = "Generell: bei akuter Verschlechterung sofort ärztliche Abklärung",
|
||||
order = 35,
|
||||
order = 33,
|
||||
isStaticHint = true,
|
||||
),
|
||||
),
|
||||
@@ -571,9 +577,8 @@ object DocumentationCatalog {
|
||||
CheckItemDefinition(id = "ziel_abheilung", label = "Abheilung", order = 7),
|
||||
CheckItemDefinition(id = "ziel_geruchsreduzierung", label = "Geruchsreduzierung", order = 8),
|
||||
CheckItemDefinition(id = "ziel_einfache_durchfuehrung", label = "Einfache Durchführung", order = 9),
|
||||
CheckItemDefinition(id = "ziel_wundrandschutz", label = "Wundrandschutz", order = 10),
|
||||
CheckItemDefinition(id = "ziel_aufbruch_vorhandenes_material", label = "Aufbruch des vorhandenen Materials", order = 11),
|
||||
CheckItemDefinition(id = "ziel_selbstversorgung_foerdern", label = "Selbstversorgung der Wunde durch Patient/Angehörige unterstützen/fördern", order = 12),
|
||||
CheckItemDefinition(id = "ziel_aufbruch_vorhandenes_material", label = "Aufbruch des vorhandenen Materials", order = 10),
|
||||
CheckItemDefinition(id = "ziel_selbstversorgung_foerdern", label = "Selbstversorgung der Wunde durch Patient/Angehörige unterstützen/fördern", order = 11),
|
||||
),
|
||||
),
|
||||
BlockDefinition(
|
||||
|
||||
@@ -2,10 +2,13 @@ package de.medapp.wunddoku.pdf
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Color
|
||||
import android.graphics.Paint
|
||||
import android.graphics.Typeface
|
||||
import android.graphics.pdf.PdfDocument
|
||||
import android.text.Layout
|
||||
import android.text.StaticLayout
|
||||
import android.text.TextPaint
|
||||
import de.medapp.wunddoku.domain.error.FailureCategory
|
||||
import de.medapp.wunddoku.domain.model.InputSpecCodec
|
||||
@@ -66,6 +69,8 @@ class AndroidPdfDocumentExporter(
|
||||
private const val ITEM_TEXT_SIZE = 11f
|
||||
private const val FREE_TEXT_TEXT_SIZE = 10.5f
|
||||
private const val FREE_TEXT_INDENT = 20f
|
||||
private const val RUNNING_HEADER_TEXT_SIZE = 9.5f
|
||||
private const val RUNNING_HEADER_SPACING_AFTER = 10f
|
||||
|
||||
// PDF-003: Signaturbereich. Fachlich handschriftliche Unterschriften, keine
|
||||
// kryptographische PDF-Signatur (s. Aufgabenstellung Abschnitt 5).
|
||||
@@ -160,6 +165,9 @@ class AndroidPdfDocumentExporter(
|
||||
marginRight = MARGIN,
|
||||
marginTop = MARGIN,
|
||||
marginBottom = MARGIN,
|
||||
pageHeaderDrawer = PageHeaderDrawer { canvas, pageNumber, left, top, width ->
|
||||
drawRunningHeader(canvas, pageNumber, left, top, width, model, paints)
|
||||
},
|
||||
)
|
||||
|
||||
drawHeader(cursor, model, paints)
|
||||
@@ -243,6 +251,32 @@ class AndroidPdfDocumentExporter(
|
||||
textSize = FREE_TEXT_TEXT_SIZE
|
||||
color = Color.DKGRAY
|
||||
}
|
||||
// Zwischenüberschriften innerhalb eines Blocks: kursiv + unterstrichen.
|
||||
val itemSubheading = TextPaint().apply {
|
||||
isAntiAlias = true
|
||||
textSize = ITEM_TEXT_SIZE
|
||||
typeface = Typeface.create(Typeface.DEFAULT, Typeface.ITALIC)
|
||||
isUnderlineText = true
|
||||
color = Color.BLACK
|
||||
}
|
||||
// Wichtiger Hinweis: fett + unterstrichen.
|
||||
val itemWarning = TextPaint().apply {
|
||||
isAntiAlias = true
|
||||
textSize = ITEM_TEXT_SIZE
|
||||
typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD)
|
||||
isUnderlineText = true
|
||||
color = Color.BLACK
|
||||
}
|
||||
// Seitenkopf ab Seite 2 (Patient + Erstelldatum).
|
||||
val runningHeader = TextPaint().apply {
|
||||
isAntiAlias = true
|
||||
textSize = RUNNING_HEADER_TEXT_SIZE
|
||||
color = Color.DKGRAY
|
||||
}
|
||||
val runningHeaderLine = Paint().apply {
|
||||
color = Color.LTGRAY
|
||||
strokeWidth = 1f
|
||||
}
|
||||
|
||||
// PDF-003: Signaturbereich.
|
||||
val signatureLabel = TextPaint().apply {
|
||||
@@ -286,6 +320,37 @@ class AndroidPdfDocumentExporter(
|
||||
cursor.drawDivider(spacingAfter = 12f)
|
||||
}
|
||||
|
||||
/**
|
||||
* Seitenkopf für alle Seiten AB Seite 2 (Änderungswunsch: auf jeder Seite müssen Name des
|
||||
* Patienten und Erstellungsdatum stehen): Seite 1 trägt bereits den großen Dokumentkopf
|
||||
* ([drawHeader]). Gilt auch für Unterschriften- und Fotoseiten. Verwendet ausschließlich
|
||||
* Snapshot-Daten aus dem [PdfExportModel]. Liefert die belegte Höhe zurück.
|
||||
*/
|
||||
private fun drawRunningHeader(
|
||||
canvas: Canvas,
|
||||
pageNumber: Int,
|
||||
left: Float,
|
||||
top: Float,
|
||||
width: Float,
|
||||
model: PdfExportModel,
|
||||
paints: ExportPaints,
|
||||
): Float {
|
||||
if (pageNumber <= 1) return 0f
|
||||
val text = "Patient: ${model.patientFullName}, geb. ${model.patientBirthDate.format(HEADER_DATE_FORMATTER)}" +
|
||||
" · ${model.patientDisplayAddress} · Erstellt am: ${model.createdAt.format(HEADER_DATE_FORMATTER)}"
|
||||
val layout = StaticLayout.Builder.obtain(text, 0, text.length, paints.runningHeader, width.toInt().coerceAtLeast(1))
|
||||
.setAlignment(Layout.Alignment.ALIGN_NORMAL)
|
||||
.setIncludePad(false)
|
||||
.build()
|
||||
canvas.save()
|
||||
canvas.translate(left, top)
|
||||
layout.draw(canvas)
|
||||
canvas.restore()
|
||||
val lineY = top + layout.height + 4f
|
||||
canvas.drawLine(left, lineY, left + width, lineY, paints.runningHeaderLine)
|
||||
return layout.height + 4f + 1f + RUNNING_HEADER_SPACING_AFTER
|
||||
}
|
||||
|
||||
/** Überschriften erscheinen im PDF immer mit abschließendem Doppelpunkt (kein doppelter
|
||||
* Doppelpunkt, falls der Titel bereits mit einem endet). */
|
||||
private fun headingText(title: String): String {
|
||||
@@ -294,6 +359,8 @@ class AndroidPdfDocumentExporter(
|
||||
}
|
||||
|
||||
private fun drawBlock(cursor: PdfPageCursor, block: PdfExportBlock, paints: ExportPaints) {
|
||||
// Änderungswunsch: "Empfohlene Wundversorgung" beginnt immer auf einer eigenen Seite.
|
||||
if (block.startsOnNewPage) cursor.startNewPageUnlessEmpty()
|
||||
cursor.drawText(headingText(block.title), paints.blockTitle, spacingAfter = 6f)
|
||||
|
||||
for (item in block.items) {
|
||||
@@ -330,14 +397,37 @@ class AndroidPdfDocumentExporter(
|
||||
return
|
||||
}
|
||||
|
||||
// Generische "Freitext"-Einträge (Änderungswunsch): Bezeichnung wird NICHT gedruckt, nur
|
||||
// der eingegebene Text (eingerückt unter dem vorhergehenden Eintrag). Ein angehakter,
|
||||
// aber leerer Freitext erzeugt keine Ausgabe.
|
||||
if (!item.showLabel) {
|
||||
if (item.freeTextValue.isNotBlank()) {
|
||||
cursor.drawText(
|
||||
text = item.freeTextValue,
|
||||
paint = paints.item,
|
||||
indent = FREE_TEXT_INDENT,
|
||||
spacingAfter = 6f,
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Die Freitext-Option bekommt einen abschließenden Doppelpunkt, sofern tatsächlich Text
|
||||
// folgt (z. B. "Granudacyn" ohne Text bleibt ohne hängenden Doppelpunkt), normale
|
||||
// Optionen nie.
|
||||
val labelText = if (item.isFreeText && item.freeTextValue.isNotBlank()) "${item.label}:" else item.label
|
||||
val baseLabel = if (item.isFreeText && item.freeTextValue.isNotBlank()) "${item.label}:" else item.label
|
||||
|
||||
// Änderungswunsch: Zwischenüberschriften kursiv + unterstrichen, wichtiger Hinweis fett +
|
||||
// unterstrichen mit Ausrufezeichen davor und dahinter.
|
||||
val (labelText, labelPaint) = when (item.style) {
|
||||
PdfItemStyle.SUBHEADING -> baseLabel to paints.itemSubheading
|
||||
PdfItemStyle.WARNING -> "! $baseLabel !" to paints.itemWarning
|
||||
PdfItemStyle.NORMAL -> baseLabel to paints.item
|
||||
}
|
||||
|
||||
cursor.drawText(
|
||||
text = labelText,
|
||||
paint = paints.item,
|
||||
paint = labelPaint,
|
||||
indent = ITEM_INDENT,
|
||||
spacingAfter = 3f,
|
||||
)
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package de.medapp.wunddoku.pdf
|
||||
|
||||
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.DocumentationBlock
|
||||
import de.medapp.wunddoku.domain.usecase.BlockEditedPolicy
|
||||
import java.time.ZoneId
|
||||
|
||||
@@ -27,22 +30,24 @@ object DocumentationToPdfExportModelMapper {
|
||||
.sortedBy { it.order }
|
||||
.map { block ->
|
||||
PdfExportBlock(
|
||||
definitionId = block.definitionId,
|
||||
title = block.title,
|
||||
// Änderungswunsch: nur angehakte Optionen erscheinen im PDF - nicht
|
||||
// angehakte Checkboxen werden komplett weggelassen (nicht nur unausgefüllt
|
||||
// dargestellt). Ein leeres Ergebnis ist hier ausgeschlossen: BlockEditedPolicy
|
||||
startsOnNewPage = block.definitionId in BLOCKS_ON_OWN_PAGE,
|
||||
// Nur angehakte Optionen erscheinen im PDF. Hinweistexte (isStaticHint) sind
|
||||
// technisch immer angehakt und werden gruppenabhängig gefiltert, s.
|
||||
// [exportedItems]. Ein leeres Ergebnis ist ausgeschlossen: BlockEditedPolicy
|
||||
// (Filter oben) lässt nur Blöcke mit mindestens einer angehakten Option durch.
|
||||
items = block.items
|
||||
.filter { it.checked }
|
||||
.sortedBy { it.order }
|
||||
.map { item ->
|
||||
PdfExportItem(
|
||||
label = item.label,
|
||||
isFreeText = item.isFreeText,
|
||||
freeTextValue = item.freeTextValue,
|
||||
inputSpec = item.inputSpec,
|
||||
)
|
||||
},
|
||||
items = exportedItems(block).map { item ->
|
||||
PdfExportItem(
|
||||
label = item.label,
|
||||
isFreeText = item.isFreeText,
|
||||
freeTextValue = item.freeTextValue,
|
||||
inputSpec = item.inputSpec,
|
||||
// Generische "Freitext"-Bezeichnung nie drucken, nur den Text.
|
||||
showLabel = !(item.isFreeText && item.label == DocumentationCatalog.FREE_TEXT_LABEL),
|
||||
style = styleFor(item),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -80,4 +85,67 @@ object DocumentationToPdfExportModelMapper {
|
||||
photos = photos,
|
||||
)
|
||||
}
|
||||
|
||||
/** Blöcke, die im PDF/JPG immer auf einer neuen Seite beginnen. */
|
||||
private val BLOCKS_ON_OWN_PAGE: Set<String> = setOf("empfohlene_wundversorgung")
|
||||
|
||||
/** Zwischenüberschriften (kursiv + unterstrichen), anhand der stabilen Definitions-IDs. */
|
||||
private val SUBHEADING_DEFINITION_IDS: Set<String> = setOf(
|
||||
"empfohlene_wundversorgung_hinweis_reinigen_mit",
|
||||
"empfohlene_wundversorgung_primaer_abdecken_austamponieren",
|
||||
"empfohlene_wundversorgung_wundrandschutz",
|
||||
"empfohlene_wundversorgung_hinweis_sekundaere_wundauflage",
|
||||
"empfohlene_wundversorgung_hinweis_fixieren_mit",
|
||||
"empfohlene_wundversorgung_wechselintervall",
|
||||
)
|
||||
|
||||
/** Wichtige Hinweise (fett + unterstrichen, mit Ausrufezeichen). */
|
||||
private val WARNING_DEFINITION_IDS: Set<String> = setOf(
|
||||
"empfohlene_wundversorgung_hinweis_abklaerung",
|
||||
)
|
||||
|
||||
private fun styleFor(item: CheckItem): PdfItemStyle = when (item.definitionId) {
|
||||
in WARNING_DEFINITION_IDS -> PdfItemStyle.WARNING
|
||||
in SUBHEADING_DEFINITION_IDS -> PdfItemStyle.SUBHEADING
|
||||
else -> PdfItemStyle.NORMAL
|
||||
}
|
||||
|
||||
/**
|
||||
* Bestimmt die zu exportierenden Einträge eines Blocks (sortiert nach order):
|
||||
* - normale Einträge: nur wenn angehakt
|
||||
* - Hinweistexte (isStaticHint, technisch immer angehakt): Ein Hinweis, dem im Block
|
||||
* eigene Einträge folgen (bis zum nächsten Hinweis), ist eine Gruppenüberschrift und
|
||||
* wird nur gedruckt, wenn mindestens einer dieser Einträge angehakt ist (z. B. "Zu
|
||||
* versorgendes Areal" bei Größe). Hinweise ohne folgende Einträge ("+ bei Bedarf",
|
||||
* "Generell: ...") bleiben immer erhalten. Der automatisch angehängte generische
|
||||
* Freitext eines Blocks gehört zu keiner Gruppe. Trägt der erste angehakte Eintrag der
|
||||
* Gruppe dieselbe Bezeichnung wie der Hinweis, entfällt der Hinweis (keine Doppelung).
|
||||
*
|
||||
* Ausschließlich Snapshot-Daten, kein Zugriff auf den aktuellen Katalog.
|
||||
*/
|
||||
private fun exportedItems(block: DocumentationBlock): List<CheckItem> {
|
||||
val sorted = block.items.sortedBy { it.order }
|
||||
val genericFreeTextId = "${block.definitionId}_freetext"
|
||||
val result = mutableListOf<CheckItem>()
|
||||
for ((index, item) in sorted.withIndex()) {
|
||||
if (!item.checked) continue
|
||||
if (!item.isStaticHint) {
|
||||
result.add(item)
|
||||
continue
|
||||
}
|
||||
val groupItems = sorted
|
||||
.drop(index + 1)
|
||||
.takeWhile { !it.isStaticHint }
|
||||
.filter { it.definitionId != genericFreeTextId }
|
||||
val keep = when {
|
||||
groupItems.isEmpty() -> true
|
||||
else -> {
|
||||
val firstChecked = groupItems.firstOrNull { it.checked }
|
||||
firstChecked != null && !firstChecked.label.trim().equals(item.label.trim(), ignoreCase = true)
|
||||
}
|
||||
}
|
||||
if (keep) result.add(item)
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,7 +27,12 @@ data class PdfExportModel(
|
||||
)
|
||||
|
||||
data class PdfExportBlock(
|
||||
/** Stabile Definitions-ID des Blocks (z. B. "empfohlene_wundversorgung"). */
|
||||
val definitionId: String = "",
|
||||
val title: String,
|
||||
/** `true`: der Block beginnt im PDF/JPG zwingend auf einer neuen Seite (Änderungswunsch:
|
||||
* "Empfohlene Wundversorgung" auf eigener Seite). */
|
||||
val startsOnNewPage: Boolean = false,
|
||||
/** Bereits gefiltert (NUR angehakte Optionen, s. Mapper - Änderungswunsch) und sortiert
|
||||
* (itemOrder). Nicht angehakte Optionen erscheinen im PDF überhaupt nicht mehr. */
|
||||
val items: List<PdfExportItem>,
|
||||
@@ -45,8 +50,23 @@ data class PdfExportItem(
|
||||
val isFreeText: Boolean,
|
||||
val freeTextValue: String,
|
||||
val inputSpec: String = "",
|
||||
/** `false`: die Bezeichnung wird nicht gedruckt, nur der eingegebene Text (generische
|
||||
* "Freitext"-Einträge, Änderungswunsch: Schriftzug "Freitext" im PDF/JPG nicht anzeigen). */
|
||||
val showLabel: Boolean = true,
|
||||
val style: PdfItemStyle = PdfItemStyle.NORMAL,
|
||||
)
|
||||
|
||||
/** Darstellung der Bezeichnung eines Eintrags im PDF (rein layoutbezogen). */
|
||||
enum class PdfItemStyle {
|
||||
NORMAL,
|
||||
|
||||
/** Zwischenüberschrift innerhalb eines Blocks: kursiv und unterstrichen. */
|
||||
SUBHEADING,
|
||||
|
||||
/** Wichtiger Hinweis: fett, unterstrichen, mit Ausrufezeichen davor und dahinter. */
|
||||
WARNING,
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimale, für den Fotoanhang benötigte Referenz. Enthält bewusst nur [fileName] -
|
||||
* `documentationId` steht bereits am [PdfExportModel] selbst zur Verfügung (nötig, um über
|
||||
|
||||
@@ -10,6 +10,15 @@ import android.text.Layout
|
||||
import android.text.StaticLayout
|
||||
import android.text.TextPaint
|
||||
|
||||
/**
|
||||
* Zeichnet den Seitenkopf einer (neuen) Seite und liefert die dafür belegte Höhe zurück
|
||||
* (0f, wenn auf dieser Seite kein Seitenkopf gezeichnet wird, z. B. Seite 1 mit dem großen
|
||||
* Dokumentkopf). [left]/[top] sind die obere linke Ecke des Inhaltsbereichs, [width] dessen Breite.
|
||||
*/
|
||||
internal fun interface PageHeaderDrawer {
|
||||
fun draw(canvas: Canvas, pageNumber: Int, left: Float, top: Float, width: Float): Float
|
||||
}
|
||||
|
||||
/**
|
||||
* Verwaltet Seiten, Zeichenfläche und die aktuelle vertikale Schreibposition ("Cursor") beim
|
||||
* Erzeugen eines [PdfDocument]. Kapselt die komplette Seitenumbruch-Logik an einer Stelle, damit
|
||||
@@ -33,8 +42,13 @@ internal class PdfPageCursor(
|
||||
private val marginRight: Float,
|
||||
private val marginTop: Float,
|
||||
private val marginBottom: Float,
|
||||
/** Optionaler Seitenkopf, der auf JEDER neu begonnenen Seite gezeichnet wird (Änderungswunsch:
|
||||
* Patientenname und Erstellungsdatum auf jeder Seite). */
|
||||
private val pageHeaderDrawer: PageHeaderDrawer? = null,
|
||||
) {
|
||||
private var pageNumber = 0
|
||||
/** Y-Position, ab der auf der aktuellen Seite Inhalt beginnt (nach einem evtl. Seitenkopf). */
|
||||
private var pageContentTop: Float = marginTop
|
||||
private var currentPage: PdfDocument.Page? = null
|
||||
private var canvas: Canvas? = null
|
||||
private var cursorY: Float = marginTop
|
||||
@@ -74,7 +88,15 @@ internal class PdfPageCursor(
|
||||
val page = pdfDocument.startPage(info)
|
||||
currentPage = page
|
||||
canvas = page.canvas
|
||||
cursorY = marginTop
|
||||
val headerHeight = pageHeaderDrawer?.draw(
|
||||
canvas = page.canvas,
|
||||
pageNumber = pageNumber,
|
||||
left = marginLeft,
|
||||
top = marginTop,
|
||||
width = pageWidth - marginLeft - marginRight,
|
||||
) ?: 0f
|
||||
pageContentTop = marginTop + headerHeight
|
||||
cursorY = pageContentTop
|
||||
}
|
||||
|
||||
private fun finishCurrentPageIfOpen() {
|
||||
@@ -98,6 +120,15 @@ internal class PdfPageCursor(
|
||||
startNewPage()
|
||||
}
|
||||
|
||||
/**
|
||||
* Beginnt eine neue Seite, außer die aktuelle Seite ist noch völlig leer (Cursor steht am
|
||||
* Inhaltsanfang) - so entstehen keine Leerseiten, wenn ein Block, der immer auf einer
|
||||
* eigenen Seite beginnt, ohnehin ganz oben stünde.
|
||||
*/
|
||||
fun startNewPageUnlessEmpty() {
|
||||
if (currentPage == null || cursorY > pageContentTop) startNewPage()
|
||||
}
|
||||
|
||||
/**
|
||||
* Zeichnet [bitmap] proportional eingepasst (kein Zuschneiden, keine Verzerrung) horizontal
|
||||
* über die volle Inhaltsbreite und vertikal zentriert im aktuell verbleibenden Platz der
|
||||
|
||||
+9
-2
@@ -85,6 +85,7 @@ import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import de.medapp.wunddoku.R
|
||||
import de.medapp.wunddoku.domain.catalog.DocumentationCatalog
|
||||
import de.medapp.wunddoku.domain.model.CheckItem
|
||||
import de.medapp.wunddoku.domain.model.InputFieldDef
|
||||
import de.medapp.wunddoku.domain.model.InputSpecCodec
|
||||
@@ -907,19 +908,25 @@ private fun FreeTextCheckItemRow(
|
||||
onCheckedChanged: (checkItemStateId: String, checked: Boolean) -> Unit,
|
||||
onFreeTextChanged: (checkItemStateId: String, text: String) -> Unit,
|
||||
) {
|
||||
// Änderungswunsch: Ist ein generischer "Freitext"-Eintrag angehakt, wird der Schriftzug
|
||||
// "Freitext" nicht mehr angezeigt (nur Häkchenkästchen + Eingabefeld). Fachlich benannte
|
||||
// Freitext-Einträge (z. B. "Wundrandschutz", "Wechselintervall") behalten ihre Bezeichnung.
|
||||
val hideName = item.checked && item.label == DocumentationCatalog.FREE_TEXT_LABEL
|
||||
val fieldLabel: (@Composable () -> Unit)? =
|
||||
if (hideName) null else ({ Text(stringResource(R.string.documentation_edit_free_text_label)) })
|
||||
Column {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Checkbox(
|
||||
checked = item.checked,
|
||||
onCheckedChange = { checked -> onCheckedChanged(item.checkItemStateId, checked) },
|
||||
)
|
||||
Text(item.label)
|
||||
if (!hideName) Text(item.label)
|
||||
}
|
||||
OutlinedTextField(
|
||||
value = item.freeTextValue,
|
||||
onValueChange = { text -> onFreeTextChanged(item.checkItemStateId, text) },
|
||||
enabled = item.checked,
|
||||
label = { Text(stringResource(R.string.documentation_edit_free_text_label)) },
|
||||
label = fieldLabel,
|
||||
modifier = Modifier.fillMaxWidth().padding(start = 40.dp),
|
||||
minLines = 2,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
package de.medapp.wunddoku.domain.catalog
|
||||
|
||||
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
|
||||
|
||||
/**
|
||||
* Strukturprüfungen für den statischen Katalog sowie die fachlichen Änderungswünsche
|
||||
* (Wundart/Wundgrund geteilt, Wundrandschutz, Granudacyn).
|
||||
*/
|
||||
class DocumentationCatalogTest {
|
||||
|
||||
private fun items(blockId: String) = DocumentationCatalog.findBlock(blockId)!!.items
|
||||
|
||||
@Test
|
||||
fun alle_checkitem_ids_sind_katalogweit_eindeutig() {
|
||||
val ids = DocumentationCatalog.blocks.flatMap { b -> b.items.map { it.id } }
|
||||
assertEquals(ids.size, ids.toSet().size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun order_ist_je_block_eindeutig_und_aufsteigend() {
|
||||
for (block in DocumentationCatalog.blocks) {
|
||||
val orders = block.items.map { it.order }
|
||||
assertEquals("Block ${block.id}", orders.sorted(), orders)
|
||||
assertEquals("Block ${block.id}", orders.size, orders.toSet().size)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun wundart_postoperativ_und_dehiszenz_sind_getrennte_punkte() {
|
||||
val wundart = items("wundart")
|
||||
assertEquals("Post-operative Wundheilungsstörung", wundart.single { it.id == "wundart_post_operative_wundheilungsstoerung" }.label)
|
||||
assertEquals("Dehiszenz", wundart.single { it.id == "wundart_dehiszenz" }.label)
|
||||
assertTrue(wundart.none { it.label.contains("/ Dehiszenz") })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun wundgrund_unregelmaessig_und_landkartenartig_sind_getrennte_punkte() {
|
||||
val wundgrund = items("wundgrund_erscheinung")
|
||||
assertEquals("Unregelmäßig", wundgrund.single { it.id == "wundgrund_unregelmaessig" }.label)
|
||||
assertEquals("Landkartenartig/amorph", wundgrund.single { it.id == "wundgrund_landkartenartig_amorph" }.label)
|
||||
assertNull(wundgrund.firstOrNull { it.id == "wundgrund_unregelmaessig_landkartenartig" })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun wundrandschutz_ist_aus_ziel_entfernt() {
|
||||
assertTrue(items("ziel").none { it.label.contains("Wundrandschutz") })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun wundrandschutz_ist_eine_einzelne_freitext_box_ohne_ja_nein() {
|
||||
val ev = items("empfohlene_wundversorgung")
|
||||
val wrs = ev.single { it.id == "empfohlene_wundversorgung_wundrandschutz" }
|
||||
assertEquals("Wundrandschutz", wrs.label)
|
||||
assertTrue(wrs.isFreeText)
|
||||
assertNull(ev.firstOrNull { it.id == "empfohlene_wundversorgung_hinweis_wundrandschutz" })
|
||||
assertNull(ev.firstOrNull { it.id == "empfohlene_wundversorgung_wundrandschutz_ja" })
|
||||
assertNull(ev.firstOrNull { it.id == "empfohlene_wundversorgung_wundrandschutz_nein" })
|
||||
// "Wie oft pro Woche" bleibt als eigene Checkbox erhalten.
|
||||
assertNotNull(ev.firstOrNull { it.id == "empfohlene_wundversorgung_wundrandschutz_tage_pro_woche" })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun granudacyn_ist_einfache_checkbox_und_freitext_ein_eigener_punkt_direkt_darunter() {
|
||||
val ev = items("empfohlene_wundversorgung")
|
||||
val granudacyn = ev.single { it.id == "empfohlene_wundversorgung_granudacyn" }
|
||||
val freitext = ev.single { it.id == "empfohlene_wundversorgung_granudacyn_freitext" }
|
||||
|
||||
assertFalse(granudacyn.isFreeText)
|
||||
assertTrue(freitext.isFreeText)
|
||||
assertEquals(DocumentationCatalog.FREE_TEXT_LABEL, freitext.label)
|
||||
assertEquals(granudacyn.order + 1, freitext.order)
|
||||
}
|
||||
}
|
||||
+147
-1
@@ -172,10 +172,156 @@ class DocumentationToPdfExportModelMapperTest {
|
||||
|
||||
val items = DocumentationToPdfExportModelMapper.map(doc).blocks.single().items
|
||||
|
||||
assertEquals(PdfExportItem("Freitext", isFreeText = true, freeTextValue = "Rötung", inputSpec = ""), items[0])
|
||||
// Generischer "Freitext": Bezeichnung wird nicht gedruckt (showLabel = false).
|
||||
assertEquals(
|
||||
PdfExportItem("Freitext", isFreeText = true, freeTextValue = "Rötung", inputSpec = "", showLabel = false),
|
||||
items[0],
|
||||
)
|
||||
assertEquals(PdfExportItem("Maße", isFreeText = false, freeTextValue = "3|4", inputSpec = "SPEC"), items[1])
|
||||
}
|
||||
|
||||
// --- Änderungswünsche: Freitext, Hinweisgruppen, Stile, eigene Seite ------------------
|
||||
|
||||
@Test
|
||||
fun benannter_freitext_behaelt_seine_bezeichnung() {
|
||||
val doc = documentation(
|
||||
listOf(
|
||||
block("a", 1, items = listOf(
|
||||
item("w", 1, checked = true, label = "Wundrandschutz", isFreeText = true, freeTextValue = "Cavilon"),
|
||||
)),
|
||||
)
|
||||
)
|
||||
|
||||
assertTrue(DocumentationToPdfExportModelMapper.map(doc).blocks.single().items.single().showLabel)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun gruppenhinweis_ohne_angehakten_eintrag_der_gruppe_entfaellt() {
|
||||
// Größe: "Zu versorgendes Areal" darf nicht erscheinen, wenn nur die Maße ausgefüllt sind.
|
||||
val doc = documentation(
|
||||
listOf(
|
||||
block("groesse", 1, items = listOf(
|
||||
item("h_masse", 1, checked = true, label = "Maße", isStaticHint = true),
|
||||
item("masse", 2, checked = true, label = "Maße (Länge / Breite / Tiefe)", freeTextValue = "3|4|1", inputSpec = "SPEC"),
|
||||
item("h_areal", 3, checked = true, label = "Zu versorgendes Areal", isStaticHint = true),
|
||||
item("areal", 4, checked = false, label = "Zu versorgendes Areal"),
|
||||
item("areal_ft", 5, checked = false, label = "Freitext für zu versorgendes Areal", isFreeText = true),
|
||||
// generischer Freitext des Blocks gehört zu keiner Gruppe
|
||||
item("groesse_freetext", 6, checked = false, label = "Freitext", isFreeText = true),
|
||||
)),
|
||||
)
|
||||
)
|
||||
|
||||
val labels = DocumentationToPdfExportModelMapper.map(doc).blocks.single().items.map { it.label }
|
||||
|
||||
assertEquals(listOf("Maße", "Maße (Länge / Breite / Tiefe)"), labels)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun generischer_freitext_laesst_gruppenhinweis_nicht_erscheinen() {
|
||||
val doc = documentation(
|
||||
listOf(
|
||||
block("groesse", 1, items = listOf(
|
||||
item("masse", 1, checked = true, label = "Maße", freeTextValue = "3|4|1", inputSpec = "SPEC"),
|
||||
item("h_areal", 2, checked = true, label = "Zu versorgendes Areal", isStaticHint = true),
|
||||
item("areal", 3, checked = false, label = "Areal"),
|
||||
item("groesse_freetext", 4, checked = true, label = "Freitext", isFreeText = true, freeTextValue = "x"),
|
||||
)),
|
||||
)
|
||||
)
|
||||
|
||||
val labels = DocumentationToPdfExportModelMapper.map(doc).blocks.single().items.map { it.label }
|
||||
|
||||
assertEquals(listOf("Maße", "Freitext"), labels)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun gruppenhinweis_mit_angehaktem_eintrag_bleibt_erhalten() {
|
||||
val doc = documentation(
|
||||
listOf(
|
||||
block("a", 1, items = listOf(
|
||||
item("h", 1, checked = true, label = "Fixieren mit:", isStaticHint = true),
|
||||
item("m", 2, checked = true, label = "Mullbinde"),
|
||||
item("k", 3, checked = false, label = "Klebevlies"),
|
||||
)),
|
||||
)
|
||||
)
|
||||
|
||||
val labels = DocumentationToPdfExportModelMapper.map(doc).blocks.single().items.map { it.label }
|
||||
|
||||
assertEquals(listOf("Fixieren mit:", "Mullbinde"), labels)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun gruppenhinweis_mit_gleichnamigem_angehaktem_eintrag_wird_nicht_doppelt_gedruckt() {
|
||||
val doc = documentation(
|
||||
listOf(
|
||||
block("a", 1, items = listOf(
|
||||
item("h", 1, checked = true, label = "Zu versorgendes Areal", isStaticHint = true),
|
||||
item("areal", 2, checked = true, label = "Zu versorgendes Areal"),
|
||||
)),
|
||||
)
|
||||
)
|
||||
|
||||
val labels = DocumentationToPdfExportModelMapper.map(doc).blocks.single().items.map { it.label }
|
||||
|
||||
assertEquals(listOf("Zu versorgendes Areal"), labels)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun abschliessende_hinweise_ohne_folgende_eintraege_bleiben_immer_erhalten() {
|
||||
val doc = documentation(
|
||||
listOf(
|
||||
block("empfohlene_wundversorgung", 1, items = listOf(
|
||||
item("x", 1, checked = true, label = "Mullbinde"),
|
||||
item("empfohlene_wundversorgung_hinweis_bedarf", 2, checked = true, label = "+ bei Bedarf", isStaticHint = true),
|
||||
item("empfohlene_wundversorgung_hinweis_abklaerung", 3, checked = true, label = "Generell: ...", isStaticHint = true),
|
||||
item("empfohlene_wundversorgung_freetext", 4, checked = false, label = "Freitext", isFreeText = true),
|
||||
)),
|
||||
)
|
||||
)
|
||||
|
||||
val labels = DocumentationToPdfExportModelMapper.map(doc).blocks.single().items.map { it.label }
|
||||
|
||||
assertEquals(listOf("Mullbinde", "+ bei Bedarf", "Generell: ..."), labels)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun stile_werden_anhand_der_definitions_id_vergeben() {
|
||||
val doc = documentation(
|
||||
listOf(
|
||||
block("empfohlene_wundversorgung", 1, items = listOf(
|
||||
item("empfohlene_wundversorgung_hinweis_reinigen_mit", 1, checked = true, label = "Reinigen mit:", isStaticHint = true),
|
||||
item("empfohlene_wundversorgung_nacl", 2, checked = true, label = "NaCl 0,9%"),
|
||||
item("empfohlene_wundversorgung_primaer_abdecken_austamponieren", 3, checked = true, label = "Primär Wunde abdecken/austamponieren"),
|
||||
item("empfohlene_wundversorgung_hinweis_abklaerung", 4, checked = true, label = "Generell: ...", isStaticHint = true),
|
||||
)),
|
||||
)
|
||||
)
|
||||
|
||||
val styles = DocumentationToPdfExportModelMapper.map(doc).blocks.single().items.map { it.style }
|
||||
|
||||
assertEquals(
|
||||
listOf(PdfItemStyle.SUBHEADING, PdfItemStyle.NORMAL, PdfItemStyle.SUBHEADING, PdfItemStyle.WARNING),
|
||||
styles,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun empfohlene_wundversorgung_beginnt_auf_eigener_seite_andere_bloecke_nicht() {
|
||||
val doc = documentation(
|
||||
listOf(
|
||||
block("weitere_ueberlegung", 1, items = listOf(item("a", 1, checked = true))),
|
||||
block("empfohlene_wundversorgung", 2, items = listOf(item("b", 1, checked = true))),
|
||||
block("ziel", 3, items = listOf(item("c", 1, checked = true))),
|
||||
)
|
||||
)
|
||||
|
||||
val blocks = DocumentationToPdfExportModelMapper.map(doc).blocks
|
||||
|
||||
assertEquals(listOf(false, true, false), blocks.map { it.startsOnNewPage })
|
||||
}
|
||||
|
||||
// --- Sortierung ----------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
|
||||
Reference in New Issue
Block a user