Compare commits

..

19 Commits

Author SHA1 Message Date
niklas.ortmann d8b405ef4e README.md aktualisiert 2026-10-15 17:50:44 +02:00
niklas.ortmann 829118233b Merge pull request 'beta1.4 Release' (#21) from dev into main
Reviewed-on: #21
2026-10-15 17:49:00 +02:00
niklas.ortmann c5fcf27367 README.md aktualisiert 2026-10-12 02:11:20 +02:00
niklas.ortmann 09e7224c0a Dateien nach "/" hochladen 2026-10-12 02:09:05 +02:00
niklas.ortmann 34cd20786b Merge pull request 'beta 1.3 Release' (#18) from dev into main
Reviewed-on: #18
2026-10-12 01:58:47 +02:00
niklas.ortmann bf9fcf7b0b README.md aktualisiert 2026-10-09 16:54:09 +02:00
niklas.ortmann 4eaf1480a2 Dateien nach "/" hochladen 2026-10-09 15:48:30 +02:00
niklas.ortmann 4a29d653bc Merge pull request 'QA-002' (#16) from dev-QA002 into main
Reviewed-on: #16
2026-10-09 10:08:04 +02:00
niklas.ortmann 77f71973a7 QA-002
Room-Tests
2026-10-09 10:07:17 +02:00
niklas.ortmann 85600bfaed Merge pull request 'beta 1.2 release' (#14) from dev into main
Reviewed-on: #14
2026-10-08 00:22:57 +02:00
niklas.ortmann 42e4b5c78c README.md aktualisiert 2026-10-07 11:49:23 +02:00
niklas.ortmann b265f3fadb README.md aktualisiert 2026-10-07 11:49:01 +02:00
niklas.ortmann e8ccab4f00 README.md aktualisiert 2026-10-07 11:48:25 +02:00
niklas.ortmann 76bf0f16ec README.md aktualisiert 2026-10-07 11:47:42 +02:00
niklas.ortmann 52a95e2b54 README.md aktualisiert 2026-10-07 11:45:07 +02:00
niklas.ortmann 08f24aadd5 Merge pull request 'kleine Inhaltliche Korrekturen' (#12) from dev-Inhalte2 into main
Reviewed-on: #12
2026-10-06 09:00:16 +02:00
niklas.ortmann 732c5b73c0 Update DocumentationCatalog.kt 2026-10-06 08:59:17 +02:00
niklas.ortmann f25596563c beta1.0-release 2026-10-03 00:47:14 +02:00
niklas.ortmann b5ef793513 Merge pull request 'Beta 1.0 Release' (#11) from dev into main
Reviewed-on: #11
2026-10-03 00:37:20 +02:00
26 changed files with 2056 additions and 543 deletions
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+2 -2
View File
@@ -61,8 +61,8 @@ android {
// im gesamten Projekt spürbar.
minSdk = 26
targetSdk = 35
versionCode = 5
versionName = "1.5"
versionCode = 1
versionName = "1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
Binary file not shown.
Binary file not shown.
+2 -2
View File
@@ -11,8 +11,8 @@
"type": "SINGLE",
"filters": [],
"attributes": [],
"versionCode": 5,
"versionName": "1.5",
"versionCode": 1,
"versionName": "1.0",
"outputFile": "app-release.apk"
}
],
@@ -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 6 erhöht (Wundart/Wundgrund geteilt, Wundrandschutz neu geordnet, Granudacyn und Freitext entkoppelt).
* Metadatum in Documentation.catalogVersion übernommen. Auf 5 erhöht (Decubitus als einfache Checkbox, Umbau "Empfohlene Wundversorgung").
*/
object DocumentationCatalog {
const val CATALOG_VERSION: Int = 6
const val CATALOG_VERSION: Int = 5
/**
* Label der automatisch an jeden Block angehängten Freitext-Option. Zentral hier definiert,
@@ -65,31 +65,30 @@ 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", 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_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_transplantatverlust",
label = "Wunde nach Transplantatverlust",
order = 16,
order = 15,
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 = 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),
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),
),
),
BlockDefinition(
@@ -97,7 +96,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(
@@ -121,9 +120,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(
@@ -166,25 +169,24 @@ 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", 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),
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),
),
),
BlockDefinition(
@@ -346,8 +348,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),
@@ -500,61 +502,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: 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),
// Granudacyn: Checkbox + direkt folgendes Freitextfeld, das nur bei angehakter
// Checkbox aktiv ist (= vorhandenes isFreeText-Verhalten, s. FreeTextCheckItemRow).
CheckItemDefinition(id = "empfohlene_wundversorgung_granudacyn", label = "Granudacyn", order = 5, isFreeText = true),
CheckItemDefinition(
id = "empfohlene_wundversorgung_getraenkte_kompresse",
label = "Getränkte Kompresse",
order = 7,
order = 6,
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 = 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),
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),
// "Wie oft pro Woche": eigene Checkbox; die Auswahl (1-7x) ist erst nach dem
// Anhaken aktiv (InputSpec.requiresOwnCheckbox).
CheckItemDefinition(
id = "empfohlene_wundversorgung_wundrandschutz_tage_pro_woche",
label = "Wie oft pro Woche",
order = 17,
order = 19,
inputSpec = InputSpec(
pdfTemplate = "{0}x pro Woche",
fields = listOf(InputFieldDef.Choice(label = "Tage pro Woche", options = WEEKDAY_COUNT_OPTIONS)),
requiresOwnCheckbox = true,
),
),
CheckItemDefinition(id = "empfohlene_wundversorgung_hinweis_sekundaere_wundauflage", label = "Sekundäre Wundauflage:", order = 18, isStaticHint = true),
CheckItemDefinition(id = "empfohlene_wundversorgung_sekundaer_sterile_kompresse", label = "Sterile Kompresse", order = 19),
CheckItemDefinition(id = "empfohlene_wundversorgung_saugkompresse", label = "Saugkompresse", order = 20),
CheckItemDefinition(id = "empfohlene_wundversorgung_sonstige_wundauflage", label = "Sonstiges (Wundauflage)", order = 21, isFreeText = true),
CheckItemDefinition(id = "empfohlene_wundversorgung_hinweis_fixieren_mit", label = "Fixieren mit:", order = 22, isStaticHint = true),
CheckItemDefinition(id = "empfohlene_wundversorgung_mullbinde", label = "Mullbinde", order = 23),
CheckItemDefinition(id = "empfohlene_wundversorgung_klebevlies", label = "Klebevlies", order = 24),
CheckItemDefinition(id = "empfohlene_wundversorgung_folienverband", label = "Folienverband", order = 25),
CheckItemDefinition(id = "empfohlene_wundversorgung_schlauchverband", label = "Schlauchverband", order = 26),
CheckItemDefinition(id = "empfohlene_wundversorgung_kohaesive_fixierbinde", label = "Kohäsive Fixierbinde", order = 27),
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_sekundaere_wundauflage", label = "Sekundäre Wundauflage:", order = 20, isStaticHint = true),
CheckItemDefinition(id = "empfohlene_wundversorgung_sekundaer_sterile_kompresse", label = "Sterile Kompresse", order = 21),
CheckItemDefinition(id = "empfohlene_wundversorgung_saugkompresse", label = "Saugkompresse", order = 22),
CheckItemDefinition(id = "empfohlene_wundversorgung_sonstige_wundauflage", label = "Sonstiges (Wundauflage)", order = 23, isFreeText = true),
CheckItemDefinition(id = "empfohlene_wundversorgung_hinweis_fixieren_mit", label = "Fixieren mit:", order = 24, isStaticHint = true),
CheckItemDefinition(id = "empfohlene_wundversorgung_mullbinde", label = "Mullbinde", order = 25),
CheckItemDefinition(id = "empfohlene_wundversorgung_klebevlies", label = "Klebevlies", order = 26),
CheckItemDefinition(id = "empfohlene_wundversorgung_folienverband", label = "Folienverband", order = 27),
CheckItemDefinition(id = "empfohlene_wundversorgung_schlauchverband", label = "Schlauchverband", order = 28),
CheckItemDefinition(id = "empfohlene_wundversorgung_kohaesive_fixierbinde", label = "Kohäsive Fixierbinde", order = 29),
CheckItemDefinition(id = "empfohlene_wundversorgung_kohaesive_fixierbinde_freitext", label = FREE_TEXT_LABEL, order = 30, isFreeText = true),
CheckItemDefinition(id = "empfohlene_wundversorgung_kompressionsverband_system", label = "Kompressionsverband / System", order = 31),
CheckItemDefinition(id = "empfohlene_wundversorgung_kompressionsverband_system_freitext", label = FREE_TEXT_LABEL, order = 32, isFreeText = true),
CheckItemDefinition(id = "empfohlene_wundversorgung_wechselintervall", label = "Wechselintervall", order = 33, isFreeText = true),
CheckItemDefinition(id = "empfohlene_wundversorgung_hinweis_bedarf", label = "+ bei Bedarf", order = 34, isStaticHint = true),
CheckItemDefinition(
id = "empfohlene_wundversorgung_hinweis_abklaerung",
label = "Generell: bei akuter Verschlechterung sofort ärztliche Abklärung",
order = 33,
order = 35,
isStaticHint = true,
),
),
@@ -573,8 +575,9 @@ 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_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),
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),
),
),
BlockDefinition(
@@ -2,13 +2,10 @@ 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
@@ -69,8 +66,6 @@ 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).
@@ -165,9 +160,6 @@ 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)
@@ -251,32 +243,6 @@ 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 {
@@ -320,37 +286,6 @@ 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 {
@@ -359,8 +294,6 @@ 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) {
@@ -397,37 +330,14 @@ 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 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
}
val labelText = if (item.isFreeText && item.freeTextValue.isNotBlank()) "${item.label}:" else item.label
cursor.drawText(
text = labelText,
paint = labelPaint,
paint = paints.item,
indent = ITEM_INDENT,
spacingAfter = 3f,
)
@@ -1,9 +1,6 @@
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
@@ -30,22 +27,20 @@ object DocumentationToPdfExportModelMapper {
.sortedBy { it.order }
.map { block ->
PdfExportBlock(
definitionId = block.definitionId,
title = block.title,
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
// Ä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
// (Filter oben) lässt nur Blöcke mit mindestens einer angehakten Option durch.
items = exportedItems(block).map { item ->
items = block.items
.filter { it.checked }
.sortedBy { it.order }
.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),
)
},
)
@@ -85,67 +80,4 @@ 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,12 +27,7 @@ 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>,
@@ -50,23 +45,8 @@ 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,15 +10,6 @@ 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
@@ -42,13 +33,8 @@ 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
@@ -88,15 +74,7 @@ internal class PdfPageCursor(
val page = pdfDocument.startPage(info)
currentPage = page
canvas = page.canvas
val headerHeight = pageHeaderDrawer?.draw(
canvas = page.canvas,
pageNumber = pageNumber,
left = marginLeft,
top = marginTop,
width = pageWidth - marginLeft - marginRight,
) ?: 0f
pageContentTop = marginTop + headerHeight
cursorY = pageContentTop
cursorY = marginTop
}
private fun finishCurrentPageIfOpen() {
@@ -120,15 +98,6 @@ 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
@@ -85,7 +85,6 @@ 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
@@ -908,25 +907,19 @@ 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) },
)
if (!hideName) Text(item.label)
Text(item.label)
}
OutlinedTextField(
value = item.freeTextValue,
onValueChange = { text -> onFreeTextChanged(item.checkItemStateId, text) },
enabled = item.checked,
label = fieldLabel,
label = { Text(stringResource(R.string.documentation_edit_free_text_label)) },
modifier = Modifier.fillMaxWidth().padding(start = 40.dp),
minLines = 2,
)
@@ -1,78 +0,0 @@
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)
}
}
@@ -172,156 +172,10 @@ class DocumentationToPdfExportModelMapperTest {
val items = DocumentationToPdfExportModelMapper.map(doc).blocks.single().items
// Generischer "Freitext": Bezeichnung wird nicht gedruckt (showLabel = false).
assertEquals(
PdfExportItem("Freitext", isFreeText = true, freeTextValue = "Rötung", inputSpec = "", showLabel = false),
items[0],
)
assertEquals(PdfExportItem("Freitext", isFreeText = true, freeTextValue = "Rötung", inputSpec = ""), 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