QA-001 #15

Merged
niklas.ortmann merged 2 commits from dev-QA001 into dev 2026-10-09 10:02:09 +02:00
9 changed files with 2144 additions and 0 deletions
@@ -0,0 +1,378 @@
package de.medapp.wunddoku.data.repository
import android.net.Uri
import androidx.test.ext.junit.runners.AndroidJUnit4
import de.medapp.wunddoku.data.entity.BlockStateEntity
import de.medapp.wunddoku.data.entity.CheckItemStateEntity
import de.medapp.wunddoku.data.entity.DocumentationEntity
import de.medapp.wunddoku.domain.catalog.DocumentationCatalog
import de.medapp.wunddoku.domain.model.CheckItem
import de.medapp.wunddoku.domain.model.Documentation
import de.medapp.wunddoku.domain.model.DocumentationStatus
import de.medapp.wunddoku.testutil.DuplicateCall
import de.medapp.wunddoku.testutil.TestEnvironment
import de.medapp.wunddoku.testutil.expectThrows
import de.medapp.wunddoku.testutil.firstPlainItem
import de.medapp.wunddoku.testutil.runDbTest
import de.medapp.wunddoku.testutil.signatureBitmap
import kotlinx.coroutines.flow.first
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import java.io.IOException
import java.time.LocalDate
/**
* Fortsetzen-Tests: "Dokumentation fortsetzen" erzeugt aus dem Stand einer vorhandenen
* Dokumentation eine NEUE; die Quelle bleibt unverändert.
*/
@RunWith(AndroidJUnit4::class)
class ContinueDocumentationTest {
private lateinit var env: TestEnvironment
private val repo get() = env.documentationRepository
@Before
fun setUp() {
env = TestEnvironment()
}
@After
fun tearDown() {
env.close()
}
/** Alle Items eines Dokuments als Map definitionId (inkl. Blockbezug) -> Item. */
private fun Documentation.itemsByKey(): Map<String, CheckItem> =
blocks.flatMap { block -> block.items.map { "${block.definitionId}/${it.definitionId}" to it } }.toMap()
/** Bereitet eine Quelle mit einigen Eingaben vor und liefert sie frisch geladen zurück. */
private suspend fun prepareSourceWithInput(): Documentation {
env.insertPatient()
val created = repo.createEmpty("patient-1")
val firstBlock = created.blocks.first()
repo.setChecked(firstBlock.firstPlainItem().checkItemStateId, true)
val freeText = firstBlock.items.last()
repo.setChecked(freeText.checkItemStateId, true)
repo.updateFreeText(freeText.checkItemStateId, "Rötung am Wundrand")
return repo.getFullDocumentation(created.id)!!
}
// --- Inhalt wird übernommen ----------------------------------------------------------
@Test
fun fortsetzen_uebernimmt_bloecke_und_optionen_inklusive_haken_und_freitext() = runDbTest {
val source = prepareSourceWithInput()
val copy = repo.continueDocumentation(source.id)
assertEquals(source.blocks.size, copy.blocks.size)
for (sourceBlock in source.blocks) {
val copyBlock = copy.blocks.single { it.definitionId == sourceBlock.definitionId }
assertEquals(sourceBlock.title, copyBlock.title)
assertEquals(sourceBlock.order, copyBlock.order)
assertEquals(sourceBlock.isEdited, copyBlock.isEdited)
}
// Option für Option: gleiche Inhalte (Label, Reihenfolge, Haken, Freitext, Hinweis, strukturierte Eingabe).
val sourceItems = source.itemsByKey()
val copyItems = copy.itemsByKey()
assertEquals(sourceItems.keys, copyItems.keys)
for ((key, sourceItem) in sourceItems) {
val copyItem = copyItems.getValue(key)
assertEquals(key, sourceItem.label, copyItem.label)
assertEquals(key, sourceItem.order, copyItem.order)
assertEquals(key, sourceItem.checked, copyItem.checked)
assertEquals(key, sourceItem.isFreeText, copyItem.isFreeText)
assertEquals(key, sourceItem.freeTextValue, copyItem.freeTextValue)
assertEquals(key, sourceItem.isStaticHint, copyItem.isStaticHint)
assertEquals(key, sourceItem.inputSpec, copyItem.inputSpec)
}
assertEquals(1, copy.blocks.count { it.isEdited })
assertEquals("Rötung am Wundrand", copy.blocks.first().items.last().freeTextValue)
}
@Test
fun fortsetzen_erzeugt_ausschliesslich_neue_ids() = runDbTest {
val source = prepareSourceWithInput()
val copy = repo.continueDocumentation(source.id)
assertNotEquals(source.id, copy.id)
val sourceBlockIds = source.blocks.map { it.blockStateId }.toSet()
val sourceItemIds = source.blocks.flatMap { b -> b.items.map { it.checkItemStateId } }.toSet()
assertTrue(copy.blocks.none { it.blockStateId in sourceBlockIds })
assertTrue(copy.blocks.flatMap { b -> b.items.map { it.checkItemStateId } }.none { it in sourceItemIds })
}
@Test
fun fortsetzen_setzt_metadaten_der_neuen_dokumentation_korrekt() = runDbTest {
val source = prepareSourceWithInput()
env.setStatus(source.id, DocumentationStatus.COMPLETED)
val copy = repo.continueDocumentation(source.id)
assertEquals(DocumentationStatus.IN_PROGRESS, copy.status)
assertEquals(source.id, copy.continuedFromDocumentationId)
assertEquals(source.patientId, copy.patientId)
assertEquals(copy.createdAt, copy.updatedAt)
assertEquals(DocumentationStatus.COMPLETED, repo.getFullDocumentation(source.id)!!.status)
assertEquals(2, env.db.documentationDao().countForPatient("patient-1"))
assertTrue(env.patientRepository.hasDocumentations("patient-1"))
}
// --- Quelle bleibt unverändert / Unabhängigkeit --------------------------------------
@Test
fun fortsetzen_laesst_die_quelldokumentation_unveraendert() = runDbTest {
val source = prepareSourceWithInput()
repo.continueDocumentation(source.id)
val sourceAfter = repo.getFullDocumentation(source.id)!!
assertEquals(source.status, sourceAfter.status)
assertEquals(source.createdAt, sourceAfter.createdAt)
assertEquals(source.updatedAt, sourceAfter.updatedAt)
assertNull(sourceAfter.continuedFromDocumentationId)
assertEquals(source.blocks, sourceAfter.blocks)
assertEquals(source.photos, sourceAfter.photos)
assertEquals(source.signatures, sourceAfter.signatures)
}
@Test
fun aenderungen_an_der_kopie_veraendern_die_quelle_nicht_und_umgekehrt() = runDbTest {
val source = prepareSourceWithInput()
val copy = repo.continueDocumentation(source.id)
// Kopie: den in der Quelle gesetzten Haken entfernen und Freitext ändern.
val copyFirstBlock = copy.blocks.first()
repo.setChecked(copyFirstBlock.firstPlainItem().checkItemStateId, false)
repo.updateFreeText(copyFirstBlock.items.last().checkItemStateId, "Neuer Text")
val sourceAfter = repo.getFullDocumentation(source.id)!!
assertTrue(sourceAfter.blocks.first().firstPlainItem().checked)
assertEquals("Rötung am Wundrand", sourceAfter.blocks.first().items.last().freeTextValue)
// Quelle: weiteren Haken in einem anderen Block setzen.
val otherSourceBlock = source.blocks[1]
repo.setChecked(otherSourceBlock.firstPlainItem().checkItemStateId, true)
val copyAfter = repo.getFullDocumentation(copy.id)!!
assertFalse(copyAfter.blocks.single { it.definitionId == otherSourceBlock.definitionId }.isEdited)
assertEquals("Neuer Text", copyAfter.blocks.first().items.last().freeTextValue)
}
// --- Patienten-Snapshot --------------------------------------------------------------
@Test
fun fortsetzen_bildet_den_patienten_snapshot_frisch_aus_dem_aktuellen_patienten() = runDbTest {
val source = prepareSourceWithInput()
val patientDao = env.db.patientDao()
patientDao.update(
patientDao.getById("patient-1")!!.copy(
lastName = "Musterfrau", street = "Neue Straße 7", postalCode = "99999", city = "Neustadt",
)
)
val copy = repo.continueDocumentation(source.id)
assertEquals("Musterfrau", copy.patientSnapshot.lastName)
assertEquals("Neue Straße 7", copy.patientSnapshot.street)
assertEquals("99999", copy.patientSnapshot.postalCode)
assertEquals("Neustadt", copy.patientSnapshot.city)
assertEquals(LocalDate.of(1980, 3, 14), copy.patientSnapshot.birthDate)
// Die Quelle behält ihren damaligen Stand.
val sourceSnapshot = repo.getFullDocumentation(source.id)!!.patientSnapshot
assertEquals("Mustermann", sourceSnapshot.lastName)
assertEquals("Musterstraße 1", sourceSnapshot.street)
assertEquals("Musterstadt", sourceSnapshot.city)
}
// --- Historische Snapshots / Katalogversion ------------------------------------------
@Test
fun fortsetzen_bewahrt_historische_titel_labels_und_katalogversion() = runDbTest {
env.insertPatient()
val now = 5_000L
val historicDocId = "historic-doc"
env.db.documentationDao().insert(
DocumentationEntity(
id = historicDocId, patientId = "patient-1", status = DocumentationStatus.COMPLETED,
createdAt = now, updatedAt = now, continuedFromDocumentationId = null, pdfLastGeneratedAt = null,
catalogVersion = 1,
patientFirstNameSnapshot = "Max", patientLastNameSnapshot = "Mustermann",
patientBirthDateSnapshot = "1980-03-14", patientStreetSnapshot = "Musterstraße 1",
patientPostalCodeSnapshot = "12345", patientCitySnapshot = "Musterstadt",
)
)
env.db.blockStateDao().insertAll(
listOf(BlockStateEntity("hist-block", historicDocId, "gibt_es_nicht_mehr", "Alter Titel", 1, now))
)
env.db.checkItemStateDao().insertAll(
listOf(
CheckItemStateEntity("hist-item", "hist-block", "alter_eintrag", "Alter Eintrag", 1, checked = true),
)
)
assertNotEquals("Voraussetzung: aktuelle Katalogversion weicht ab", 1, DocumentationCatalog.CATALOG_VERSION)
val copy = repo.continueDocumentation(historicDocId)
assertEquals(1, copy.catalogVersion)
val block = copy.blocks.single()
assertEquals("gibt_es_nicht_mehr", block.definitionId)
assertEquals("Alter Titel", block.title)
assertEquals("Alter Eintrag", block.items.single().label)
assertTrue(block.items.single().checked)
}
// --- Unterschriften ------------------------------------------------------------------
@Test
fun fortsetzen_kopiert_keine_unterschriften() = runDbTest {
val source = prepareSourceWithInput()
repo.saveSignature(source.id, "Anna Schmidt", signatureBitmap())
val copy = repo.continueDocumentation(source.id)
assertTrue(copy.signatures.isEmpty())
assertEquals(1, repo.getFullDocumentation(source.id)!!.signatures.size)
assertEquals(1, env.signatureStorage.saved.size) // keine zusätzliche Datei angelegt
}
// --- Fotos ---------------------------------------------------------------------------
@Test
fun fortsetzen_kopiert_fotos_standardmaessig_als_eigenstaendige_dateien() = runDbTest {
val source = prepareSourceWithInput()
val p1 = repo.addPhoto(source.id, Uri.parse("content://test/1"))
val p2 = repo.addPhoto(source.id, Uri.parse("content://test/2"))
val copy = repo.continueDocumentation(source.id)
assertEquals(listOf(0, 1), copy.photos.map { it.orderIndex })
assertTrue(copy.photos.none { it.id == p1.id || it.id == p2.id })
assertTrue("Dateien müssen physisch eigenständig sein", copy.photos.none { it.fileName == p1.fileName || it.fileName == p2.fileName })
assertTrue(copy.photos.all { it.documentationId == copy.id })
assertEquals(
setOf(
DuplicateCall(source.id, p1.fileName, copy.id),
DuplicateCall(source.id, p2.fileName, copy.id),
),
env.photoStorage.duplicated.toSet(),
)
assertEquals(2, env.photoStorage.duplicated.size)
// Quelle behält ihre Fotos.
assertEquals(listOf(p1.fileName, p2.fileName), repo.getFullDocumentation(source.id)!!.photos.map { it.fileName })
}
@Test
fun fortsetzen_mit_copyPhotos_false_uebernimmt_keine_fotos() = runDbTest {
val source = prepareSourceWithInput()
repo.addPhoto(source.id, Uri.parse("content://test/1"))
val copy = repo.continueDocumentation(source.id, copyPhotos = false)
assertTrue(copy.photos.isEmpty())
assertTrue(env.photoStorage.duplicated.isEmpty())
}
@Test
fun foto_loeschen_in_der_kopie_betrifft_die_quelle_nicht() = runDbTest {
val source = prepareSourceWithInput()
repo.addPhoto(source.id, Uri.parse("content://test/1"))
val copy = repo.continueDocumentation(source.id)
repo.removePhoto(copy.photos.single().id)
assertTrue(repo.getFullDocumentation(copy.id)!!.photos.isEmpty())
assertEquals(1, repo.getFullDocumentation(source.id)!!.photos.size)
}
// --- Fehlerfälle / Atomarität --------------------------------------------------------
@Test
fun fortsetzen_einer_unbekannten_dokumentation_wirft_und_legt_nichts_an() = runDbTest {
env.insertPatient()
val error = expectThrows<IllegalStateException> { repo.continueDocumentation("unbekannt") }
assertTrue(error.message.orEmpty().contains("unbekannt"))
assertTrue(env.db.documentationDao().observeAll().first().isEmpty())
}
@Test
fun fortsetzen_ist_atomar_ein_fehler_beim_foto_kopieren_hinterlaesst_keine_halbe_dokumentation() = runDbTest {
val source = prepareSourceWithInput()
repo.addPhoto(source.id, Uri.parse("content://test/1"))
repo.addPhoto(source.id, Uri.parse("content://test/2"))
env.photoStorage.failOnDuplicateCall = 2 // zweites Foto schlägt fehl
expectThrows<IOException> { repo.continueDocumentation(source.id) }
val all = env.db.documentationDao().observeAll().first()
assertEquals(listOf(source.id), all.map { it.id })
assertEquals(1, env.db.documentationDao().countForPatient("patient-1"))
}
@Test
fun loeschen_der_quelle_laesst_die_fortgesetzte_dokumentation_bestehen() = runDbTest {
val source = prepareSourceWithInput()
repo.addPhoto(source.id, Uri.parse("content://test/1"))
val copy = repo.continueDocumentation(source.id)
repo.delete(source.id)
val copyAfter = repo.getFullDocumentation(copy.id)!!
assertNull("Verweis auf die gelöschte Quelle wird auf null gesetzt", copyAfter.continuedFromDocumentationId)
assertEquals(copy.blocks.size, copyAfter.blocks.size)
assertEquals(1, copyAfter.photos.size)
assertEquals(listOf(source.id), env.photoStorage.deletedAll) // nur der Ordner der Quelle
}
@Test
fun mehrfaches_fortsetzen_bildet_eine_kette() = runDbTest {
val first = prepareSourceWithInput()
val second = repo.continueDocumentation(first.id)
val third = repo.continueDocumentation(second.id)
assertEquals(first.id, second.continuedFromDocumentationId)
assertEquals(second.id, third.continuedFromDocumentationId)
assertEquals(1, third.blocks.count { it.isEdited })
assertEquals(3, env.db.documentationDao().countForPatient("patient-1"))
}
// --- continueLatestForPatient --------------------------------------------------------
@Test
fun continueLatestForPatient_liefert_null_wenn_der_patient_noch_keine_dokumentation_hat() = runDbTest {
env.insertPatient()
assertNull(repo.continueLatestForPatient("patient-1"))
assertTrue(env.db.documentationDao().observeAll().first().isEmpty())
}
@Test
fun continueLatestForPatient_setzt_die_zuletzt_erstellte_dokumentation_des_patienten_fort() = runDbTest {
env.insertPatient()
env.insertPatient(id = "patient-2", firstName = "Erika", lastName = "Musterfrau")
val older = repo.createEmpty("patient-1")
val newer = repo.createEmpty("patient-1")
val otherPatientsDoc = repo.createEmpty("patient-2")
env.setCreatedAt(older.id, 1_000L)
env.setCreatedAt(newer.id, 2_000L)
env.setCreatedAt(otherPatientsDoc.id, 3_000L) // neuer, aber anderer Patient
repo.setChecked(newer.blocks.first().firstPlainItem().checkItemStateId, true)
val copy = repo.continueLatestForPatient("patient-1")!!
assertEquals(newer.id, copy.continuedFromDocumentationId)
assertEquals("patient-1", copy.patientId)
assertEquals(1, copy.blocks.count { it.isEdited })
}
}
@@ -0,0 +1,545 @@
package de.medapp.wunddoku.data.repository
import android.net.Uri
import androidx.test.ext.junit.runners.AndroidJUnit4
import de.medapp.wunddoku.domain.catalog.DocumentationCatalog
import de.medapp.wunddoku.domain.model.DocumentationStatus
import de.medapp.wunddoku.domain.model.InputSpecCodec
import de.medapp.wunddoku.pdf.PdfExportError
import de.medapp.wunddoku.pdf.PdfExportResult
import de.medapp.wunddoku.pdf.PdfToImagesResult
import de.medapp.wunddoku.testutil.TestEnvironment
import de.medapp.wunddoku.testutil.blankBitmap
import de.medapp.wunddoku.testutil.expectThrows
import de.medapp.wunddoku.testutil.firstPlainItem
import de.medapp.wunddoku.testutil.runDbTest
import de.medapp.wunddoku.testutil.signatureBitmap
import kotlinx.coroutines.flow.first
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import java.io.File
import java.time.LocalDate
import java.time.ZoneId
/**
* Repository-Tests für [DocumentationRepository] gegen eine echte In-Memory-Room-Datenbank.
* Dateispeicher und PDF-Erzeugung sind durch Fakes ersetzt (s. testutil/Fakes.kt).
* "Fortsetzen" wird separat in [ContinueDocumentationTest] getestet.
*/
@RunWith(AndroidJUnit4::class)
class DocumentationRepositoryTest {
private lateinit var env: TestEnvironment
private val repo get() = env.documentationRepository
@Before
fun setUp() {
env = TestEnvironment()
}
@After
fun tearDown() {
env.close()
}
// --- createEmpty ---------------------------------------------------------------------
@Test
fun createEmpty_legt_neue_dokumentation_im_status_in_bearbeitung_an() = runDbTest {
env.insertPatient()
val doc = repo.createEmpty("patient-1")
assertEquals("patient-1", doc.patientId)
assertEquals(DocumentationStatus.IN_PROGRESS, doc.status)
assertNull(doc.continuedFromDocumentationId)
assertEquals(DocumentationCatalog.CATALOG_VERSION, doc.catalogVersion)
assertEquals(doc.createdAt, doc.updatedAt)
assertTrue(doc.photos.isEmpty())
assertTrue(doc.signatures.isEmpty())
assertEquals(doc.id, repo.getFullDocumentation(doc.id)?.id)
}
@Test
fun createEmpty_uebernimmt_den_patienten_snapshot() = runDbTest {
env.insertPatient(
firstName = "Erika", lastName = "Musterfrau", birthDate = "1975-12-01",
street = "Hauptstraße 5", postalCode = "54321", city = "Beispielstadt",
)
val snapshot = repo.createEmpty("patient-1").patientSnapshot
assertEquals("Erika", snapshot.firstName)
assertEquals("Musterfrau", snapshot.lastName)
assertEquals(LocalDate.of(1975, 12, 1), snapshot.birthDate)
assertEquals("Hauptstraße 5", snapshot.street)
assertEquals("54321", snapshot.postalCode)
assertEquals("Beispielstadt", snapshot.city)
}
@Test
fun createEmpty_erzeugt_pro_katalogblock_einen_block_mit_titel_und_reihenfolge_snapshot() = runDbTest {
env.insertPatient()
val doc = repo.createEmpty("patient-1")
assertEquals(DocumentationCatalog.blocks.size, doc.blocks.size)
for (def in DocumentationCatalog.blocks) {
val block = doc.blocks.single { it.definitionId == def.id }
assertEquals(def.title, block.title)
assertEquals(def.order, block.order)
}
}
@Test
fun createEmpty_haengt_pro_block_die_generische_freitext_option_als_letztes_element_an() = runDbTest {
env.insertPatient()
val doc = repo.createEmpty("patient-1")
for (def in DocumentationCatalog.blocks) {
val block = doc.blocks.single { it.definitionId == def.id }
assertEquals(def.items.size + 1, block.items.size)
val generic = block.items.last()
assertEquals(def.freeTextDefinitionId, generic.definitionId)
assertEquals(DocumentationCatalog.FREE_TEXT_LABEL, generic.label)
assertEquals((def.items.maxOfOrNull { it.order } ?: 0) + 1, generic.order)
assertTrue(generic.isFreeText)
assertFalse(generic.isStaticHint)
assertFalse(generic.checked)
assertEquals("", generic.freeTextValue)
}
}
@Test
fun createEmpty_uebernimmt_katalogeintraege_1_zu_1_und_hinweistexte_starten_angehakt() = runDbTest {
env.insertPatient()
val doc = repo.createEmpty("patient-1")
for (def in DocumentationCatalog.blocks) {
val block = doc.blocks.single { it.definitionId == def.id }
for (itemDef in def.items) {
val state = block.items.single { it.definitionId == itemDef.id }
assertEquals(itemDef.label, state.label)
assertEquals(itemDef.order, state.order)
assertEquals(itemDef.isFreeText, state.isFreeText)
assertEquals(itemDef.isStaticHint, state.isStaticHint)
// Nur Hinweistexte starten angehakt, alles andere unangehakt/leer.
assertEquals(itemDef.isStaticHint, state.checked)
assertEquals("", state.freeTextValue)
val expectedSpec = itemDef.inputSpec?.let(InputSpecCodec::encode) ?: ""
assertEquals(expectedSpec, state.inputSpec)
}
}
}
@Test
fun createEmpty_ergibt_keinen_bearbeiteten_block_auch_nicht_durch_hinweistexte() = runDbTest {
env.insertPatient()
val doc = repo.createEmpty("patient-1")
assertTrue(doc.blocks.none { it.isEdited })
}
@Test
fun createEmpty_vergibt_fuer_jede_dokumentation_neue_ids() = runDbTest {
env.insertPatient()
val first = repo.createEmpty("patient-1")
val second = repo.createEmpty("patient-1")
assertTrue(first.id != second.id)
val allBlockIds = (first.blocks + second.blocks).map { it.blockStateId }
assertEquals(allBlockIds.size, allBlockIds.toSet().size)
assertEquals(2, env.db.documentationDao().countForPatient("patient-1"))
}
@Test
fun createEmpty_mit_unbekanntem_patienten_wirft_und_legt_nichts_an() = runDbTest {
val error = expectThrows<IllegalStateException> { repo.createEmpty("gibt-es-nicht") }
assertTrue(error.message.orEmpty().contains("gibt-es-nicht"))
assertTrue(env.db.documentationDao().observeAll().first().isEmpty())
}
// --- Lesen / Liste / Suche -----------------------------------------------------------
@Test
fun getFullDocumentation_liefert_null_fuer_unbekannte_id() = runDbTest {
assertNull(repo.getFullDocumentation("unbekannt"))
}
@Test
fun observeAll_liefert_neueste_dokumentation_zuerst_mit_snapshot_namen() = runDbTest {
env.insertPatient()
val older = repo.createEmpty("patient-1")
val newer = repo.createEmpty("patient-1")
env.setCreatedAt(older.id, 1_000L)
env.setCreatedAt(newer.id, 2_000L)
val list = repo.observeAll().first()
assertEquals(listOf(newer.id, older.id), list.map { it.id })
assertEquals("Max Mustermann", list.first().patientFullName)
assertEquals(DocumentationStatus.IN_PROGRESS, list.first().status)
}
@Test
fun search_sucht_gegen_den_aktuellen_patientennamen_zeigt_aber_den_snapshot_namen() = runDbTest {
env.insertPatient()
val doc = repo.createEmpty("patient-1")
val patientDao = env.db.patientDao()
patientDao.update(patientDao.getById("patient-1")!!.copy(lastName = "Musterfrau"))
val found = repo.search("Musterfrau").first()
assertEquals(listOf(doc.id), found.map { it.id })
assertEquals("Max Mustermann", found.single().patientFullName)
assertTrue(repo.search("Mustermann").first().isEmpty())
}
@Test
fun search_findet_auch_ueber_vornamen_und_geburtsdatum() = runDbTest {
env.insertPatient()
val doc = repo.createEmpty("patient-1")
assertEquals(listOf(doc.id), repo.search("Ma").first().map { it.id })
assertEquals(listOf(doc.id), repo.search("1980-03").first().map { it.id })
assertTrue(repo.search("Zzz").first().isEmpty())
}
// --- Autosave / Blockstatus ----------------------------------------------------------
@Test
fun setChecked_persistiert_und_macht_den_block_bearbeitet() = runDbTest {
env.insertPatient()
val doc = repo.createEmpty("patient-1")
val block = doc.blocks.first()
val item = block.firstPlainItem()
repo.setChecked(item.checkItemStateId, true)
val reloaded = repo.getFullDocumentation(doc.id)!!
val reloadedBlock = reloaded.blocks.single { it.blockStateId == block.blockStateId }
assertTrue(reloadedBlock.items.single { it.checkItemStateId == item.checkItemStateId }.checked)
assertTrue(reloadedBlock.isEdited)
assertEquals(1, reloaded.blocks.count { it.isEdited })
}
@Test
fun setChecked_false_nimmt_den_bearbeitet_status_wieder_zurueck() = runDbTest {
env.insertPatient()
val doc = repo.createEmpty("patient-1")
val block = doc.blocks.first()
val item = block.firstPlainItem()
repo.setChecked(item.checkItemStateId, true)
repo.setChecked(item.checkItemStateId, false)
val reloaded = repo.getFullDocumentation(doc.id)!!
assertTrue(reloaded.blocks.none { it.isEdited })
}
@Test
fun freitext_wird_gespeichert_und_zaehlt_erst_mit_haken_als_bearbeitung() = runDbTest {
env.insertPatient()
val doc = repo.createEmpty("patient-1")
val block = doc.blocks.first()
val freeText = block.items.last()
val text = "Größe 3,5 × 2 cm\nzweite Zeile – Rötung"
repo.updateFreeText(freeText.checkItemStateId, text)
val textOnly = repo.getFullDocumentation(doc.id)!!
assertEquals(text, textOnly.blocks.single { it.blockStateId == block.blockStateId }.items.last().freeTextValue)
assertTrue("Freitext ohne Haken darf den Block nicht bearbeitet machen", textOnly.blocks.none { it.isEdited })
repo.setChecked(freeText.checkItemStateId, true)
val checked = repo.getFullDocumentation(doc.id)!!
val reloadedBlock = checked.blocks.single { it.blockStateId == block.blockStateId }
assertTrue(reloadedBlock.items.last().checked)
assertEquals(text, reloadedBlock.items.last().freeTextValue)
assertTrue(reloadedBlock.isEdited)
}
@Test
fun aenderungen_in_einer_dokumentation_beruehren_eine_andere_nicht() = runDbTest {
env.insertPatient()
val first = repo.createEmpty("patient-1")
val second = repo.createEmpty("patient-1")
repo.setChecked(first.blocks.first().firstPlainItem().checkItemStateId, true)
assertTrue(repo.getFullDocumentation(second.id)!!.blocks.none { it.isEdited })
}
// --- Fotos ---------------------------------------------------------------------------
@Test
fun addPhoto_vergibt_fortlaufenden_orderIndex_und_speichert_den_storage_dateinamen() = runDbTest {
env.insertPatient()
val doc = repo.createEmpty("patient-1")
val uri1 = Uri.parse("content://test/1")
val uri2 = Uri.parse("content://test/2")
val p1 = repo.addPhoto(doc.id, uri1)
val p2 = repo.addPhoto(doc.id, uri2)
assertEquals(0, p1.orderIndex)
assertEquals(1, p2.orderIndex)
assertEquals(listOf(doc.id to uri1, doc.id to uri2), env.photoStorage.imported)
val photos = repo.getFullDocumentation(doc.id)!!.photos
assertEquals(listOf(p1.fileName, p2.fileName), photos.map { it.fileName })
assertEquals(listOf(0, 1), photos.map { it.orderIndex })
}
@Test
fun removePhoto_loescht_db_zeile_und_datei_und_vergibt_orderIndex_nicht_neu() = runDbTest {
env.insertPatient()
val doc = repo.createEmpty("patient-1")
val p1 = repo.addPhoto(doc.id, Uri.parse("content://test/1"))
val p2 = repo.addPhoto(doc.id, Uri.parse("content://test/2"))
repo.removePhoto(p1.id)
assertEquals(listOf(p2.id), repo.getFullDocumentation(doc.id)!!.photos.map { it.id })
assertEquals(listOf(doc.id to p1.fileName), env.photoStorage.deleted)
val p3 = repo.addPhoto(doc.id, Uri.parse("content://test/3"))
assertEquals(2, p3.orderIndex)
}
@Test
fun removePhoto_mit_unbekannter_id_tut_nichts() = runDbTest {
env.insertPatient()
repo.createEmpty("patient-1")
repo.removePhoto("unbekannt")
assertTrue(env.photoStorage.deleted.isEmpty())
}
@Test
fun removePhoto_entfernt_die_db_zeile_auch_wenn_die_datei_nicht_geloescht_werden_kann() = runDbTest {
env.insertPatient()
val doc = repo.createEmpty("patient-1")
val photo = repo.addPhoto(doc.id, Uri.parse("content://test/1"))
env.photoStorage.failOnDelete = true
repo.removePhoto(photo.id)
assertTrue(repo.getFullDocumentation(doc.id)!!.photos.isEmpty())
}
// --- Signaturen ----------------------------------------------------------------------
@Test
fun saveSignature_trimmt_den_namen_und_vergibt_fortlaufenden_orderIndex() = runDbTest {
env.insertPatient()
val doc = repo.createEmpty("patient-1")
val first = repo.saveSignature(doc.id, " Anna Schmidt ", signatureBitmap())
val second = repo.saveSignature(doc.id, "Bernd Beispiel", signatureBitmap())
assertEquals("Anna Schmidt", first.signerName)
assertEquals(0, first.orderIndex)
assertEquals(1, second.orderIndex)
val signatures = repo.getFullDocumentation(doc.id)!!.signatures
assertEquals(listOf("Anna Schmidt", "Bernd Beispiel"), signatures.map { it.signerName })
assertEquals(listOf(first.fileName, second.fileName), signatures.map { it.fileName })
assertEquals(listOf(doc.id to first.fileName, doc.id to second.fileName), env.signatureStorage.saved)
}
@Test
fun saveSignature_aktualisiert_das_wundversorger_register() = runDbTest {
env.insertPatient()
val doc = repo.createEmpty("patient-1")
repo.saveSignature(doc.id, "Anna Schmidt", signatureBitmap())
assertEquals("Anna Schmidt", env.woundCarerRepository.getMostRecentlyUsed()?.name)
assertEquals(1, env.woundCarerSignatureStorage.saved.size)
}
@Test
fun saveSignature_mit_leerem_namen_wirft_und_hinterlaesst_keine_datei() = runDbTest {
env.insertPatient()
val doc = repo.createEmpty("patient-1")
expectThrows<IllegalArgumentException> { repo.saveSignature(doc.id, " ", signatureBitmap()) }
assertTrue(env.signatureStorage.saved.isEmpty())
assertTrue(env.db.signatureDao().getForDocumentation(doc.id).isEmpty())
assertNull(env.woundCarerRepository.getMostRecentlyUsed())
}
@Test
fun saveSignature_mit_leerer_zeichnung_wirft_und_hinterlaesst_keine_datei() = runDbTest {
env.insertPatient()
val doc = repo.createEmpty("patient-1")
expectThrows<IllegalArgumentException> { repo.saveSignature(doc.id, "Anna Schmidt", blankBitmap()) }
assertTrue(env.signatureStorage.saved.isEmpty())
assertTrue(env.db.signatureDao().getForDocumentation(doc.id).isEmpty())
assertNull(env.woundCarerRepository.getMostRecentlyUsed())
}
@Test
fun removeSignature_loescht_db_zeile_und_datei() = runDbTest {
env.insertPatient()
val doc = repo.createEmpty("patient-1")
val signature = repo.saveSignature(doc.id, "Anna Schmidt", signatureBitmap())
repo.removeSignature(signature.id)
assertTrue(repo.getFullDocumentation(doc.id)!!.signatures.isEmpty())
assertEquals(listOf(doc.id to signature.fileName), env.signatureStorage.deleted)
}
@Test
fun removeSignature_mit_unbekannter_id_tut_nichts_und_toleriert_storage_fehler() = runDbTest {
env.insertPatient()
val doc = repo.createEmpty("patient-1")
repo.removeSignature("unbekannt")
assertTrue(env.signatureStorage.deleted.isEmpty())
val signature = repo.saveSignature(doc.id, "Anna Schmidt", signatureBitmap())
env.signatureStorage.failOnDelete = true
repo.removeSignature(signature.id)
assertTrue(repo.getFullDocumentation(doc.id)!!.signatures.isEmpty())
}
// --- Löschen -------------------------------------------------------------------------
@Test
fun delete_entfernt_dokumentation_samt_bloecken_fotos_und_signaturen_sowie_dateiordner() = runDbTest {
env.insertPatient()
val doc = repo.createEmpty("patient-1")
repo.addPhoto(doc.id, Uri.parse("content://test/1"))
repo.saveSignature(doc.id, "Anna Schmidt", signatureBitmap())
val blockStateIds = doc.blocks.map { it.blockStateId }
repo.delete(doc.id)
assertNull(repo.getFullDocumentation(doc.id))
assertTrue(env.db.blockStateDao().getForDocumentation(doc.id).isEmpty())
assertTrue(env.db.photoDao().getForDocumentation(doc.id).isEmpty())
assertTrue(env.db.signatureDao().getForDocumentation(doc.id).isEmpty())
for (blockStateId in blockStateIds) {
assertTrue(env.db.checkItemStateDao().getForBlockState(blockStateId).isEmpty())
}
assertEquals(listOf(doc.id), env.photoStorage.deletedAll)
assertEquals(listOf(doc.id), env.signatureStorage.deletedAll)
assertNotNull("Der Patient darf nicht mitgelöscht werden", env.patientRepository.getById("patient-1"))
}
@Test
fun delete_beruehrt_das_wundversorger_register_nicht() = runDbTest {
env.insertPatient()
val doc = repo.createEmpty("patient-1")
repo.saveSignature(doc.id, "Anna Schmidt", signatureBitmap())
repo.delete(doc.id)
assertEquals("Anna Schmidt", env.woundCarerRepository.getMostRecentlyUsed()?.name)
assertTrue(env.woundCarerSignatureStorage.deletedAll.isEmpty())
assertTrue(env.woundCarerSignatureStorage.deleted.isEmpty())
}
@Test
fun delete_mit_unbekannter_id_tut_nichts() = runDbTest {
repo.delete("unbekannt")
assertTrue(env.photoStorage.deletedAll.isEmpty())
assertTrue(env.signatureStorage.deletedAll.isEmpty())
}
@Test
fun delete_entfernt_die_dokumentation_auch_wenn_dateiordner_nicht_geloescht_werden_koennen() = runDbTest {
env.insertPatient()
val doc = repo.createEmpty("patient-1")
env.photoStorage.failOnDeleteAll = true
env.signatureStorage.failOnDeleteAll = true
repo.delete(doc.id)
assertNull(repo.getFullDocumentation(doc.id))
}
// --- PDF-Export ----------------------------------------------------------------------
@Test
fun exportPdf_fuer_unbekannte_dokumentation_liefert_fachlichen_fehler_ohne_exporter_aufruf() = runDbTest {
val result = repo.exportPdf("unbekannt")
assertEquals(PdfExportResult.Failure(PdfExportError.DocumentationNotFound), result)
assertTrue(env.pdfExporter.models.isEmpty())
}
@Test
fun exportPdf_uebergibt_nur_bearbeitete_bloecke_und_den_schemakonformen_dateinamen() = runDbTest {
env.insertPatient()
val doc = repo.createEmpty("patient-1")
val block = doc.blocks.first()
val item = block.firstPlainItem()
repo.setChecked(item.checkItemStateId, true)
val result = repo.exportPdf(doc.id)
assertTrue(result is PdfExportResult.Success)
val model = env.pdfExporter.models.single()
assertEquals(doc.id, model.documentationId)
assertEquals(listOf(block.title), model.blocks.map { it.title })
assertTrue(model.blocks.single().items.any { it.label == item.label })
val createdDate = doc.createdAt.atZone(ZoneId.systemDefault()).toLocalDate()
assertEquals("${createdDate}_Max_Mustermann_1980-03-14.pdf", model.fileName)
}
@Test
fun exportPdf_verwendet_den_snapshot_und_nicht_den_aktuellen_patientendatensatz() = runDbTest {
env.insertPatient()
val doc = repo.createEmpty("patient-1")
val patientDao = env.db.patientDao()
patientDao.update(patientDao.getById("patient-1")!!.copy(lastName = "Musterfrau", city = "Neustadt"))
repo.exportPdf(doc.id)
val model = env.pdfExporter.models.single()
assertEquals("Max Mustermann", model.patientFullName)
assertEquals("Musterstraße 1, 12345 Musterstadt", model.patientDisplayAddress)
}
@Test
fun exportPdf_reicht_das_exporter_ergebnis_unveraendert_durch() = runDbTest {
env.insertPatient()
val doc = repo.createEmpty("patient-1")
val failure = PdfExportResult.Failure(PdfExportError.WriteFailed("Speicher voll"))
env.pdfExporter.result = { failure }
assertEquals(failure, repo.exportPdf(doc.id))
}
@Test
fun convertPdfToImages_delegiert_an_den_converter() = runDbTest {
val pdf = File("/fake/pdfs/test.pdf")
env.pdfToImagesConverter.result = PdfToImagesResult.Failure.FileNotFound
val result = repo.convertPdfToImages("doc-1", pdf)
assertEquals(PdfToImagesResult.Failure.FileNotFound, result)
assertEquals(listOf("doc-1" to pdf), env.pdfToImagesConverter.calls)
}
}
@@ -0,0 +1,223 @@
package de.medapp.wunddoku.data.repository
import android.database.sqlite.SQLiteConstraintException
import androidx.test.ext.junit.runners.AndroidJUnit4
import de.medapp.wunddoku.domain.model.Patient
import de.medapp.wunddoku.testutil.TestEnvironment
import de.medapp.wunddoku.testutil.expectThrows
import de.medapp.wunddoku.testutil.runDbTest
import kotlinx.coroutines.flow.first
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import java.time.Instant
import java.time.LocalDate
import java.util.UUID
/** Repository-Tests für [PatientRepository] gegen eine echte In-Memory-Room-Datenbank. */
@RunWith(AndroidJUnit4::class)
class PatientRepositoryTest {
private lateinit var env: TestEnvironment
private val repo get() = env.patientRepository
@Before
fun setUp() {
env = TestEnvironment()
}
@After
fun tearDown() {
env.close()
}
private fun patient(
id: String = "p-1",
firstName: String = "Max",
lastName: String = "Mustermann",
birthDate: LocalDate = LocalDate.of(1980, 3, 14),
) = Patient(
id = id,
firstName = firstName,
lastName = lastName,
birthDate = birthDate,
street = "Musterstraße 1",
postalCode = "12345",
city = "Musterstadt",
phone1 = "0123 456789",
phone2 = "0987 654321",
phone3 = null,
createdAt = Instant.EPOCH,
updatedAt = Instant.EPOCH,
)
// --- upsert / getById ----------------------------------------------------------------
@Test
fun upsert_legt_neuen_patienten_an_und_setzt_zeitstempel_zentral() = runDbTest {
val before = Instant.now().toEpochMilli()
val saved = repo.upsert(patient())
assertEquals(saved.createdAt, saved.updatedAt)
assertTrue(saved.createdAt.toEpochMilli() >= before)
val loaded = repo.getById("p-1")!!
assertEquals("Max", loaded.firstName)
assertEquals("Mustermann", loaded.lastName)
assertEquals(LocalDate.of(1980, 3, 14), loaded.birthDate)
assertEquals("Musterstraße 1", loaded.street)
assertEquals("12345", loaded.postalCode)
assertEquals("Musterstadt", loaded.city)
assertEquals("0123 456789", loaded.phone1)
assertEquals("0987 654321", loaded.phone2)
assertNull(loaded.phone3)
// Die Datenbank speichert Millisekunden, upsert() liefert Instant.now() mit höherer Genauigkeit.
assertEquals(saved.createdAt.toEpochMilli(), loaded.createdAt.toEpochMilli())
}
@Test
fun upsert_aktualisiert_bestehenden_patienten_und_behaelt_createdAt() = runDbTest {
val first = repo.upsert(patient())
// createdAt im übergebenen Objekt wird bewusst ignoriert (Instant.EPOCH aus dem Helper).
val second = repo.upsert(patient(lastName = "Musterfrau"))
assertEquals(first.createdAt.toEpochMilli(), second.createdAt.toEpochMilli())
assertTrue(second.updatedAt.toEpochMilli() >= first.updatedAt.toEpochMilli())
val loaded = repo.getById("p-1")!!
assertEquals("Musterfrau", loaded.lastName)
assertEquals(first.createdAt.toEpochMilli(), loaded.createdAt.toEpochMilli())
}
@Test
fun upsert_erzeugt_bei_unterschiedlichen_ids_getrennte_datensaetze() = runDbTest {
repo.upsert(patient(id = "p-1"))
repo.upsert(patient(id = "p-2", firstName = "Erika", lastName = "Musterfrau"))
assertEquals("Max", repo.getById("p-1")!!.firstName)
assertEquals("Erika", repo.getById("p-2")!!.firstName)
}
@Test
fun getById_liefert_null_fuer_unbekannte_id() = runDbTest {
assertNull(repo.getById("unbekannt"))
}
@Test
fun newId_liefert_gueltige_und_eindeutige_uuids() {
val a = repo.newId()
val b = repo.newId()
assertEquals(a, UUID.fromString(a).toString())
assertTrue(a != b)
}
// --- findExisting --------------------------------------------------------------------
@Test
fun findExisting_trifft_nur_bei_gleichem_namen_und_geburtsdatum() = runDbTest {
repo.upsert(patient())
assertEquals("p-1", repo.findExisting("Max", "Mustermann", LocalDate.of(1980, 3, 14))?.id)
assertNull(repo.findExisting("Max", "Mustermann", LocalDate.of(1980, 3, 15)))
assertNull(repo.findExisting("Moritz", "Mustermann", LocalDate.of(1980, 3, 14)))
assertNull(repo.findExisting("Max", "Musterfrau", LocalDate.of(1980, 3, 14)))
}
// --- Dokumentationen / Löschen -------------------------------------------------------
@Test
fun hasDocumentations_ist_erst_nach_dem_anlegen_einer_dokumentation_true() = runDbTest {
repo.upsert(patient())
assertFalse(repo.hasDocumentations("p-1"))
env.documentationRepository.createEmpty("p-1")
assertTrue(repo.hasDocumentations("p-1"))
}
@Test
fun delete_entfernt_patienten_ohne_dokumentation() = runDbTest {
repo.upsert(patient())
repo.delete("p-1")
assertNull(repo.getById("p-1"))
}
@Test
fun delete_wird_bei_vorhandener_dokumentation_von_der_datenbank_verhindert() = runDbTest {
repo.upsert(patient())
val doc = env.documentationRepository.createEmpty("p-1")
expectThrows<SQLiteConstraintException> { repo.delete("p-1") }
assertNotNull(repo.getById("p-1"))
assertNotNull(env.documentationRepository.getFullDocumentation(doc.id))
}
@Test
fun delete_mit_unbekannter_id_tut_nichts() = runDbTest {
repo.upsert(patient())
repo.delete("unbekannt")
assertNotNull(repo.getById("p-1"))
}
// --- Liste / Suche -------------------------------------------------------------------
@Test
fun observeAll_sortiert_nach_nachname_dann_vorname() = runDbTest {
repo.upsert(patient(id = "p-1", firstName = "Max", lastName = "Müller"))
repo.upsert(patient(id = "p-2", firstName = "Anna", lastName = "Müller"))
repo.upsert(patient(id = "p-3", firstName = "Zoe", lastName = "Adler"))
val list = repo.observeAll().first()
assertEquals(listOf("p-3", "p-2", "p-1"), list.map { it.id })
assertEquals("Zoe Adler", list.first().fullName)
}
@Test
fun observeAll_zeigt_datum_der_letzten_dokumentation_oder_null() = runDbTest {
repo.upsert(patient(id = "p-1"))
repo.upsert(patient(id = "p-2", firstName = "Erika", lastName = "Musterfrau"))
val older = env.documentationRepository.createEmpty("p-1")
val newer = env.documentationRepository.createEmpty("p-1")
env.setCreatedAt(older.id, 1_000L)
env.setCreatedAt(newer.id, 2_000L)
val byId = repo.observeAll().first().associateBy { it.id }
assertEquals(Instant.ofEpochMilli(2_000L), byId.getValue("p-1").lastDocumentationAt)
assertNull(byId.getValue("p-2").lastDocumentationAt)
}
@Test
fun search_findet_ueber_nachname_vorname_und_geburtsdatum() = runDbTest {
repo.upsert(patient(id = "p-1", firstName = "Max", lastName = "Mustermann"))
repo.upsert(patient(id = "p-2", firstName = "Erika", lastName = "Musterfrau", birthDate = LocalDate.of(1975, 12, 1)))
// Beide Nachnamen beginnen mit "Muster": sortiert nach Nachname -> Musterfrau vor Mustermann.
assertEquals(listOf("p-2", "p-1"), repo.search("Muster").first().map { it.id })
assertEquals(listOf("p-1"), repo.search("Max").first().map { it.id })
assertEquals(listOf("p-2"), repo.search("Erika").first().map { it.id })
assertEquals(listOf("p-2"), repo.search("1975-12").first().map { it.id })
assertTrue(repo.search("Zzz").first().isEmpty())
}
@Test
fun search_ist_unabhaengig_von_gross_und_kleinschreibung() = runDbTest {
repo.upsert(patient())
assertEquals(listOf("p-1"), repo.search("mustermann").first().map { it.id })
assertEquals(listOf("p-1"), repo.search("MAX").first().map { it.id })
}
}
@@ -0,0 +1,156 @@
package de.medapp.wunddoku.data.repository
import androidx.test.ext.junit.runners.AndroidJUnit4
import de.medapp.wunddoku.testutil.TestEnvironment
import de.medapp.wunddoku.testutil.runDbTest
import de.medapp.wunddoku.testutil.signatureBitmap
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
/** Repository-Tests für das Wundversorger-Register ([WoundCarerRepository]). */
@RunWith(AndroidJUnit4::class)
class WoundCarerRepositoryTest {
private lateinit var env: TestEnvironment
private val repo get() = env.woundCarerRepository
private val storage get() = env.woundCarerSignatureStorage
@Before
fun setUp() {
env = TestEnvironment()
}
@After
fun tearDown() {
env.close()
}
/** updatedAt basiert auf System.currentTimeMillis() - kurze Pause für eindeutige Reihenfolge. */
private fun tick() = Thread.sleep(5)
@Test
fun getMostRecentlyUsed_ist_null_solange_nie_eine_signatur_gespeichert_wurde() = runDbTest {
assertNull(repo.getMostRecentlyUsed())
}
@Test
fun recordSignatureUsage_legt_neuen_eintrag_mit_eigener_signaturdatei_an() = runDbTest {
repo.recordSignatureUsage("Anna Schmidt", signatureBitmap())
val carer = repo.getMostRecentlyUsed()!!
assertEquals("Anna Schmidt", carer.name)
assertEquals(1, storage.saved.size)
assertEquals(carer.id to carer.signatureFileName, storage.saved.single())
}
@Test
fun recordSignatureUsage_aktualisiert_bekannten_namen_case_insensitiv_und_ersetzt_die_signatur() = runDbTest {
repo.recordSignatureUsage("Anna Schmidt", signatureBitmap())
val first = repo.getMostRecentlyUsed()!!
tick()
repo.recordSignatureUsage("anna schmidt", signatureBitmap())
val second = repo.getMostRecentlyUsed()!!
assertEquals(first.id, second.id)
assertEquals("anna schmidt", second.name) // zuletzt getippte Schreibweise
assertNotEquals(first.signatureFileName, second.signatureFileName)
assertTrue(second.updatedAt >= first.updatedAt)
assertEquals(1, repo.search("a").size)
// Alte Signaturdatei wurde entfernt, genau eine Datei bleibt zugeordnet.
assertEquals(listOf(first.id to first.signatureFileName), storage.deleted)
}
@Test
fun recordSignatureUsage_ignoriert_fehler_beim_loeschen_der_alten_signaturdatei() = runDbTest {
repo.recordSignatureUsage("Anna Schmidt", signatureBitmap())
storage.failOnDelete = true
tick()
repo.recordSignatureUsage("Anna Schmidt", signatureBitmap())
assertEquals(1, repo.search("Anna").size)
}
@Test
fun getMostRecentlyUsed_liefert_den_zuletzt_aktualisierten_wundversorger() = runDbTest {
repo.recordSignatureUsage("Anna Schmidt", signatureBitmap())
tick()
repo.recordSignatureUsage("Bernd Beispiel", signatureBitmap())
assertEquals("Bernd Beispiel", repo.getMostRecentlyUsed()?.name)
tick()
repo.recordSignatureUsage("Anna Schmidt", signatureBitmap())
assertEquals("Anna Schmidt", repo.getMostRecentlyUsed()?.name)
}
// --- search --------------------------------------------------------------------------
@Test
fun search_liefert_praefix_treffer_case_insensitiv_neueste_zuerst() = runDbTest {
repo.recordSignatureUsage("Anna Schmidt", signatureBitmap())
tick()
repo.recordSignatureUsage("Andreas Müller", signatureBitmap())
tick()
repo.recordSignatureUsage("Bernd Beispiel", signatureBitmap())
val result = repo.search("an")
assertEquals(listOf("Andreas Müller", "Anna Schmidt"), result.map { it.name })
}
@Test
fun search_trimmt_die_eingabe_und_liefert_bei_leerer_eingabe_nichts() = runDbTest {
repo.recordSignatureUsage("Anna Schmidt", signatureBitmap())
assertEquals(listOf("Anna Schmidt"), repo.search(" Ann ").map { it.name })
assertTrue(repo.search("").isEmpty())
assertTrue(repo.search(" ").isEmpty())
}
@Test
fun search_ist_ein_praefix_und_kein_teilstring_abgleich() = runDbTest {
repo.recordSignatureUsage("Anna Schmidt", signatureBitmap())
assertTrue(repo.search("Schmidt").isEmpty())
}
@Test
fun search_beachtet_das_limit() = runDbTest {
for (name in listOf("Anna A", "Anna B", "Anna C", "Anna D")) {
repo.recordSignatureUsage(name, signatureBitmap())
tick()
}
assertEquals(2, repo.search("Anna", limit = 2).size)
assertEquals(4, repo.search("Anna").size) // Standardlimit 5 deckt alle vier ab
assertEquals(listOf("Anna D", "Anna C"), repo.search("Anna", limit = 2).map { it.name })
}
// --- loadSignatureBitmap -------------------------------------------------------------
@Test
fun loadSignatureBitmap_delegiert_an_den_wundversorger_speicher() = runDbTest {
val bitmap = signatureBitmap()
storage.decodeResult = bitmap
val result = repo.loadSignatureBitmap("carer-1", "sig.png")
assertEquals(bitmap, result)
assertEquals(listOf("carer-1" to "sig.png"), storage.decodeCalls)
}
@Test
fun loadSignatureBitmap_liefert_null_wenn_die_datei_fehlt() = runDbTest {
storage.decodeResult = null
assertNull(repo.loadSignatureBitmap("carer-1", "fehlt.png"))
}
}
@@ -0,0 +1,138 @@
package de.medapp.wunddoku.testutil
import android.graphics.Bitmap
import android.net.Uri
import de.medapp.wunddoku.media.PhotoStorage
import de.medapp.wunddoku.pdf.DocumentationPdfExporter
import de.medapp.wunddoku.pdf.PdfExportModel
import de.medapp.wunddoku.pdf.PdfExportResult
import de.medapp.wunddoku.pdf.PdfToImagesConverter
import de.medapp.wunddoku.pdf.PdfToImagesResult
import de.medapp.wunddoku.signature.SignatureStorage
import java.io.File
import java.io.IOException
/** Aufgezeichneter Aufruf von [PhotoStorage.duplicate]. */
data class DuplicateCall(
val sourceDocumentationId: String,
val sourceFileName: String,
val targetDocumentationId: String,
)
/**
* In-Memory-Fake für [PhotoStorage]: schreibt keine Dateien, zeichnet aber alle Aufrufe auf und
* vergibt eindeutige Dateinamen. Fehler lassen sich gezielt auslösen.
*/
class FakePhotoStorage : PhotoStorage {
val imported = mutableListOf<Pair<String, Uri>>()
val duplicated = mutableListOf<DuplicateCall>()
val deleted = mutableListOf<Pair<String, String>>()
val deletedAll = mutableListOf<String>()
var failOnDelete = false
var failOnDeleteAll = false
/** 1-basierte Nummer des [duplicate]-Aufrufs, der mit einer [IOException] fehlschlagen soll. */
var failOnDuplicateCall: Int? = null
private var counter = 0
override suspend fun importFrom(documentationId: String, sourceUri: Uri): String {
imported += documentationId to sourceUri
return "imported_${++counter}.jpg"
}
override suspend fun duplicate(
sourceDocumentationId: String,
sourceFileName: String,
targetDocumentationId: String,
): String {
duplicated += DuplicateCall(sourceDocumentationId, sourceFileName, targetDocumentationId)
if (failOnDuplicateCall == duplicated.size) throw IOException("Kopieren fehlgeschlagen (Test)")
return "copy_${++counter}.jpg"
}
override suspend fun delete(documentationId: String, fileName: String) {
if (failOnDelete) throw IOException("Löschen fehlgeschlagen (Test)")
deleted += documentationId to fileName
}
override suspend fun deleteAll(documentationId: String) {
if (failOnDeleteAll) throw IOException("Ordner löschen fehlgeschlagen (Test)")
deletedAll += documentationId
}
override fun resolve(documentationId: String, fileName: String): File =
File("/fake/photos/$documentationId/$fileName")
override suspend fun decodeThumbnail(documentationId: String, fileName: String, maxDimensionPx: Int): Bitmap? = null
override suspend fun decodeFitted(
documentationId: String,
fileName: String,
maxWidthPx: Int,
maxHeightPx: Int,
): Bitmap? = null
}
/** In-Memory-Fake für [SignatureStorage] (analog [FakePhotoStorage]). */
class FakeSignatureStorage : SignatureStorage {
val saved = mutableListOf<Pair<String, String>>() // key (documentationId bzw. woundCarerId) -> fileName
val deleted = mutableListOf<Pair<String, String>>()
val deletedAll = mutableListOf<String>()
val decodeCalls = mutableListOf<Pair<String, String>>()
var failOnDelete = false
var failOnDeleteAll = false
var decodeResult: Bitmap? = null
private var counter = 0
override suspend fun save(documentationId: String, bitmap: Bitmap): String {
val fileName = "sig_${++counter}.png"
saved += documentationId to fileName
return fileName
}
override suspend fun delete(documentationId: String, fileName: String) {
if (failOnDelete) throw IOException("Löschen fehlgeschlagen (Test)")
deleted += documentationId to fileName
}
override suspend fun deleteAll(documentationId: String) {
if (failOnDeleteAll) throw IOException("Ordner löschen fehlgeschlagen (Test)")
deletedAll += documentationId
}
override fun resolve(documentationId: String, fileName: String): File =
File("/fake/signatures/$documentationId/$fileName")
override suspend fun decode(documentationId: String, fileName: String): Bitmap? {
decodeCalls += documentationId to fileName
return decodeResult
}
}
/** Zeichnet die an den Exporter übergebenen Modelle auf und liefert ein konfigurierbares Ergebnis. */
class FakePdfExporter : DocumentationPdfExporter {
val models = mutableListOf<PdfExportModel>()
var result: ((PdfExportModel) -> PdfExportResult) = { model ->
PdfExportResult.Success(File("/fake/pdfs/${model.fileName}"))
}
override suspend fun export(model: PdfExportModel): PdfExportResult {
models += model
return result(model)
}
}
/** Zeichnet Aufrufe auf und liefert ein konfigurierbares Ergebnis. */
class FakePdfToImagesConverter : PdfToImagesConverter {
val calls = mutableListOf<Pair<String, File>>()
var result: PdfToImagesResult = PdfToImagesResult.Success(emptyList())
override suspend fun convert(documentationId: String, pdfFile: File): PdfToImagesResult {
calls += documentationId to pdfFile
return result
}
}
@@ -0,0 +1,133 @@
package de.medapp.wunddoku.testutil
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import androidx.room.Room
import androidx.test.platform.app.InstrumentationRegistry
import de.medapp.wunddoku.data.database.AppDatabase
import de.medapp.wunddoku.data.entity.PatientEntity
import de.medapp.wunddoku.data.repository.DocumentationRepository
import de.medapp.wunddoku.data.repository.PatientRepository
import de.medapp.wunddoku.data.repository.WoundCarerRepository
import de.medapp.wunddoku.domain.model.CheckItem
import de.medapp.wunddoku.domain.model.DocumentationBlock
import de.medapp.wunddoku.domain.model.DocumentationStatus
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.runBlocking
/**
* Gemeinsame Testumgebung für alle Instrumented-Tests: echte Room-Datenbank im Arbeitsspeicher
* (gleiche Entities/DAOs/Foreign Keys wie in Produktion), aber Fakes für Dateispeicher und
* PDF-Erzeugung. Jeder Test erzeugt eine eigene Instanz und ruft am Ende [close] auf.
*/
class TestEnvironment {
val db: AppDatabase = Room.inMemoryDatabaseBuilder(
InstrumentationRegistry.getInstrumentation().targetContext,
AppDatabase::class.java,
).build()
val photoStorage = FakePhotoStorage()
val signatureStorage = FakeSignatureStorage()
val woundCarerSignatureStorage = FakeSignatureStorage()
val pdfExporter = FakePdfExporter()
val pdfToImagesConverter = FakePdfToImagesConverter()
val patientRepository = PatientRepository(patientDao = db.patientDao())
val woundCarerRepository = WoundCarerRepository(
woundCarerDao = db.woundCarerDao(),
woundCarerSignatureStorage = woundCarerSignatureStorage,
)
val documentationRepository = DocumentationRepository(
documentationDao = db.documentationDao(),
blockStateDao = db.blockStateDao(),
checkItemStateDao = db.checkItemStateDao(),
photoDao = db.photoDao(),
signatureDao = db.signatureDao(),
patientDao = db.patientDao(),
photoStorage = photoStorage,
signatureStorage = signatureStorage,
woundCarerRepository = woundCarerRepository,
pdfExporter = pdfExporter,
pdfToImagesConverter = pdfToImagesConverter,
db = db,
)
fun close() = db.close()
/** Legt direkt über das DAO einen Patienten an (ohne Repository-Logik). */
suspend fun insertPatient(
id: String = "patient-1",
firstName: String = "Max",
lastName: String = "Mustermann",
birthDate: String = "1980-03-14",
street: String = "Musterstraße 1",
postalCode: String = "12345",
city: String = "Musterstadt",
): PatientEntity {
val entity = PatientEntity(
id = id,
firstName = firstName,
lastName = lastName,
birthDate = birthDate,
street = street,
postalCode = postalCode,
city = city,
phone1 = "0123 456789",
phone2 = null,
phone3 = null,
createdAt = 1_000L,
updatedAt = 1_000L,
)
db.patientDao().insert(entity)
return entity
}
/** Setzt createdAt einer Dokumentation gezielt (vermeidet zeitabhängige Tests). */
suspend fun setCreatedAt(documentationId: String, createdAt: Long) {
val dao = db.documentationDao()
dao.update(dao.getById(documentationId)!!.copy(createdAt = createdAt))
}
suspend fun setStatus(documentationId: String, status: DocumentationStatus) {
val dao = db.documentationDao()
dao.update(dao.getById(documentationId)!!.copy(status = status))
}
}
/** Führt einen Test mit Suspend-Aufrufen aus; Rückgabetyp Unit, damit JUnit die Methode akzeptiert. */
fun runDbTest(block: suspend CoroutineScope.() -> Unit) {
runBlocking { block() }
}
/** Erstes Item eines Blocks, das eine normale Checkbox ist (kein Freitext, hint oder strukturierte Eingabe). */
fun DocumentationBlock.firstPlainItem(): CheckItem =
items.first { !it.isFreeText && !it.isStaticHint && it.inputSpec.isEmpty() }
/** Weißes Bitmap ohne jeden Strich - gilt als "leere Signatur". */
fun blankBitmap(): Bitmap =
Bitmap.createBitmap(40, 20, Bitmap.Config.ARGB_8888).apply { eraseColor(Color.WHITE) }
/** Weißes Bitmap mit einem schwarzen Strich - gilt als gültige Signatur. */
fun signatureBitmap(): Bitmap = blankBitmap().also { bitmap ->
val paint = Paint().apply {
color = Color.BLACK
strokeWidth = 3f
}
Canvas(bitmap).drawLine(2f, 2f, 38f, 18f, paint)
}
/** Erwartet, dass [block] eine Exception vom Typ [T] wirft, und liefert sie zurück. */
inline fun <reified T : Throwable> expectThrows(block: () -> Unit): T {
try {
block()
} catch (e: Throwable) {
if (e is T) return e
throw e
}
throw AssertionError("Erwartet: ${T::class.java.simpleName}, es wurde aber keine Exception geworfen")
}
@@ -0,0 +1,151 @@
package de.medapp.wunddoku.domain.usecase
import de.medapp.wunddoku.domain.model.CheckItem
import de.medapp.wunddoku.domain.model.DocumentationBlock
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Blockstatus-Tests: Wann gilt ein Dokumentationsblock als "bearbeitet"?
* Diese Regel steuert Fortschrittsanzeige UND PDF-Export und muss deshalb exakt stimmen.
*/
class BlockEditedPolicyTest {
private var idCounter = 0
private fun item(
checked: Boolean,
isFreeText: Boolean = false,
freeTextValue: String = "",
isStaticHint: Boolean = false,
inputSpec: String = "",
): CheckItem {
idCounter++
return CheckItem(
checkItemStateId = "state-$idCounter",
definitionId = "def-$idCounter",
label = "Eintrag $idCounter",
order = idCounter,
checked = checked,
isFreeText = isFreeText,
freeTextValue = freeTextValue,
isStaticHint = isStaticHint,
inputSpec = inputSpec,
)
}
private fun block(vararg items: CheckItem) = DocumentationBlock(
blockStateId = "block-state",
definitionId = "block-def",
title = "Testblock",
order = 1,
items = items.toList(),
)
// --- nicht bearbeitet ----------------------------------------------------------------
@Test
fun block_ohne_eintraege_ist_nicht_bearbeitet() {
assertFalse(BlockEditedPolicy.isEdited(block()))
}
@Test
fun block_ohne_angehakten_eintrag_ist_nicht_bearbeitet() {
val b = block(item(checked = false), item(checked = false), item(checked = false, isFreeText = true))
assertFalse(BlockEditedPolicy.isEdited(b))
}
@Test
fun nicht_angehakter_freitext_mit_text_zaehlt_nicht_als_bearbeitung() {
// Das Freitextfeld ist in der UI gesperrt, solange die Checkbox nicht angehakt ist.
val b = block(item(checked = false, isFreeText = true, freeTextValue = "Text ohne Haken"))
assertFalse(BlockEditedPolicy.isEdited(b))
}
@Test
fun nur_hinweistexte_machen_einen_block_nicht_bearbeitet() {
// Hinweistexte sind technisch immer checked = true, aber keine Nutzereingabe.
val b = block(
item(checked = true, isStaticHint = true),
item(checked = true, isStaticHint = true),
)
assertFalse(BlockEditedPolicy.isEdited(b))
}
@Test
fun hinweistexte_plus_nicht_angehakte_eintraege_sind_nicht_bearbeitet() {
val b = block(
item(checked = true, isStaticHint = true),
item(checked = false),
item(checked = false, isFreeText = true),
)
assertFalse(BlockEditedPolicy.isEdited(b))
}
// --- bearbeitet ----------------------------------------------------------------------
@Test
fun ein_angehakter_normaler_eintrag_macht_den_block_bearbeitet() {
assertTrue(BlockEditedPolicy.isEdited(block(item(checked = true))))
}
@Test
fun angehakter_freitext_zaehlt_auch_ohne_eingegebenen_text() {
val b = block(item(checked = true, isFreeText = true, freeTextValue = ""))
assertTrue(BlockEditedPolicy.isEdited(b))
}
@Test
fun angehakter_freitext_mit_text_ist_bearbeitet() {
val b = block(item(checked = true, isFreeText = true, freeTextValue = "Rötung am Wundrand"))
assertTrue(BlockEditedPolicy.isEdited(b))
}
@Test
fun angehakter_eintrag_mit_strukturierter_eingabe_ist_bearbeitet() {
val b = block(item(checked = true, freeTextValue = "3", inputSpec = "spec"))
assertTrue(BlockEditedPolicy.isEdited(b))
}
@Test
fun hinweistext_wird_ignoriert_sobald_ein_echter_eintrag_angehakt_ist() {
val b = block(
item(checked = true, isStaticHint = true),
item(checked = true),
)
assertTrue(BlockEditedPolicy.isEdited(b))
}
@Test
fun mehrere_angehakte_eintraege_sind_bearbeitet() {
val b = block(item(checked = true), item(checked = true), item(checked = false))
assertTrue(BlockEditedPolicy.isEdited(b))
}
// --- Zusammenspiel mit DocumentationBlock.isEdited ------------------------------------
@Test
fun documentation_block_isEdited_delegiert_an_die_policy() {
val edited = block(item(checked = true))
val untouched = block(item(checked = false), item(checked = true, isStaticHint = true))
assertEquals(BlockEditedPolicy.isEdited(edited), edited.isEdited)
assertEquals(BlockEditedPolicy.isEdited(untouched), untouched.isEdited)
assertTrue(edited.isEdited)
assertFalse(untouched.isEdited)
}
@Test
fun isEdited_wird_aus_dem_aktuellen_inhalt_abgeleitet_und_nicht_gespeichert() {
val untouched = block(item(checked = false))
assertFalse(untouched.isEdited)
val nowChecked = untouched.copy(items = untouched.items.map { it.copy(checked = true) })
assertTrue(nowChecked.isEdited)
val uncheckedAgain = nowChecked.copy(items = nowChecked.items.map { it.copy(checked = false) })
assertFalse(uncheckedAgain.isEdited)
}
}
@@ -0,0 +1,245 @@
package de.medapp.wunddoku.pdf
import de.medapp.wunddoku.domain.model.CheckItem
import de.medapp.wunddoku.domain.model.Documentation
import de.medapp.wunddoku.domain.model.DocumentationBlock
import de.medapp.wunddoku.domain.model.DocumentationStatus
import de.medapp.wunddoku.domain.model.PatientStammdatenSnapshot
import de.medapp.wunddoku.domain.model.Photo
import de.medapp.wunddoku.domain.model.Signature
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneId
/**
* Blockstatus im PDF-Export: Nur bearbeitete Blöcke und nur angehakte Optionen gelangen ins PDF,
* alles wird aus den historischen Snapshots gebildet und stabil sortiert.
*/
class DocumentationToPdfExportModelMapperTest {
private val snapshot = PatientStammdatenSnapshot(
firstName = "Max",
lastName = "Mustermann",
birthDate = LocalDate.of(1980, 3, 14),
street = "Musterstraße 1",
postalCode = "12345",
city = "Musterstadt",
)
// Mittag in der Systemzeitzone -> das lokale Datum ist zeitzonenunabhängig der 23.08.2026.
private val createdAt: Instant =
LocalDate.of(2026, 8, 23).atTime(12, 0).atZone(ZoneId.systemDefault()).toInstant()
private fun item(
id: String,
order: Int,
checked: Boolean,
label: String = "Label $id",
isFreeText: Boolean = false,
freeTextValue: String = "",
isStaticHint: Boolean = false,
inputSpec: String = "",
) = CheckItem(
checkItemStateId = "state-$id",
definitionId = id,
label = label,
order = order,
checked = checked,
isFreeText = isFreeText,
freeTextValue = freeTextValue,
isStaticHint = isStaticHint,
inputSpec = inputSpec,
)
private fun block(id: String, order: Int, title: String = "Titel $id", items: List<CheckItem>) =
DocumentationBlock("state-$id", id, title, order, items)
private fun documentation(
blocks: List<DocumentationBlock>,
photos: List<Photo> = emptyList(),
signatures: List<Signature> = emptyList(),
) = Documentation(
id = "doc-1",
patientId = "patient-1",
patientSnapshot = snapshot,
status = DocumentationStatus.IN_PROGRESS,
createdAt = createdAt,
updatedAt = createdAt,
continuedFromDocumentationId = null,
catalogVersion = 4,
blocks = blocks,
photos = photos,
signatures = signatures,
)
// --- Blockfilter ---------------------------------------------------------------------
@Test
fun nur_bearbeitete_bloecke_werden_exportiert() {
val doc = documentation(
listOf(
block("a", 1, items = listOf(item("a1", 1, checked = true))),
block("b", 2, items = listOf(item("b1", 1, checked = false))),
block("c", 3, items = listOf(item("c1", 1, checked = true))),
)
)
val model = DocumentationToPdfExportModelMapper.map(doc)
assertEquals(listOf("Titel a", "Titel c"), model.blocks.map { it.title })
}
@Test
fun block_mit_nur_hinweistexten_wird_nicht_exportiert() {
val doc = documentation(
listOf(
block(
"hint", 1,
items = listOf(
item("h1", 1, checked = true, isStaticHint = true),
item("h2", 2, checked = false),
),
),
)
)
assertTrue(DocumentationToPdfExportModelMapper.map(doc).blocks.isEmpty())
}
@Test
fun dokumentation_ohne_bearbeiteten_block_ergibt_leere_blockliste() {
val doc = documentation(listOf(block("a", 1, items = listOf(item("a1", 1, checked = false)))))
assertTrue(DocumentationToPdfExportModelMapper.map(doc).blocks.isEmpty())
}
// --- Itemfilter ----------------------------------------------------------------------
@Test
fun nur_angehakte_optionen_erscheinen_im_pdf() {
val doc = documentation(
listOf(
block(
"a", 1,
items = listOf(
item("a1", 1, checked = true, label = "Ja"),
item("a2", 2, checked = false, label = "Nein"),
item("a3", 3, checked = true, label = "Auch ja"),
),
),
)
)
val items = DocumentationToPdfExportModelMapper.map(doc).blocks.single().items
assertEquals(listOf("Ja", "Auch ja"), items.map { it.label })
}
@Test
fun hinweistexte_eines_bearbeiteten_blocks_bleiben_im_pdf_erhalten() {
val doc = documentation(
listOf(
block(
"a", 1,
items = listOf(
item("hint", 1, checked = true, label = "Reinigen mit:", isStaticHint = true),
item("a2", 2, checked = true, label = "Ringer-Lösung"),
),
),
)
)
val items = DocumentationToPdfExportModelMapper.map(doc).blocks.single().items
assertEquals(listOf("Reinigen mit:", "Ringer-Lösung"), items.map { it.label })
}
@Test
fun freitext_und_strukturierte_eingabe_werden_unveraendert_durchgereicht() {
val doc = documentation(
listOf(
block(
"a", 1,
items = listOf(
item("f", 1, checked = true, label = "Freitext", isFreeText = true, freeTextValue = "Rötung"),
item("s", 2, checked = true, label = "Maße", freeTextValue = "3|4", inputSpec = "SPEC"),
),
),
)
)
val items = DocumentationToPdfExportModelMapper.map(doc).blocks.single().items
assertEquals(PdfExportItem("Freitext", isFreeText = true, freeTextValue = "Rötung", inputSpec = ""), items[0])
assertEquals(PdfExportItem("Maße", isFreeText = false, freeTextValue = "3|4", inputSpec = "SPEC"), items[1])
}
// --- Sortierung ----------------------------------------------------------------------
@Test
fun bloecke_und_optionen_werden_nach_order_sortiert() {
val doc = documentation(
listOf(
block("zweiter", 2, items = listOf(item("z1", 1, checked = true))),
block(
"erster", 1,
items = listOf(
item("e2", 2, checked = true, label = "B"),
item("e1", 1, checked = true, label = "A"),
),
),
)
)
val blocks = DocumentationToPdfExportModelMapper.map(doc).blocks
assertEquals(listOf("Titel erster", "Titel zweiter"), blocks.map { it.title })
assertEquals(listOf("A", "B"), blocks.first().items.map { it.label })
}
@Test
fun fotos_und_signaturen_werden_nach_orderIndex_sortiert() {
val photos = listOf(
Photo("p2", "doc-1", "zweites.jpg", 1, createdAt),
Photo("p1", "doc-1", "erstes.jpg", 0, createdAt),
)
val signatures = listOf(
Signature("s2", "doc-1", "Berta", "b.png", 1, createdAt),
Signature("s1", "doc-1", "Anna", "a.png", 0, createdAt),
)
val model = DocumentationToPdfExportModelMapper.map(documentation(emptyList(), photos, signatures))
assertEquals(listOf("erstes.jpg", "zweites.jpg"), model.photos.map { it.fileName })
assertEquals(listOf("Anna", "Berta"), model.signatures.map { it.signerName })
assertEquals(listOf("a.png", "b.png"), model.signatures.map { it.fileName })
}
@Test
fun alle_signaturen_werden_exportiert_ohne_obergrenze() {
val signatures = (0 until 5).map { Signature("s$it", "doc-1", "Person $it", "s$it.png", it, createdAt) }
val model = DocumentationToPdfExportModelMapper.map(documentation(emptyList(), signatures = signatures))
assertEquals(5, model.signatures.size)
}
// --- Kopfdaten (Snapshot) ------------------------------------------------------------
@Test
fun kopfdaten_stammen_aus_dem_patienten_snapshot_der_dokumentation() {
val model = DocumentationToPdfExportModelMapper.map(documentation(emptyList()))
assertEquals("doc-1", model.documentationId)
assertEquals("Max Mustermann", model.patientFullName)
assertEquals(LocalDate.of(1980, 3, 14), model.patientBirthDate)
assertEquals("Musterstraße 1, 12345 Musterstadt", model.patientDisplayAddress)
assertEquals(LocalDate.of(2026, 8, 23), model.createdAt)
}
@Test
fun dateiname_folgt_dem_schema_und_nutzt_snapshot_und_erstelldatum() {
val model = DocumentationToPdfExportModelMapper.map(documentation(emptyList()))
assertEquals("2026-08-23_Max_Mustermann_1980-03-14.pdf", model.fileName)
}
}
@@ -0,0 +1,175 @@
package de.medapp.wunddoku.pdf
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import java.time.LocalDate
/**
* Reine JVM-Tests (keine Android-Abhängigkeit) für das Dateinamensschema
* `Erstelldatum_Vorname_Nachname_Geburtsdatum.pdf`.
*/
class PdfFileNameGeneratorTest {
private val created = LocalDate.of(2026, 8, 23)
private val birth = LocalDate.of(1980, 3, 14)
private fun name(
first: String,
last: String,
createdAt: LocalDate = created,
birthDate: LocalDate = birth,
): String = PdfFileNameGenerator.generate(createdAt, first, last, birthDate)
// --- Grundschema ---------------------------------------------------------------------
@Test
fun standardfall_folgt_dem_vereinbarten_schema() {
assertEquals("2026-08-23_Max_Mustermann_1980-03-14.pdf", name("Max", "Mustermann"))
}
@Test
fun datumsteile_werden_mit_fuehrenden_nullen_im_iso_format_ausgegeben() {
val result = name("Max", "Mustermann", createdAt = LocalDate.of(2026, 1, 5), birthDate = LocalDate.of(1999, 2, 3))
assertEquals("2026-01-05_Max_Mustermann_1999-02-03.pdf", result)
}
@Test
fun schaltjahr_geburtsdatum_wird_korrekt_ausgegeben() {
val result = name("Max", "Mustermann", birthDate = LocalDate.of(2000, 2, 29))
assertEquals("2026-08-23_Max_Mustermann_2000-02-29.pdf", result)
}
@Test
fun ergebnis_ist_deterministisch() {
assertEquals(name("Max", "Mustermann"), name("Max", "Mustermann"))
}
@Test
fun verschiedene_erstelldaten_fuehren_zu_verschiedenen_dateinamen() {
val a = name("Max", "Mustermann", createdAt = LocalDate.of(2026, 8, 23))
val b = name("Max", "Mustermann", createdAt = LocalDate.of(2026, 8, 24))
assertNotEquals(a, b)
}
// --- Umlaute / Diakritika ------------------------------------------------------------
@Test
fun deutsche_umlaute_werden_transliteriert_statt_entfernt() {
assertEquals("2026-08-23_Max_Mueller_1980-03-14.pdf", name("Max", "Müller"))
assertEquals("2026-08-23_Aenne_Oezdemir_1980-03-14.pdf", name("Änne", "Özdemir"))
assertEquals("2026-08-23_Uwe_Ueberall_1980-03-14.pdf", name("Uwe", "Überall"))
}
@Test
fun eszett_wird_zu_doppel_s() {
assertEquals("2026-08-23_Max_Weiss_1980-03-14.pdf", name("Max", "Weiß"))
}
@Test
fun mueller_und_mller_bleiben_unterscheidbar() {
assertNotEquals(name("Max", "Müller"), name("Max", "Mller"))
}
@Test
fun andere_diakritika_werden_entfernt() {
assertEquals("2026-08-23_Jose_Celik_1980-03-14.pdf", name("José", "Çelik"))
}
// --- Leerzeichen / Sonderzeichen -----------------------------------------------------
@Test
fun leerzeichen_werden_zu_unterstrichen() {
assertEquals("2026-08-23_Anna_Maria_Mustermann_1980-03-14.pdf", name("Anna Maria", "Mustermann"))
}
@Test
fun mehrfaches_whitespace_wird_zu_einem_unterstrich_zusammengefasst() {
assertEquals("2026-08-23_Anna_Maria_Mustermann_1980-03-14.pdf", name("Anna Maria", "Mustermann"))
assertEquals("2026-08-23_Anna_Maria_Mustermann_1980-03-14.pdf", name("Anna\t\nMaria", "Mustermann"))
}
@Test
fun mehrfache_unterstriche_werden_zusammengefasst() {
assertEquals("2026-08-23_Max_Muster_mann_1980-03-14.pdf", name("Max", "Muster__mann"))
}
@Test
fun fuehrende_und_nachgestellte_trennzeichen_werden_entfernt() {
val expected = "2026-08-23_Max_Mustermann_1980-03-14.pdf"
assertEquals(expected, name(" Max ", "Mustermann"))
assertEquals(expected, name("_Max_", "Mustermann"))
assertEquals(expected, name("-Max-", "Mustermann"))
}
@Test
fun bindestrich_im_doppelnamen_bleibt_erhalten() {
assertEquals("2026-08-23_Aenne-Marie_Meyer-Luedenscheidt_1980-03-14.pdf", name("Änne-Marie", "Meyer-Lüdenscheidt"))
}
@Test
fun apostroph_und_punkt_werden_entfernt() {
assertEquals("2026-08-23_Sean_OBrien_1980-03-14.pdf", name("Sean", "O'Brien"))
assertEquals("2026-08-23_Max_Dr_Mustermann_1980-03-14.pdf", name("Max", "Dr. Mustermann"))
}
// --- Sicherheit (Pfad-/Dateisystemzeichen) --------------------------------------------
@Test
fun pfadseparatoren_und_punkte_koennen_keinen_pfad_erzeugen() {
assertEquals(
"2026-08-23_Unbekannt_etcpasswd_1980-03-14.pdf",
name("../..", "../etc/passwd"),
)
}
@Test
fun fuer_dateinamen_ungueltige_zeichen_werden_entfernt() {
assertEquals(
"2026-08-23_MaxMuster_Mustermann_1980-03-14.pdf",
name("Max:Mus*ter?", "Mustermann"),
)
assertEquals(
"2026-08-23_Max_Mustermann_1980-03-14.pdf",
name("Ma<x>|\"", "Muster\\mann"),
)
}
@Test
fun emojis_werden_entfernt() {
assertEquals("2026-08-23_Max_Mustermann_1980-03-14.pdf", name("Max😀", "Mustermann"))
}
@Test
fun dateiname_besteht_immer_nur_aus_sicheren_zeichen_und_endet_auf_pdf() {
val inputs = listOf(
"Max" to "Mustermann",
"Änne-Marie" to "Özdemir Weiß",
"José" to "O'Brien",
"../../x" to "a\\b/c",
" " to "",
"😀" to "???",
"Ma<x>|\"" to "Muster\\mann",
)
val allowed = Regex("^[A-Za-z0-9_.-]+$")
for ((first, last) in inputs) {
val result = name(first, last)
assertTrue("Unsicherer Dateiname: $result", allowed.matches(result))
assertTrue("Falsche Endung: $result", result.endsWith(".pdf"))
}
}
// --- Platzhalter für leere Namen -----------------------------------------------------
@Test
fun leerer_oder_blanker_name_wird_zu_unbekannt() {
assertEquals("2026-08-23_Unbekannt_Mustermann_1980-03-14.pdf", name("", "Mustermann"))
assertEquals("2026-08-23_Max_Unbekannt_1980-03-14.pdf", name("Max", " "))
}
@Test
fun name_nur_aus_ungueltigen_zeichen_wird_zu_unbekannt() {
assertEquals("2026-08-23_Unbekannt_Unbekannt_1980-03-14.pdf", name("???", "***"))
}
}