From dcfa11bef9b622b94d10714498e287c884db887a Mon Sep 17 00:00:00 2001 From: NOrtmann1 Date: Thu, 8 Oct 2026 12:15:31 +0200 Subject: [PATCH 1/2] QA-001 - [x] Dateinamen-Tests vorhanden - [x] Fortsetzen-Tests vorhanden - [x] Blockstatus-Tests vorhanden - [x] Repository-Tests vorhanden --- .../repository/ContinueDocumentationTest.kt | 378 ++++++++++++ .../repository/DocumentationRepositoryTest.kt | 545 ++++++++++++++++++ .../data/repository/PatientRepositoryTest.kt | 222 +++++++ .../repository/WoundCarerRepositoryTest.kt | 156 +++++ .../java/de/medapp/wunddoku/testutil/Fakes.kt | 138 +++++ .../wunddoku/testutil/TestEnvironment.kt | 133 +++++ .../domain/usecase/BlockEditedPolicyTest.kt | 151 +++++ ...DocumentationToPdfExportModelMapperTest.kt | 245 ++++++++ .../wunddoku/pdf/PdfFileNameGeneratorTest.kt | 175 ++++++ 9 files changed, 2143 insertions(+) create mode 100644 app/src/androidTest/java/de/medapp/wunddoku/data/repository/ContinueDocumentationTest.kt create mode 100644 app/src/androidTest/java/de/medapp/wunddoku/data/repository/DocumentationRepositoryTest.kt create mode 100644 app/src/androidTest/java/de/medapp/wunddoku/data/repository/PatientRepositoryTest.kt create mode 100644 app/src/androidTest/java/de/medapp/wunddoku/data/repository/WoundCarerRepositoryTest.kt create mode 100644 app/src/androidTest/java/de/medapp/wunddoku/testutil/Fakes.kt create mode 100644 app/src/androidTest/java/de/medapp/wunddoku/testutil/TestEnvironment.kt create mode 100644 app/src/test/java/de/medapp/wunddoku/domain/usecase/BlockEditedPolicyTest.kt create mode 100644 app/src/test/java/de/medapp/wunddoku/pdf/DocumentationToPdfExportModelMapperTest.kt create mode 100644 app/src/test/java/de/medapp/wunddoku/pdf/PdfFileNameGeneratorTest.kt diff --git a/app/src/androidTest/java/de/medapp/wunddoku/data/repository/ContinueDocumentationTest.kt b/app/src/androidTest/java/de/medapp/wunddoku/data/repository/ContinueDocumentationTest.kt new file mode 100644 index 0000000..16d444b --- /dev/null +++ b/app/src/androidTest/java/de/medapp/wunddoku/data/repository/ContinueDocumentationTest.kt @@ -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 = + 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 { 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 { 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 }) + } +} diff --git a/app/src/androidTest/java/de/medapp/wunddoku/data/repository/DocumentationRepositoryTest.kt b/app/src/androidTest/java/de/medapp/wunddoku/data/repository/DocumentationRepositoryTest.kt new file mode 100644 index 0000000..3ce6a6e --- /dev/null +++ b/app/src/androidTest/java/de/medapp/wunddoku/data/repository/DocumentationRepositoryTest.kt @@ -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 { 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 { 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 { 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) + } +} diff --git a/app/src/androidTest/java/de/medapp/wunddoku/data/repository/PatientRepositoryTest.kt b/app/src/androidTest/java/de/medapp/wunddoku/data/repository/PatientRepositoryTest.kt new file mode 100644 index 0000000..c4e4468 --- /dev/null +++ b/app/src/androidTest/java/de/medapp/wunddoku/data/repository/PatientRepositoryTest.kt @@ -0,0 +1,222 @@ +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) + assertEquals(saved.createdAt, loaded.createdAt) + } + + @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, second.createdAt) + assertTrue(second.updatedAt >= first.updatedAt) + val loaded = repo.getById("p-1")!! + assertEquals("Musterfrau", loaded.lastName) + assertEquals(first.createdAt, loaded.createdAt) + } + + @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 { 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 }) + } +} diff --git a/app/src/androidTest/java/de/medapp/wunddoku/data/repository/WoundCarerRepositoryTest.kt b/app/src/androidTest/java/de/medapp/wunddoku/data/repository/WoundCarerRepositoryTest.kt new file mode 100644 index 0000000..fac990e --- /dev/null +++ b/app/src/androidTest/java/de/medapp/wunddoku/data/repository/WoundCarerRepositoryTest.kt @@ -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")) + } +} diff --git a/app/src/androidTest/java/de/medapp/wunddoku/testutil/Fakes.kt b/app/src/androidTest/java/de/medapp/wunddoku/testutil/Fakes.kt new file mode 100644 index 0000000..f3d0726 --- /dev/null +++ b/app/src/androidTest/java/de/medapp/wunddoku/testutil/Fakes.kt @@ -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>() + val duplicated = mutableListOf() + val deleted = mutableListOf>() + val deletedAll = mutableListOf() + + 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>() // key (documentationId bzw. woundCarerId) -> fileName + val deleted = mutableListOf>() + val deletedAll = mutableListOf() + val decodeCalls = mutableListOf>() + + 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() + 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>() + var result: PdfToImagesResult = PdfToImagesResult.Success(emptyList()) + + override suspend fun convert(documentationId: String, pdfFile: File): PdfToImagesResult { + calls += documentationId to pdfFile + return result + } +} diff --git a/app/src/androidTest/java/de/medapp/wunddoku/testutil/TestEnvironment.kt b/app/src/androidTest/java/de/medapp/wunddoku/testutil/TestEnvironment.kt new file mode 100644 index 0000000..2ac7830 --- /dev/null +++ b/app/src/androidTest/java/de/medapp/wunddoku/testutil/TestEnvironment.kt @@ -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 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") +} diff --git a/app/src/test/java/de/medapp/wunddoku/domain/usecase/BlockEditedPolicyTest.kt b/app/src/test/java/de/medapp/wunddoku/domain/usecase/BlockEditedPolicyTest.kt new file mode 100644 index 0000000..8a07853 --- /dev/null +++ b/app/src/test/java/de/medapp/wunddoku/domain/usecase/BlockEditedPolicyTest.kt @@ -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) + } +} diff --git a/app/src/test/java/de/medapp/wunddoku/pdf/DocumentationToPdfExportModelMapperTest.kt b/app/src/test/java/de/medapp/wunddoku/pdf/DocumentationToPdfExportModelMapperTest.kt new file mode 100644 index 0000000..df3d5bd --- /dev/null +++ b/app/src/test/java/de/medapp/wunddoku/pdf/DocumentationToPdfExportModelMapperTest.kt @@ -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) = + DocumentationBlock("state-$id", id, title, order, items) + + private fun documentation( + blocks: List, + photos: List = emptyList(), + signatures: List = 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) + } +} diff --git a/app/src/test/java/de/medapp/wunddoku/pdf/PdfFileNameGeneratorTest.kt b/app/src/test/java/de/medapp/wunddoku/pdf/PdfFileNameGeneratorTest.kt new file mode 100644 index 0000000..e9b6374 --- /dev/null +++ b/app/src/test/java/de/medapp/wunddoku/pdf/PdfFileNameGeneratorTest.kt @@ -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|\"", "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|\"" 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("???", "***")) + } +} -- 2.47.3 From 26cc81388e4f5b583202a143111d11fca6e4e517 Mon Sep 17 00:00:00 2001 From: NOrtmann1 Date: Fri, 9 Oct 2026 10:01:32 +0200 Subject: [PATCH 2/2] Update PatientRepositoryTest.kt --- .../wunddoku/data/repository/PatientRepositoryTest.kt | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/app/src/androidTest/java/de/medapp/wunddoku/data/repository/PatientRepositoryTest.kt b/app/src/androidTest/java/de/medapp/wunddoku/data/repository/PatientRepositoryTest.kt index c4e4468..093e157 100644 --- a/app/src/androidTest/java/de/medapp/wunddoku/data/repository/PatientRepositoryTest.kt +++ b/app/src/androidTest/java/de/medapp/wunddoku/data/repository/PatientRepositoryTest.kt @@ -77,7 +77,8 @@ class PatientRepositoryTest { assertEquals("0123 456789", loaded.phone1) assertEquals("0987 654321", loaded.phone2) assertNull(loaded.phone3) - assertEquals(saved.createdAt, loaded.createdAt) + // Die Datenbank speichert Millisekunden, upsert() liefert Instant.now() mit höherer Genauigkeit. + assertEquals(saved.createdAt.toEpochMilli(), loaded.createdAt.toEpochMilli()) } @Test @@ -87,11 +88,11 @@ class PatientRepositoryTest { // createdAt im übergebenen Objekt wird bewusst ignoriert (Instant.EPOCH aus dem Helper). val second = repo.upsert(patient(lastName = "Musterfrau")) - assertEquals(first.createdAt, second.createdAt) - assertTrue(second.updatedAt >= first.updatedAt) + 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, loaded.createdAt) + assertEquals(first.createdAt.toEpochMilli(), loaded.createdAt.toEpochMilli()) } @Test -- 2.47.3