Compare commits

..

16 Commits

Author SHA1 Message Date
niklas.ortmann 77f71973a7 QA-002
Room-Tests
2026-10-09 10:07:17 +02:00
niklas.ortmann 51370a9172 Merge pull request 'QA-001' (#15) from dev-QA001 into dev
Reviewed-on: #15
2026-10-09 10:02:08 +02:00
niklas.ortmann 26cc81388e Update PatientRepositoryTest.kt 2026-10-09 10:01:32 +02:00
niklas.ortmann dcfa11bef9 QA-001
- [x] Dateinamen-Tests vorhanden
- [x] Fortsetzen-Tests vorhanden
- [x] Blockstatus-Tests vorhanden
- [x] Repository-Tests vorhanden
2026-10-08 12:15:31 +02:00
niklas.ortmann e6bc99d144 Update DocumentationEditScreen.kt 2026-10-08 00:22:06 +02:00
niklas.ortmann 2bc0d959e1 README.md aktualisiert 2026-10-07 11:49:41 +02:00
niklas.ortmann b77f7f0f20 README.md aktualisiert 2026-10-07 11:48:14 +02:00
niklas.ortmann 70bdc84cfa README.md aktualisiert 2026-10-07 11:47:54 +02:00
niklas.ortmann cefecb86dd README.md aktualisiert 2026-10-07 11:44:59 +02:00
niklas.ortmann 1fdcceb149 Merge pull request 'neue Boxinhalte nach Kundenwunsch' (#10) from dev-Inhalte2 into dev
Reviewed-on: #10
2026-10-03 00:35:27 +02:00
niklas.ortmann 8a430d5089 neue Boxinhalte nach Kundenwunsch 2026-10-03 00:34:59 +02:00
niklas.ortmann 0c7eaabbb7 Merge pull request 'MEDIA-005' (#9) from dev-MEDIA-005 into dev
Reviewed-on: #9
2026-09-27 17:28:09 +02:00
niklas.ortmann 63bc93ebd2 MEDIA-005
Export fertig
2026-09-27 17:27:14 +02:00
niklas.ortmann 0f4ce2d2e1 Merge pull request 'MEDIA-005' (#8) from dev-MEDIA-005 into dev
Reviewed-on: #8
2026-09-24 16:49:06 +02:00
niklas.ortmann 08091a15a2 MEDIA-005
JPG-Export
2026-09-24 13:28:22 +02:00
niklas.ortmann 4a62d195ac Merge pull request 'Box-Inhalte' (#7) from dev-Inhalte into dev
Reviewed-on: #7
2026-09-24 13:13:23 +02:00
39 changed files with 5981 additions and 197 deletions
+37 -77
View File
@@ -7,7 +7,7 @@ Die App ermöglicht die Verwaltung von Patienten und Wunddokumentationen,
unterstützt Fotos und digitale Unterschriften und erzeugt aus unterstützt Fotos und digitale Unterschriften und erzeugt aus
abgeschlossenen Dokumentationen PDF-Dateien. abgeschlossenen Dokumentationen PDF-Dateien.
> **Projektstatus:** In Entwicklung > **Projektstatus:** beta1.1
------------------------------------------------------------------------ ------------------------------------------------------------------------
@@ -344,32 +344,6 @@ werden kann.
- JDK über Android Studio - JDK über Android Studio
- Android-Gerät oder Android Emulator - Android-Gerät oder Android Emulator
### Projekt öffnen
Repository klonen:
``` bash
git clone <repository-url>
cd VulneraDoc
```
Anschließend den Projektordner in Android Studio öffnen.
Android Studio führt beim ersten Öffnen einen Gradle-Sync durch und lädt
die benötigten Dependencies.
Danach:
``` text
Gradle Sync
↓
Build / Make Project
↓
Android-Gerät oder Emulator auswählen
↓
Run
```
------------------------------------------------------------------------ ------------------------------------------------------------------------
## Projektphasen ## Projektphasen
@@ -387,40 +361,40 @@ Run
### Phase 2 -- Projektgrundgerüst ### Phase 2 -- Projektgrundgerüst
- [x] Android-Projekt - [x] Android-Projekt
- [ ] Room-Datenbank - [x] Room-Datenbank
- [ ] Entities - [x] Entities
- [ ] DAOs - [x] DAOs
- [ ] Repositories - [x] Repositories
- [ ] Navigation - [x] Navigation
- [ ] ViewModels - [x] ViewModels
### Phase 3 -- Kernfunktionalität ### Phase 3 -- Kernfunktionalität
- [ ] Patientenverwaltung - [x] Patientenverwaltung
- [ ] Patientenliste - [x] Patientenliste
- [ ] Wunddokumentationsliste - [x] Wunddokumentationsliste
- [ ] Dokumentation erstellen - [x] Dokumentation erstellen
- [ ] Dokumentationsblöcke - [x] Dokumentationsblöcke
- [ ] Blocknavigation - [x] Blocknavigation
- [ ] Autosave - [x] Autosave
- [ ] Dokumentation bearbeiten - [x] Dokumentation bearbeiten
- [ ] Dokumentation fortsetzen - [x] Dokumentation fortsetzen
### Phase 4 -- Medien ### Phase 4 -- Medien
- [ ] Fotoimport - [x] Fotoimport
- [ ] Bildverwaltung - [x] Bildverwaltung
- [ ] Digitale Signatur - [x] Digitale Signatur
- [ ] Mehrere Signaturen - [x] Mehrere Signaturen
### Phase 5 -- PDF ### Phase 5 -- PDF
- [ ] PDF-Exporter - [x] PDF-Exporter
- [ ] Dokumentationsblöcke - [x] Dokumentationsblöcke
- [ ] Signaturen - [x] Signaturen
- [ ] Fotoanhang - [x] Fotoanhang
- [ ] PDF anzeigen - [x] PDF anzeigen
- [ ] PDF teilen - [x] PDF teilen
### Phase 6 -- Qualität und Stabilität ### Phase 6 -- Qualität und Stabilität
@@ -431,29 +405,15 @@ Run
- [ ] Tablet-Optimierung - [ ] Tablet-Optimierung
- [ ] Datenschutz-Review - [ ] Datenschutz-Review
------------------------------------------------------------------------ ### Phase 7 -- Multi-Device-Synchronisation
## Dokumentation - [ ] Geräteidentität und Pairing
- [ ] Automatische Geräteerkennung im WLAN
Die ausführliche technische Architektur befindet sich in der - [ ] Differenzabgleich / Sync-Protokoll
Projektdokumentation: - [ ] Patienten synchronisieren
- [ ] Wunddokumentationen synchronisieren
``` text - [ ] Fotos synchronisieren
docs/ - [ ] Konflikterkennung und Konfliktauflösung
└── Architekturplanung.md - [ ] Automatische Synchronisation
``` - [ ] Sync-Status und manueller Fallback
- [ ] Multi-Device- und Recovery-Tests
Dort werden unter anderem Datenmodell, Datenbankschema,
Navigationskonzept, Autosave, PDF-Erzeugung und Medienverwaltung
detailliert beschrieben.
------------------------------------------------------------------------
## Status
**VulneraDoc befindet sich aktuell in Entwicklung und ist nicht für den
produktiven Einsatz freigegeben.**
Insbesondere bei der Verarbeitung realer Patientendaten müssen vor einem
Produktiveinsatz die rechtlichen, datenschutzrechtlichen und
sicherheitstechnischen Anforderungen abschließend geprüft werden.
@@ -0,0 +1,635 @@
{
"formatVersion": 1,
"database": {
"version": 6,
"identityHash": "5e5d96efeef00d1ff7d404166a900974",
"entities": [
{
"tableName": "patients",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `firstName` TEXT NOT NULL, `lastName` TEXT NOT NULL, `birthDate` TEXT NOT NULL, `street` TEXT NOT NULL, `postalCode` TEXT NOT NULL, `city` TEXT NOT NULL, `phone1` TEXT NOT NULL, `phone2` TEXT, `phone3` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "firstName",
"columnName": "firstName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "lastName",
"columnName": "lastName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "birthDate",
"columnName": "birthDate",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "street",
"columnName": "street",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "postalCode",
"columnName": "postalCode",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "city",
"columnName": "city",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "phone1",
"columnName": "phone1",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "phone2",
"columnName": "phone2",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "phone3",
"columnName": "phone3",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "createdAt",
"columnName": "createdAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "updatedAt",
"columnName": "updatedAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_patients_lastName_firstName",
"unique": false,
"columnNames": [
"lastName",
"firstName"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_patients_lastName_firstName` ON `${TABLE_NAME}` (`lastName`, `firstName`)"
},
{
"name": "index_patients_birthDate",
"unique": false,
"columnNames": [
"birthDate"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_patients_birthDate` ON `${TABLE_NAME}` (`birthDate`)"
}
],
"foreignKeys": []
},
{
"tableName": "documentations",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `patientId` TEXT NOT NULL, `status` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `continuedFromDocumentationId` TEXT, `pdfLastGeneratedAt` INTEGER, `catalogVersion` INTEGER NOT NULL, `patientFirstNameSnapshot` TEXT NOT NULL, `patientLastNameSnapshot` TEXT NOT NULL, `patientBirthDateSnapshot` TEXT NOT NULL, `patientStreetSnapshot` TEXT NOT NULL, `patientPostalCodeSnapshot` TEXT NOT NULL, `patientCitySnapshot` TEXT NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`patientId`) REFERENCES `patients`(`id`) ON UPDATE NO ACTION ON DELETE RESTRICT , FOREIGN KEY(`continuedFromDocumentationId`) REFERENCES `documentations`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "patientId",
"columnName": "patientId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "status",
"columnName": "status",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "createdAt",
"columnName": "createdAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "updatedAt",
"columnName": "updatedAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "continuedFromDocumentationId",
"columnName": "continuedFromDocumentationId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "pdfLastGeneratedAt",
"columnName": "pdfLastGeneratedAt",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "catalogVersion",
"columnName": "catalogVersion",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "patientFirstNameSnapshot",
"columnName": "patientFirstNameSnapshot",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "patientLastNameSnapshot",
"columnName": "patientLastNameSnapshot",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "patientBirthDateSnapshot",
"columnName": "patientBirthDateSnapshot",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "patientStreetSnapshot",
"columnName": "patientStreetSnapshot",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "patientPostalCodeSnapshot",
"columnName": "patientPostalCodeSnapshot",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "patientCitySnapshot",
"columnName": "patientCitySnapshot",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_documentations_patientId_createdAt",
"unique": false,
"columnNames": [
"patientId",
"createdAt"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_documentations_patientId_createdAt` ON `${TABLE_NAME}` (`patientId`, `createdAt`)"
},
{
"name": "index_documentations_status",
"unique": false,
"columnNames": [
"status"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_documentations_status` ON `${TABLE_NAME}` (`status`)"
},
{
"name": "index_documentations_continuedFromDocumentationId",
"unique": false,
"columnNames": [
"continuedFromDocumentationId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_documentations_continuedFromDocumentationId` ON `${TABLE_NAME}` (`continuedFromDocumentationId`)"
}
],
"foreignKeys": [
{
"table": "patients",
"onDelete": "RESTRICT",
"onUpdate": "NO ACTION",
"columns": [
"patientId"
],
"referencedColumns": [
"id"
]
},
{
"table": "documentations",
"onDelete": "SET NULL",
"onUpdate": "NO ACTION",
"columns": [
"continuedFromDocumentationId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "block_states",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `documentationId` TEXT NOT NULL, `blockDefinitionId` TEXT NOT NULL, `titleSnapshot` TEXT NOT NULL, `blockOrder` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`documentationId`) REFERENCES `documentations`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "documentationId",
"columnName": "documentationId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "blockDefinitionId",
"columnName": "blockDefinitionId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "titleSnapshot",
"columnName": "titleSnapshot",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "blockOrder",
"columnName": "blockOrder",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "updatedAt",
"columnName": "updatedAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_block_states_documentationId",
"unique": false,
"columnNames": [
"documentationId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_block_states_documentationId` ON `${TABLE_NAME}` (`documentationId`)"
},
{
"name": "index_block_states_documentationId_blockDefinitionId",
"unique": true,
"columnNames": [
"documentationId",
"blockDefinitionId"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_block_states_documentationId_blockDefinitionId` ON `${TABLE_NAME}` (`documentationId`, `blockDefinitionId`)"
}
],
"foreignKeys": [
{
"table": "documentations",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"documentationId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "check_item_states",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `blockStateId` TEXT NOT NULL, `checkItemDefinitionId` TEXT NOT NULL, `labelSnapshot` TEXT NOT NULL, `itemOrder` INTEGER NOT NULL, `checked` INTEGER NOT NULL, `isFreeText` INTEGER NOT NULL, `freeTextValue` TEXT NOT NULL, `isStaticHint` INTEGER NOT NULL, `inputSpec` TEXT NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`blockStateId`) REFERENCES `block_states`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "blockStateId",
"columnName": "blockStateId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "checkItemDefinitionId",
"columnName": "checkItemDefinitionId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "labelSnapshot",
"columnName": "labelSnapshot",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "itemOrder",
"columnName": "itemOrder",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "checked",
"columnName": "checked",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isFreeText",
"columnName": "isFreeText",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "freeTextValue",
"columnName": "freeTextValue",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "isStaticHint",
"columnName": "isStaticHint",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "inputSpec",
"columnName": "inputSpec",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_check_item_states_blockStateId",
"unique": false,
"columnNames": [
"blockStateId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_check_item_states_blockStateId` ON `${TABLE_NAME}` (`blockStateId`)"
},
{
"name": "index_check_item_states_blockStateId_checkItemDefinitionId",
"unique": true,
"columnNames": [
"blockStateId",
"checkItemDefinitionId"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_check_item_states_blockStateId_checkItemDefinitionId` ON `${TABLE_NAME}` (`blockStateId`, `checkItemDefinitionId`)"
}
],
"foreignKeys": [
{
"table": "block_states",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"blockStateId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "photos",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `documentationId` TEXT NOT NULL, `fileName` TEXT NOT NULL, `orderIndex` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`documentationId`) REFERENCES `documentations`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "documentationId",
"columnName": "documentationId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "fileName",
"columnName": "fileName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "orderIndex",
"columnName": "orderIndex",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "createdAt",
"columnName": "createdAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_photos_documentationId_orderIndex",
"unique": false,
"columnNames": [
"documentationId",
"orderIndex"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_photos_documentationId_orderIndex` ON `${TABLE_NAME}` (`documentationId`, `orderIndex`)"
}
],
"foreignKeys": [
{
"table": "documentations",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"documentationId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "signatures",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `documentationId` TEXT NOT NULL, `signerName` TEXT NOT NULL, `fileName` TEXT NOT NULL, `orderIndex` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`documentationId`) REFERENCES `documentations`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "documentationId",
"columnName": "documentationId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "signerName",
"columnName": "signerName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "fileName",
"columnName": "fileName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "orderIndex",
"columnName": "orderIndex",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "createdAt",
"columnName": "createdAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_signatures_documentationId",
"unique": false,
"columnNames": [
"documentationId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_signatures_documentationId` ON `${TABLE_NAME}` (`documentationId`)"
}
],
"foreignKeys": [
{
"table": "documentations",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"documentationId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "wound_carers",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `signatureFileName` TEXT NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "signatureFileName",
"columnName": "signatureFileName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "updatedAt",
"columnName": "updatedAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '5e5d96efeef00d1ff7d404166a900974')"
]
}
}
@@ -0,0 +1,259 @@
package de.medapp.wunddoku.data.dao
import android.database.sqlite.SQLiteConstraintException
import de.medapp.wunddoku.testutil.RoomTestBase
import de.medapp.wunddoku.testutil.blockStateEntity
import de.medapp.wunddoku.testutil.checkItemStateEntity
import de.medapp.wunddoku.testutil.expectThrows
import de.medapp.wunddoku.testutil.runDbTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/** DAO-Tests für [BlockStateDao] und [CheckItemStateDao]. */
class BlockAndCheckItemDaoTest : RoomTestBase() {
// --- BlockStateDao -------------------------------------------------------------------
@Test
fun blockStates_insertAll_und_getById_liefern_alle_felder() = runDbTest {
seedDocumentation()
val block = blockStateEntity(title = "Größe & Tiefe", order = 3, updatedAt = 4_000L)
blockStateDao.insertAll(listOf(block))
assertEquals(block, blockStateDao.getById("b-1"))
assertNull(blockStateDao.getById("unbekannt"))
}
@Test
fun blockStates_getForDocumentation_sortiert_nach_blockOrder_und_filtert_nach_dokumentation() = runDbTest {
seedDocumentation(id = "d-1")
seedDocumentation(id = "d-2")
blockStateDao.insertAll(
listOf(
blockStateEntity(id = "b-3", documentationId = "d-1", blockDefinitionId = "c", order = 3),
blockStateEntity(id = "b-1", documentationId = "d-1", blockDefinitionId = "a", order = 1),
blockStateEntity(id = "b-2", documentationId = "d-1", blockDefinitionId = "b", order = 2),
blockStateEntity(id = "b-x", documentationId = "d-2", blockDefinitionId = "a", order = 1),
)
)
assertEquals(listOf("b-1", "b-2", "b-3"), blockStateDao.getForDocumentation("d-1").map { it.id })
assertEquals(listOf("b-x"), blockStateDao.getForDocumentation("d-2").map { it.id })
assertTrue(blockStateDao.getForDocumentation("unbekannt").isEmpty())
}
@Test
fun blockStates_gleiche_blockdefinition_in_derselben_dokumentation_ist_verboten() = runDbTest {
seedDocumentation()
blockStateDao.insertAll(listOf(blockStateEntity(id = "b-1", blockDefinitionId = "wundart")))
expectThrows<SQLiteConstraintException> {
blockStateDao.insertAll(listOf(blockStateEntity(id = "b-2", blockDefinitionId = "wundart")))
}
assertEquals(1, countRows("block_states"))
}
@Test
fun blockStates_gleiche_blockdefinition_in_verschiedenen_dokumentationen_ist_erlaubt() = runDbTest {
seedDocumentation(id = "d-1")
seedDocumentation(id = "d-2")
blockStateDao.insertAll(
listOf(
blockStateEntity(id = "b-1", documentationId = "d-1", blockDefinitionId = "wundart"),
blockStateEntity(id = "b-2", documentationId = "d-2", blockDefinitionId = "wundart"),
)
)
assertEquals(2, countRows("block_states"))
}
@Test
fun blockStates_mit_unbekannter_dokumentation_wirft() = runDbTest {
expectThrows<SQLiteConstraintException> {
blockStateDao.insertAll(listOf(blockStateEntity(documentationId = "gibt-es-nicht")))
}
assertEquals(0, countRows("block_states"))
}
@Test
fun blockStates_insertAll_mit_leerer_liste_ist_unkritisch() = runDbTest {
blockStateDao.insertAll(emptyList())
assertEquals(0, countRows("block_states"))
}
// --- CheckItemStateDao ---------------------------------------------------------------
private suspend fun seedBlock(id: String = "b-1", documentationId: String = "d-1", definitionId: String = "wundart") {
if (documentationDao.getById(documentationId) == null) seedDocumentation(id = documentationId)
blockStateDao.insertAll(listOf(blockStateEntity(id = id, documentationId = documentationId, blockDefinitionId = definitionId)))
}
@Test
fun checkItems_insertAll_und_getForBlockState_liefern_alle_felder_inklusive_standardwerten() = runDbTest {
seedBlock()
val plain = checkItemStateEntity(id = "c-1", definitionId = "plain", order = 1)
val full = checkItemStateEntity(
id = "c-2", definitionId = "full", order = 2, checked = true, isFreeText = true,
freeTextValue = "Text", isStaticHint = false, inputSpec = "SPEC",
)
checkItemStateDao.insertAll(listOf(plain, full))
val loaded = checkItemStateDao.getForBlockState("b-1")
assertEquals(listOf(plain, full), loaded)
assertFalse(loaded[0].checked)
assertFalse(loaded[0].isFreeText)
assertEquals("", loaded[0].freeTextValue)
assertFalse(loaded[0].isStaticHint)
assertEquals("", loaded[0].inputSpec)
}
@Test
fun checkItems_getForBlockState_sortiert_nach_itemOrder_und_filtert_nach_block() = runDbTest {
seedBlock(id = "b-1", definitionId = "a")
seedBlock(id = "b-2", definitionId = "b")
checkItemStateDao.insertAll(
listOf(
checkItemStateEntity(id = "c-3", blockStateId = "b-1", definitionId = "z", order = 3),
checkItemStateEntity(id = "c-1", blockStateId = "b-1", definitionId = "x", order = 1),
checkItemStateEntity(id = "c-2", blockStateId = "b-1", definitionId = "y", order = 2),
checkItemStateEntity(id = "c-x", blockStateId = "b-2", definitionId = "x", order = 1),
)
)
assertEquals(listOf("c-1", "c-2", "c-3"), checkItemStateDao.getForBlockState("b-1").map { it.id })
assertEquals(listOf("c-x"), checkItemStateDao.getForBlockState("b-2").map { it.id })
assertTrue(checkItemStateDao.getForBlockState("unbekannt").isEmpty())
}
@Test
fun checkItems_setChecked_setzt_und_entfernt_den_haken_ohne_andere_felder_zu_aendern() = runDbTest {
seedBlock()
val item = checkItemStateEntity(freeTextValue = "bleibt", inputSpec = "bleibt auch")
checkItemStateDao.insertAll(listOf(item))
checkItemStateDao.setChecked("c-1", true)
assertEquals(item.copy(checked = true), checkItemStateDao.getForBlockState("b-1").single())
checkItemStateDao.setChecked("c-1", false)
assertEquals(item, checkItemStateDao.getForBlockState("b-1").single())
}
@Test
fun checkItems_setChecked_betrifft_nur_die_angegebene_option() = runDbTest {
seedBlock()
checkItemStateDao.insertAll(
listOf(
checkItemStateEntity(id = "c-1", definitionId = "a", order = 1),
checkItemStateEntity(id = "c-2", definitionId = "b", order = 2),
)
)
checkItemStateDao.setChecked("c-1", true)
val byId = checkItemStateDao.getForBlockState("b-1").associateBy { it.id }
assertTrue(byId.getValue("c-1").checked)
assertFalse(byId.getValue("c-2").checked)
}
@Test
fun checkItems_setChecked_und_updateFreeTextValue_mit_unbekannter_id_sind_wirkungslos() = runDbTest {
seedBlock()
val item = checkItemStateEntity()
checkItemStateDao.insertAll(listOf(item))
checkItemStateDao.setChecked("unbekannt", true)
checkItemStateDao.updateFreeTextValue("unbekannt", "x")
assertEquals(listOf(item), checkItemStateDao.getForBlockState("b-1"))
}
@Test
fun checkItems_updateFreeTextValue_aendert_nur_den_text_und_nicht_den_haken() = runDbTest {
seedBlock()
checkItemStateDao.insertAll(listOf(checkItemStateEntity(isFreeText = true, checked = true)))
checkItemStateDao.updateFreeTextValue("c-1", "Rötung am Wundrand")
val loaded = checkItemStateDao.getForBlockState("b-1").single()
assertEquals("Rötung am Wundrand", loaded.freeTextValue)
assertTrue(loaded.checked)
assertTrue(loaded.isFreeText)
}
@Test
fun checkItems_freitext_laesst_sich_leeren() = runDbTest {
seedBlock()
checkItemStateDao.insertAll(listOf(checkItemStateEntity(isFreeText = true, freeTextValue = "alt")))
checkItemStateDao.updateFreeTextValue("c-1", "")
assertEquals("", checkItemStateDao.getForBlockState("b-1").single().freeTextValue)
}
@Test
fun checkItems_sonderzeichen_und_sql_fragmente_werden_als_reiner_text_gespeichert() = runDbTest {
seedBlock()
checkItemStateDao.insertAll(listOf(checkItemStateEntity(isFreeText = true)))
val text = "O'Brien \"Test\" ; DROP TABLE patients; -- Größe 3,5 × 2 cm\nzweite Zeile\t😀 100% _x_"
checkItemStateDao.updateFreeTextValue("c-1", text)
assertEquals(text, checkItemStateDao.getForBlockState("b-1").single().freeTextValue)
assertEquals(1, countRows("patients")) // Tabelle existiert weiterhin, der Seed-Patient ist unangetastet
}
@Test
fun checkItems_inputSpec_mit_steuerzeichen_der_codierung_bleibt_verlustfrei_erhalten() = runDbTest {
// InputSpecCodec nutzt \u001E, \u001F und \u001D als Trennzeichen.
seedBlock()
val spec = "tpl\u001EN\u001Flabel\u001F3\u001Dx\u001Ey"
checkItemStateDao.insertAll(listOf(checkItemStateEntity(inputSpec = spec)))
assertEquals(spec, checkItemStateDao.getForBlockState("b-1").single().inputSpec)
}
@Test
fun checkItems_gleiche_optionsdefinition_im_selben_block_ist_verboten() = runDbTest {
seedBlock()
checkItemStateDao.insertAll(listOf(checkItemStateEntity(id = "c-1", definitionId = "dup")))
expectThrows<SQLiteConstraintException> {
checkItemStateDao.insertAll(listOf(checkItemStateEntity(id = "c-2", definitionId = "dup")))
}
assertEquals(1, countRows("check_item_states"))
}
@Test
fun checkItems_gleiche_optionsdefinition_in_verschiedenen_bloecken_ist_erlaubt() = runDbTest {
seedBlock(id = "b-1", definitionId = "a")
seedBlock(id = "b-2", definitionId = "b")
checkItemStateDao.insertAll(
listOf(
checkItemStateEntity(id = "c-1", blockStateId = "b-1", definitionId = "same"),
checkItemStateEntity(id = "c-2", blockStateId = "b-2", definitionId = "same"),
)
)
assertEquals(2, countRows("check_item_states"))
}
@Test
fun checkItems_mit_unbekanntem_block_wirft() = runDbTest {
expectThrows<SQLiteConstraintException> {
checkItemStateDao.insertAll(listOf(checkItemStateEntity(blockStateId = "gibt-es-nicht")))
}
assertEquals(0, countRows("check_item_states"))
}
}
@@ -0,0 +1,222 @@
package de.medapp.wunddoku.data.dao
import android.database.sqlite.SQLiteConstraintException
import de.medapp.wunddoku.domain.model.DocumentationStatus
import de.medapp.wunddoku.testutil.RoomTestBase
import de.medapp.wunddoku.testutil.awaitMatching
import de.medapp.wunddoku.testutil.documentationEntity
import de.medapp.wunddoku.testutil.expectThrows
import de.medapp.wunddoku.testutil.runDbTest
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.first
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/** DAO-Tests für [DocumentationDao] (ohne Suche und Relationen, s. SearchQueryTest / DocumentationRelationsTest). */
class DocumentationDaoTest : RoomTestBase() {
// --- insert / getById ----------------------------------------------------------------
@Test
fun insert_und_getById_liefern_alle_felder_inklusive_snapshots() = runDbTest {
seedPatient()
val entity = documentationEntity(
status = DocumentationStatus.COMPLETED,
createdAt = 1_000L,
updatedAt = 2_000L,
pdfLastGeneratedAt = 3_000L,
catalogVersion = 3,
firstNameSnapshot = "Erika",
lastNameSnapshot = "Musterfrau",
birthDateSnapshot = "1975-12-01",
streetSnapshot = "Hauptstraße 5",
postalCodeSnapshot = "54321",
citySnapshot = "Beispielstadt",
)
documentationDao.insert(entity)
assertEquals(entity, documentationDao.getById("d-1"))
}
@Test
fun nullable_felder_bleiben_null() = runDbTest {
seedDocumentation()
val loaded = documentationDao.getById("d-1")!!
assertNull(loaded.continuedFromDocumentationId)
assertNull(loaded.pdfLastGeneratedAt)
}
@Test
fun getById_liefert_null_fuer_unbekannte_id() = runDbTest {
assertNull(documentationDao.getById("unbekannt"))
}
@Test
fun status_wird_als_enum_name_gespeichert_und_vollstaendig_zurueckgelesen() = runDbTest {
seedPatient()
DocumentationStatus.values().forEachIndexed { index, status ->
documentationDao.insert(documentationEntity(id = "d-$index", status = status))
}
DocumentationStatus.values().forEachIndexed { index, status ->
assertEquals(status, documentationDao.getById("d-$index")!!.status)
assertEquals(status.name, queryString("SELECT status FROM documentations WHERE id = 'd-$index'"))
}
}
// --- Foreign Keys --------------------------------------------------------------------
@Test
fun insert_mit_unbekanntem_patienten_wirft() = runDbTest {
expectThrows<SQLiteConstraintException> {
documentationDao.insert(documentationEntity(patientId = "gibt-es-nicht"))
}
assertEquals(0, countRows("documentations"))
}
@Test
fun insert_mit_unbekannter_fortsetzungsquelle_wirft() = runDbTest {
seedPatient()
expectThrows<SQLiteConstraintException> {
documentationDao.insert(documentationEntity(continuedFromDocumentationId = "gibt-es-nicht"))
}
assertEquals(0, countRows("documentations"))
}
@Test
fun fortsetzungsquelle_wird_beim_loeschen_der_quelle_auf_null_gesetzt() = runDbTest {
val source = seedDocumentation(id = "d-source")
documentationDao.insert(documentationEntity(id = "d-copy", continuedFromDocumentationId = "d-source"))
assertEquals("d-source", documentationDao.getById("d-copy")!!.continuedFromDocumentationId)
documentationDao.delete(source)
assertNull(documentationDao.getById("d-source"))
val copy = documentationDao.getById("d-copy")
assertNotNull("Die Fortsetzung darf nicht mitgelöscht werden", copy)
assertNull(copy!!.continuedFromDocumentationId)
}
// --- update / delete -----------------------------------------------------------------
@Test
fun update_aendert_nur_die_uebergebene_dokumentation() = runDbTest {
val first = seedDocumentation(id = "d-1")
val second = seedDocumentation(id = "d-2")
documentationDao.update(first.copy(status = DocumentationStatus.COMPLETED, updatedAt = 9_000L, pdfLastGeneratedAt = 9_500L))
val changed = documentationDao.getById("d-1")!!
assertEquals(DocumentationStatus.COMPLETED, changed.status)
assertEquals(9_000L, changed.updatedAt)
assertEquals(9_500L, changed.pdfLastGeneratedAt)
assertEquals(second, documentationDao.getById("d-2"))
}
@Test
fun update_einer_unbekannten_id_legt_nichts_an() = runDbTest {
seedPatient()
documentationDao.update(documentationEntity(id = "geist"))
assertNull(documentationDao.getById("geist"))
}
@Test
fun delete_entfernt_nur_die_uebergebene_dokumentation_und_laesst_den_patienten_bestehen() = runDbTest {
val first = seedDocumentation(id = "d-1")
seedDocumentation(id = "d-2")
documentationDao.delete(first)
assertNull(documentationDao.getById("d-1"))
assertNotNull(documentationDao.getById("d-2"))
assertNotNull(patientDao.getById("p-1"))
}
// --- observeAll / countForPatient ----------------------------------------------------
@Test
fun observeAll_sortiert_nach_erstelldatum_absteigend() = runDbTest {
seedDocumentation(id = "d-alt", createdAt = 1_000L)
seedDocumentation(id = "d-neu", createdAt = 3_000L)
seedDocumentation(id = "d-mitte", createdAt = 2_000L)
val ids = documentationDao.observeAll().first().map { it.id }
assertEquals(listOf("d-neu", "d-mitte", "d-alt"), ids)
}
@Test
fun observeAll_liefert_leere_liste_ohne_dokumentationen() = runDbTest {
assertTrue(documentationDao.observeAll().first().isEmpty())
}
@Test
fun observeAll_aktualisiert_sich_bei_insert_update_und_delete() = runDbTest {
seedPatient()
val afterInsert = async { documentationDao.observeAll().awaitMatching { it.size == 1 } }
val entity = documentationEntity(id = "d-1")
documentationDao.insert(entity)
assertEquals("d-1", afterInsert.await().single().id)
val afterUpdate = async {
documentationDao.observeAll().awaitMatching { list -> list.singleOrNull()?.status == DocumentationStatus.COMPLETED }
}
documentationDao.update(entity.copy(status = DocumentationStatus.COMPLETED))
afterUpdate.await()
val afterDelete = async { documentationDao.observeAll().awaitMatching { it.isEmpty() } }
documentationDao.delete(entity)
assertTrue(afterDelete.await().isEmpty())
}
@Test
fun countForPatient_zaehlt_nur_dokumentationen_des_jeweiligen_patienten() = runDbTest {
seedDocumentation(id = "d-1", patientId = "p-1")
seedDocumentation(id = "d-2", patientId = "p-1")
seedDocumentation(id = "d-3", patientId = "p-2")
assertEquals(2, documentationDao.countForPatient("p-1"))
assertEquals(1, documentationDao.countForPatient("p-2"))
assertEquals(0, documentationDao.countForPatient("unbekannt"))
}
// --- getLatestForPatient -------------------------------------------------------------
@Test
fun getLatestForPatient_liefert_die_zuletzt_erstellte_dokumentation_des_patienten() = runDbTest {
seedDocumentation(id = "d-alt", patientId = "p-1", createdAt = 1_000L)
seedDocumentation(id = "d-neu", patientId = "p-1", createdAt = 3_000L)
seedDocumentation(id = "d-mitte", patientId = "p-1", createdAt = 2_000L)
assertEquals("d-neu", documentationDao.getLatestForPatient("p-1")?.documentation?.id)
}
@Test
fun getLatestForPatient_ignoriert_dokumentationen_anderer_patienten() = runDbTest {
seedDocumentation(id = "d-1", patientId = "p-1", createdAt = 1_000L)
seedDocumentation(id = "d-2", patientId = "p-2", createdAt = 9_000L)
assertEquals("d-1", documentationDao.getLatestForPatient("p-1")?.documentation?.id)
assertEquals("d-2", documentationDao.getLatestForPatient("p-2")?.documentation?.id)
}
@Test
fun getLatestForPatient_liefert_null_ohne_dokumentation() = runDbTest {
seedPatient()
assertNull(documentationDao.getLatestForPatient("p-1"))
assertNull(documentationDao.getLatestForPatient("unbekannt"))
}
}
@@ -0,0 +1,229 @@
package de.medapp.wunddoku.data.dao
import de.medapp.wunddoku.data.entity.relations.DocumentationWithDetails
import de.medapp.wunddoku.testutil.RoomTestBase
import de.medapp.wunddoku.testutil.blockStateEntity
import de.medapp.wunddoku.testutil.checkItemStateEntity
import de.medapp.wunddoku.testutil.photoEntity
import de.medapp.wunddoku.testutil.runDbTest
import de.medapp.wunddoku.testutil.signatureEntity
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Relationstests (QA-002): [DocumentationWithDetails] (Dokumentation -> Blöcke -> Optionen,
* Fotos, Signaturen) sowie Cascade-/Restrict-Verhalten der Foreign Keys.
*/
class DocumentationRelationsTest : RoomTestBase() {
/**
* Dokumentation [docId] mit 2 Blöcken ("<prefix>-b1" order 2, "<prefix>-b2" order 1),
* 3 Optionen (b1: c1, c2 / b2: c3), 2 Fotos und 2 Signaturen.
*/
private suspend fun seedWithChildren(docId: String, createdAt: Long, prefix: String) {
seedDocumentation(id = docId, patientId = "p-1", createdAt = createdAt)
blockStateDao.insertAll(
listOf(
blockStateEntity(id = "$prefix-b1", documentationId = docId, blockDefinitionId = "wundart", order = 2),
blockStateEntity(id = "$prefix-b2", documentationId = docId, blockDefinitionId = "groesse", order = 1),
)
)
checkItemStateDao.insertAll(
listOf(
checkItemStateEntity(id = "$prefix-c1", blockStateId = "$prefix-b1", definitionId = "a", order = 1),
checkItemStateEntity(id = "$prefix-c2", blockStateId = "$prefix-b1", definitionId = "b", order = 2),
checkItemStateEntity(id = "$prefix-c3", blockStateId = "$prefix-b2", definitionId = "a", order = 1),
)
)
photoDao.insert(photoEntity(id = "$prefix-ph1", documentationId = docId, orderIndex = 0))
photoDao.insert(photoEntity(id = "$prefix-ph2", documentationId = docId, orderIndex = 1))
signatureDao.insert(signatureEntity(id = "$prefix-s1", documentationId = docId, orderIndex = 0))
signatureDao.insert(signatureEntity(id = "$prefix-s2", documentationId = docId, orderIndex = 1))
}
private fun DocumentationWithDetails.itemIdsByBlock(): Map<String, Set<String>> =
blocks.associate { it.blockState.id to it.checkItems.map { item -> item.id }.toSet() }
// --- Laden ---------------------------------------------------------------------------
@Test
fun getFullDocumentation_liefert_dokumentation_mit_allen_kindern() = runDbTest {
seedWithChildren("d-a", 1_000L, "a")
val full = documentationDao.getFullDocumentation("d-a")!!
assertEquals("d-a", full.documentation.id)
assertEquals(setOf("a-b1", "a-b2"), full.blocks.map { it.blockState.id }.toSet())
assertEquals(setOf("a-ph1", "a-ph2"), full.photos.map { it.id }.toSet())
assertEquals(setOf("a-s1", "a-s2"), full.signatures.map { it.id }.toSet())
}
@Test
fun jede_option_haengt_am_richtigen_block() = runDbTest {
seedWithChildren("d-a", 1_000L, "a")
val full = documentationDao.getFullDocumentation("d-a")!!
assertEquals(
mapOf("a-b1" to setOf("a-c1", "a-c2"), "a-b2" to setOf("a-c3")),
full.itemIdsByBlock(),
)
}
@Test
fun relation_enthaelt_die_vollstaendigen_entities_nicht_nur_ids() = runDbTest {
seedWithChildren("d-a", 1_000L, "a")
val full = documentationDao.getFullDocumentation("d-a")!!
assertEquals(documentationDao.getById("d-a"), full.documentation)
assertEquals(blockStateDao.getById("a-b1"), full.blocks.single { it.blockState.id == "a-b1" }.blockState)
assertEquals(photoDao.getById("a-ph1"), full.photos.single { it.id == "a-ph1" })
assertEquals(signatureDao.getById("a-s1"), full.signatures.single { it.id == "a-s1" })
assertEquals(
checkItemStateDao.getForBlockState("a-b1"),
full.blocks.single { it.blockState.id == "a-b1" }.checkItems.sortedBy { it.itemOrder },
)
}
@Test
fun relation_vermischt_keine_daten_verschiedener_dokumentationen() = runDbTest {
seedWithChildren("d-a", 1_000L, "a")
seedWithChildren("d-b", 2_000L, "b")
val a = documentationDao.getFullDocumentation("d-a")!!
val b = documentationDao.getFullDocumentation("d-b")!!
assertTrue(a.blocks.all { it.blockState.documentationId == "d-a" })
assertTrue(a.photos.all { it.documentationId == "d-a" })
assertTrue(a.signatures.all { it.documentationId == "d-a" })
assertEquals(setOf("a-c1", "a-c2", "a-c3"), a.itemIdsByBlock().values.flatten().toSet())
assertEquals(setOf("b-c1", "b-c2", "b-c3"), b.itemIdsByBlock().values.flatten().toSet())
}
@Test
fun dokumentation_ohne_kinder_liefert_leere_listen_statt_null() = runDbTest {
seedDocumentation(id = "d-leer")
val full = documentationDao.getFullDocumentation("d-leer")!!
assertTrue(full.blocks.isEmpty())
assertTrue(full.photos.isEmpty())
assertTrue(full.signatures.isEmpty())
}
@Test
fun block_ohne_optionen_liefert_leere_optionsliste() = runDbTest {
seedDocumentation()
blockStateDao.insertAll(listOf(blockStateEntity()))
val block = documentationDao.getFullDocumentation("d-1")!!.blocks.single()
assertTrue(block.checkItems.isEmpty())
}
@Test
fun getFullDocumentation_liefert_null_fuer_unbekannte_id() = runDbTest {
assertNull(documentationDao.getFullDocumentation("unbekannt"))
}
@Test
fun getLatestForPatient_laedt_auch_die_kinder_der_neuesten_dokumentation() = runDbTest {
seedWithChildren("d-a", 1_000L, "a")
seedWithChildren("d-b", 2_000L, "b")
val latest = documentationDao.getLatestForPatient("p-1")!!
assertEquals("d-b", latest.documentation.id)
assertEquals(setOf("b-b1", "b-b2"), latest.blocks.map { it.blockState.id }.toSet())
assertEquals(setOf("b-c1", "b-c2", "b-c3"), latest.itemIdsByBlock().values.flatten().toSet())
assertEquals(2, latest.photos.size)
assertEquals(2, latest.signatures.size)
}
@Test
fun grosse_dokumentation_wird_vollstaendig_und_korrekt_zugeordnet_geladen() = runDbTest {
seedDocumentation()
val blocks = (1..25).map { b ->
blockStateEntity(id = "b-$b", blockDefinitionId = "block_$b", title = "Block $b", order = b)
}
blockStateDao.insertAll(blocks)
val items = (1..25).flatMap { b ->
(1..20).map { i ->
checkItemStateEntity(id = "c-$b-$i", blockStateId = "b-$b", definitionId = "item_$i", order = i)
}
}
checkItemStateDao.insertAll(items)
val full = documentationDao.getFullDocumentation("d-1")!!
assertEquals(25, full.blocks.size)
assertEquals(500, full.blocks.sumOf { it.checkItems.size })
for (block in full.blocks) {
assertEquals(20, block.checkItems.size)
assertTrue(block.checkItems.all { it.blockStateId == block.blockState.id })
}
}
// --- Cascade / Restrict --------------------------------------------------------------
@Test
fun foreign_keys_sind_in_der_testdatenbank_aktiviert() = runDbTest {
assertEquals(1, queryInt("PRAGMA foreign_keys"))
}
@Test
fun dokumentation_loeschen_entfernt_bloecke_optionen_fotos_und_signaturen_per_cascade() = runDbTest {
seedWithChildren("d-a", 1_000L, "a")
documentationDao.delete(documentationDao.getById("d-a")!!)
assertEquals(0, countRows("documentations"))
assertEquals(0, countRows("block_states"))
assertEquals(0, countRows("check_item_states"))
assertEquals(0, countRows("photos"))
assertEquals(0, countRows("signatures"))
}
@Test
fun dokumentation_loeschen_laesst_patient_und_andere_dokumentationen_unangetastet() = runDbTest {
seedWithChildren("d-a", 1_000L, "a")
seedWithChildren("d-b", 2_000L, "b")
documentationDao.delete(documentationDao.getById("d-a")!!)
assertNotNull(patientDao.getById("p-1"))
val remaining = documentationDao.getFullDocumentation("d-b")!!
assertEquals(2, remaining.blocks.size)
assertEquals(3, remaining.itemIdsByBlock().values.sumOf { it.size })
assertEquals(2, remaining.photos.size)
assertEquals(2, remaining.signatures.size)
assertEquals(2, countRows("block_states"))
assertEquals(3, countRows("check_item_states"))
}
@Test
fun block_loeschen_entfernt_seine_optionen_per_cascade_aber_nicht_die_anderer_bloecke() = runDbTest {
seedWithChildren("d-a", 1_000L, "a")
execSql("DELETE FROM block_states WHERE id = 'a-b1'")
assertEquals(listOf("a-c3"), checkItemStateDao.getForBlockState("a-b2").map { it.id })
assertTrue(checkItemStateDao.getForBlockState("a-b1").isEmpty())
assertEquals(1, countRows("check_item_states"))
}
@Test
fun patient_mit_dokumentation_kann_nicht_geloescht_werden_und_kinder_bleiben_erhalten() = runDbTest {
seedWithChildren("d-a", 1_000L, "a")
val failed = runCatching { patientDao.delete(patientDao.getById("p-1")!!) }
assertTrue(failed.isFailure)
assertNotNull(documentationDao.getFullDocumentation("d-a"))
assertEquals(3, countRows("check_item_states"))
}
}
@@ -0,0 +1,232 @@
package de.medapp.wunddoku.data.dao
import android.database.sqlite.SQLiteConstraintException
import de.medapp.wunddoku.testutil.RoomTestBase
import de.medapp.wunddoku.testutil.awaitMatching
import de.medapp.wunddoku.testutil.expectThrows
import de.medapp.wunddoku.testutil.patientEntity
import de.medapp.wunddoku.testutil.runDbTest
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.first
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/** DAO-Tests für [PatientDao]. */
class PatientDaoTest : RoomTestBase() {
// --- insert / getById ----------------------------------------------------------------
@Test
fun insert_und_getById_liefern_alle_felder_zurueck() = runDbTest {
val entity = patientEntity(phone2 = "0987 654321", phone3 = "0555 123")
patientDao.insert(entity)
assertEquals(entity, patientDao.getById("p-1"))
}
@Test
fun optionale_telefonnummern_bleiben_null() = runDbTest {
patientDao.insert(patientEntity(phone2 = null, phone3 = null))
val loaded = patientDao.getById("p-1")!!
assertNull(loaded.phone2)
assertNull(loaded.phone3)
}
@Test
fun umlaute_und_sonderzeichen_bleiben_unveraendert() = runDbTest {
val entity = patientEntity(
firstName = "Jörg-Åke", lastName = "O'Brien-Müller", street = "Straße \"des\" 17. Juni 1/2", city = "Köln-Ehrenfeld",
)
patientDao.insert(entity)
assertEquals(entity, patientDao.getById("p-1"))
}
@Test
fun getById_liefert_null_fuer_unbekannte_id() = runDbTest {
assertNull(patientDao.getById("unbekannt"))
}
@Test
fun insert_mit_vorhandener_id_wirft_und_laesst_den_bestehenden_datensatz_unveraendert() = runDbTest {
val original = patientEntity(lastName = "Original")
patientDao.insert(original)
expectThrows<SQLiteConstraintException> { patientDao.insert(patientEntity(lastName = "Duplikat")) }
assertEquals(original, patientDao.getById("p-1"))
}
@Test
fun doppelte_stammdaten_mit_unterschiedlicher_id_sind_auf_db_ebene_erlaubt() = runDbTest {
// Duplikatschutz liegt bewusst in der Anwendung (findExisting), nicht in einem UNIQUE-Index.
patientDao.insert(patientEntity(id = "p-1"))
patientDao.insert(patientEntity(id = "p-2"))
assertEquals(2, countRows("patients"))
}
// --- update / delete -----------------------------------------------------------------
@Test
fun update_ueberschreibt_die_felder_des_datensatzes() = runDbTest {
patientDao.insert(patientEntity())
val changed = patientEntity(lastName = "Musterfrau", city = "Neustadt", phone2 = "0111", updatedAt = 2_000L)
patientDao.update(changed)
assertEquals(changed, patientDao.getById("p-1"))
}
@Test
fun update_einer_unbekannten_id_legt_keinen_datensatz_an() = runDbTest {
patientDao.update(patientEntity(id = "geist"))
assertNull(patientDao.getById("geist"))
assertEquals(0, countRows("patients"))
}
@Test
fun delete_entfernt_nur_den_uebergebenen_patienten() = runDbTest {
patientDao.insert(patientEntity(id = "p-1"))
patientDao.insert(patientEntity(id = "p-2"))
patientDao.delete(patientEntity(id = "p-1"))
assertNull(patientDao.getById("p-1"))
assertNotNull(patientDao.getById("p-2"))
}
@Test
fun delete_eines_unbekannten_patienten_ist_wirkungslos() = runDbTest {
patientDao.insert(patientEntity())
patientDao.delete(patientEntity(id = "unbekannt"))
assertEquals(1, countRows("patients"))
}
@Test
fun delete_wird_durch_vorhandene_dokumentation_verhindert_restrict() = runDbTest {
seedDocumentation()
expectThrows<SQLiteConstraintException> { patientDao.delete(patientEntity()) }
assertNotNull(patientDao.getById("p-1"))
assertEquals(1, countRows("documentations"))
}
// --- findExisting --------------------------------------------------------------------
@Test
fun findExisting_trifft_bei_gleichem_vornamen_nachnamen_und_geburtsdatum() = runDbTest {
patientDao.insert(patientEntity())
assertEquals("p-1", patientDao.findExisting("Max", "Mustermann", "1980-03-14")?.id)
}
@Test
fun findExisting_trifft_nicht_bei_abweichendem_feld() = runDbTest {
patientDao.insert(patientEntity())
assertNull(patientDao.findExisting("Moritz", "Mustermann", "1980-03-14"))
assertNull(patientDao.findExisting("Max", "Musterfrau", "1980-03-14"))
assertNull(patientDao.findExisting("Max", "Mustermann", "1980-03-15"))
}
@Test
fun findExisting_ist_ein_exakter_abgleich_und_unterscheidet_gross_und_kleinschreibung() = runDbTest {
// Dokumentiert das aktuelle Verhalten (SQL "="): "max" findet "Max" NICHT.
patientDao.insert(patientEntity())
assertNull(patientDao.findExisting("max", "mustermann", "1980-03-14"))
}
@Test
fun findExisting_liefert_bei_mehreren_treffern_genau_einen() = runDbTest {
patientDao.insert(patientEntity(id = "p-1"))
patientDao.insert(patientEntity(id = "p-2"))
val found = patientDao.findExisting("Max", "Mustermann", "1980-03-14")
assertTrue(found?.id == "p-1" || found?.id == "p-2")
}
// --- hasDocumentations ---------------------------------------------------------------
@Test
fun hasDocumentations_ist_false_ohne_und_true_mit_dokumentation() = runDbTest {
seedPatient("p-1")
seedPatient("p-2", lastName = "Musterfrau")
seedDocumentation(id = "d-1", patientId = "p-1")
assertTrue(patientDao.hasDocumentations("p-1"))
assertFalse(patientDao.hasDocumentations("p-2"))
assertFalse(patientDao.hasDocumentations("unbekannt"))
}
// --- observeAllWithLastDocumentation -------------------------------------------------
@Test
fun observeAllWithLastDocumentation_sortiert_nach_nachname_dann_vorname() = runDbTest {
seedPatient("p-1", firstName = "Max", lastName = "Müller")
seedPatient("p-2", firstName = "Anna", lastName = "Müller")
seedPatient("p-3", firstName = "Zoe", lastName = "Adler")
val result = patientDao.observeAllWithLastDocumentation().first()
assertEquals(listOf("p-3", "p-2", "p-1"), result.map { it.patient.id })
}
@Test
fun lastDocumentationAt_ist_das_maximale_erstelldatum_unabhaengig_von_der_einfuegereihenfolge() = runDbTest {
seedPatient("p-1")
seedDocumentation(id = "d-1", patientId = "p-1", createdAt = 3_000L)
seedDocumentation(id = "d-2", patientId = "p-1", createdAt = 1_000L)
seedDocumentation(id = "d-3", patientId = "p-1", createdAt = 2_000L)
val result = patientDao.observeAllWithLastDocumentation().first().single()
assertEquals(3_000L, result.lastDocumentationAt)
}
@Test
fun lastDocumentationAt_ist_null_ohne_dokumentation_und_beruecksichtigt_nur_eigene_dokumentationen() = runDbTest {
seedPatient("p-1")
seedPatient("p-2", firstName = "Erika", lastName = "Musterfrau")
seedDocumentation(id = "d-1", patientId = "p-1", createdAt = 5_000L)
val byId = patientDao.observeAllWithLastDocumentation().first().associateBy { it.patient.id }
assertEquals(5_000L, byId.getValue("p-1").lastDocumentationAt)
assertNull(byId.getValue("p-2").lastDocumentationAt)
}
@Test
fun observeAllWithLastDocumentation_liefert_leere_liste_ohne_patienten() = runDbTest {
assertTrue(patientDao.observeAllWithLastDocumentation().first().isEmpty())
}
@Test
fun observeAllWithLastDocumentation_aktualisiert_sich_bei_aenderungen() = runDbTest {
seedPatient("p-1")
val waiting = async {
patientDao.observeAllWithLastDocumentation().awaitMatching { list ->
list.size == 2 && list.any { it.patient.id == "p-1" && it.lastDocumentationAt == 9_000L }
}
}
seedPatient("p-2", firstName = "Erika", lastName = "Musterfrau")
seedDocumentation(id = "d-1", patientId = "p-1", createdAt = 9_000L)
assertEquals(2, waiting.await().size)
}
}
@@ -0,0 +1,168 @@
package de.medapp.wunddoku.data.dao
import android.database.sqlite.SQLiteConstraintException
import de.medapp.wunddoku.testutil.RoomTestBase
import de.medapp.wunddoku.testutil.expectThrows
import de.medapp.wunddoku.testutil.photoEntity
import de.medapp.wunddoku.testutil.runDbTest
import de.medapp.wunddoku.testutil.signatureEntity
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/** DAO-Tests für [PhotoDao] und [SignatureDao] (beide nach demselben Muster aufgebaut). */
class PhotoAndSignatureDaoTest : RoomTestBase() {
// --- PhotoDao ------------------------------------------------------------------------
@Test
fun photo_insert_und_getById_liefern_alle_felder() = runDbTest {
seedDocumentation()
val photo = photoEntity(fileName = "wunde_2026-08-23.jpg", orderIndex = 2, createdAt = 5_000L)
photoDao.insert(photo)
assertEquals(photo, photoDao.getById("ph-1"))
assertNull(photoDao.getById("unbekannt"))
}
@Test
fun photo_getForDocumentation_sortiert_nach_orderIndex_und_filtert_nach_dokumentation() = runDbTest {
seedDocumentation(id = "d-1")
seedDocumentation(id = "d-2")
photoDao.insert(photoEntity(id = "ph-3", documentationId = "d-1", orderIndex = 2))
photoDao.insert(photoEntity(id = "ph-1", documentationId = "d-1", orderIndex = 0))
photoDao.insert(photoEntity(id = "ph-2", documentationId = "d-1", orderIndex = 1))
photoDao.insert(photoEntity(id = "ph-x", documentationId = "d-2", orderIndex = 0))
assertEquals(listOf("ph-1", "ph-2", "ph-3"), photoDao.getForDocumentation("d-1").map { it.id })
assertEquals(listOf("ph-x"), photoDao.getForDocumentation("d-2").map { it.id })
assertTrue(photoDao.getForDocumentation("unbekannt").isEmpty())
}
@Test
fun photo_getMaxOrderIndex_ist_minus_eins_ohne_fotos_sonst_der_hoechste_wert() = runDbTest {
seedDocumentation(id = "d-1")
seedDocumentation(id = "d-2")
assertEquals(-1, photoDao.getMaxOrderIndex("d-1"))
photoDao.insert(photoEntity(id = "ph-1", documentationId = "d-1", orderIndex = 0))
photoDao.insert(photoEntity(id = "ph-2", documentationId = "d-1", orderIndex = 4))
photoDao.insert(photoEntity(id = "ph-3", documentationId = "d-2", orderIndex = 9))
assertEquals(4, photoDao.getMaxOrderIndex("d-1"))
assertEquals(9, photoDao.getMaxOrderIndex("d-2"))
assertEquals(-1, photoDao.getMaxOrderIndex("unbekannt"))
}
@Test
fun photo_getMaxOrderIndex_bleibt_nach_dem_loeschen_eines_mittleren_fotos_korrekt() = runDbTest {
seedDocumentation()
photoDao.insert(photoEntity(id = "ph-1", orderIndex = 0))
photoDao.insert(photoEntity(id = "ph-2", orderIndex = 1))
photoDao.insert(photoEntity(id = "ph-3", orderIndex = 2))
photoDao.delete(photoEntity(id = "ph-2", orderIndex = 1))
assertEquals(2, photoDao.getMaxOrderIndex("d-1"))
assertEquals(listOf(0, 2), photoDao.getForDocumentation("d-1").map { it.orderIndex })
}
@Test
fun photo_delete_entfernt_nur_das_angegebene_foto() = runDbTest {
seedDocumentation()
photoDao.insert(photoEntity(id = "ph-1", orderIndex = 0))
photoDao.insert(photoEntity(id = "ph-2", orderIndex = 1))
photoDao.delete(photoEntity(id = "ph-1"))
assertNull(photoDao.getById("ph-1"))
assertEquals("ph-2", photoDao.getById("ph-2")?.id)
}
@Test
fun photo_mit_gleichem_orderIndex_ist_erlaubt_kein_unique_index() = runDbTest {
seedDocumentation()
photoDao.insert(photoEntity(id = "ph-1", orderIndex = 0))
photoDao.insert(photoEntity(id = "ph-2", orderIndex = 0))
assertEquals(2, photoDao.getForDocumentation("d-1").size)
}
@Test
fun photo_mit_vorhandener_id_oder_unbekannter_dokumentation_wirft() = runDbTest {
seedDocumentation()
photoDao.insert(photoEntity(id = "ph-1"))
expectThrows<SQLiteConstraintException> { photoDao.insert(photoEntity(id = "ph-1", fileName = "andere.jpg")) }
expectThrows<SQLiteConstraintException> { photoDao.insert(photoEntity(id = "ph-2", documentationId = "gibt-es-nicht")) }
assertEquals(1, countRows("photos"))
}
// --- SignatureDao --------------------------------------------------------------------
@Test
fun signature_insert_und_getById_liefern_alle_felder() = runDbTest {
seedDocumentation()
val signature = signatureEntity(signerName = "Dr. Åsa Müller-Öz", fileName = "sig_1.png", orderIndex = 1, createdAt = 7_000L)
signatureDao.insert(signature)
assertEquals(signature, signatureDao.getById("s-1"))
assertNull(signatureDao.getById("unbekannt"))
}
@Test
fun signature_getForDocumentation_sortiert_nach_orderIndex_und_filtert_nach_dokumentation() = runDbTest {
seedDocumentation(id = "d-1")
seedDocumentation(id = "d-2")
signatureDao.insert(signatureEntity(id = "s-3", documentationId = "d-1", orderIndex = 2))
signatureDao.insert(signatureEntity(id = "s-1", documentationId = "d-1", orderIndex = 0))
signatureDao.insert(signatureEntity(id = "s-2", documentationId = "d-1", orderIndex = 1))
signatureDao.insert(signatureEntity(id = "s-x", documentationId = "d-2", orderIndex = 0))
assertEquals(listOf("s-1", "s-2", "s-3"), signatureDao.getForDocumentation("d-1").map { it.id })
assertEquals(listOf("s-x"), signatureDao.getForDocumentation("d-2").map { it.id })
assertTrue(signatureDao.getForDocumentation("unbekannt").isEmpty())
}
@Test
fun signature_getMaxOrderIndex_ist_minus_eins_ohne_signaturen_sonst_der_hoechste_wert() = runDbTest {
seedDocumentation(id = "d-1")
seedDocumentation(id = "d-2")
assertEquals(-1, signatureDao.getMaxOrderIndex("d-1"))
signatureDao.insert(signatureEntity(id = "s-1", documentationId = "d-1", orderIndex = 0))
signatureDao.insert(signatureEntity(id = "s-2", documentationId = "d-1", orderIndex = 3))
signatureDao.insert(signatureEntity(id = "s-3", documentationId = "d-2", orderIndex = 7))
assertEquals(3, signatureDao.getMaxOrderIndex("d-1"))
assertEquals(7, signatureDao.getMaxOrderIndex("d-2"))
}
@Test
fun signature_delete_entfernt_nur_die_angegebene_signatur() = runDbTest {
seedDocumentation()
signatureDao.insert(signatureEntity(id = "s-1", orderIndex = 0))
signatureDao.insert(signatureEntity(id = "s-2", orderIndex = 1))
signatureDao.delete(signatureEntity(id = "s-1"))
assertNull(signatureDao.getById("s-1"))
assertEquals("s-2", signatureDao.getById("s-2")?.id)
}
@Test
fun signature_mit_vorhandener_id_oder_unbekannter_dokumentation_wirft() = runDbTest {
seedDocumentation()
signatureDao.insert(signatureEntity(id = "s-1"))
expectThrows<SQLiteConstraintException> { signatureDao.insert(signatureEntity(id = "s-1", signerName = "Anderer")) }
expectThrows<SQLiteConstraintException> { signatureDao.insert(signatureEntity(id = "s-2", documentationId = "gibt-es-nicht")) }
assertEquals(1, countRows("signatures"))
}
}
@@ -0,0 +1,229 @@
package de.medapp.wunddoku.data.dao
import de.medapp.wunddoku.testutil.RoomTestBase
import de.medapp.wunddoku.testutil.awaitMatching
import de.medapp.wunddoku.testutil.documentationEntity
import de.medapp.wunddoku.testutil.runDbTest
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.first
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Suchtests (QA-002): [DocumentationDao.search] und [PatientDao.searchWithLastDocumentation].
*
* Szenario (Erstelldatum in Klammern):
* - p-1 Max Mustermann, 1980-03-14: d-1 (1000), d-2 (3000)
* - p-2 Erika Musterfrau, 1975-12-01: d-3 (2000)
* - p-3 Hans Beispiel, 1990-07-30: d-4 (500)
* - p-4 Zora Zimmer, 2001-01-01: keine Dokumentation
*/
class SearchQueryTest : RoomTestBase() {
private suspend fun seedDoc(id: String, patientId: String, createdAt: Long, first: String, last: String, birth: String) {
documentationDao.insert(
documentationEntity(
id = id, patientId = patientId, createdAt = createdAt,
firstNameSnapshot = first, lastNameSnapshot = last, birthDateSnapshot = birth,
)
)
}
private suspend fun seedScenario() {
seedPatient("p-1", "Max", "Mustermann", "1980-03-14")
seedPatient("p-2", "Erika", "Musterfrau", "1975-12-01")
seedPatient("p-3", "Hans", "Beispiel", "1990-07-30")
seedPatient("p-4", "Zora", "Zimmer", "2001-01-01")
seedDoc("d-1", "p-1", 1_000L, "Max", "Mustermann", "1980-03-14")
seedDoc("d-2", "p-1", 3_000L, "Max", "Mustermann", "1980-03-14")
seedDoc("d-3", "p-2", 2_000L, "Erika", "Musterfrau", "1975-12-01")
seedDoc("d-4", "p-3", 500L, "Hans", "Beispiel", "1990-07-30")
}
private suspend fun searchDocs(query: String) = documentationDao.search(query).first().map { it.id }
// --- Dokumentationssuche -------------------------------------------------------------
@Test
fun dokumentationssuche_findet_ueber_den_nachnamen_neueste_zuerst() = runDbTest {
seedScenario()
assertEquals(listOf("d-2", "d-3", "d-1"), searchDocs("Muster"))
assertEquals(listOf("d-2", "d-1"), searchDocs("Mustermann"))
}
@Test
fun dokumentationssuche_findet_ueber_den_vornamen_ohne_beachtung_der_gross_und_kleinschreibung() = runDbTest {
seedScenario()
assertEquals(listOf("d-3"), searchDocs("erika"))
assertEquals(listOf("d-3"), searchDocs("ERIKA"))
}
@Test
fun dokumentationssuche_findet_ueber_das_geburtsdatum_auch_teilweise() = runDbTest {
seedScenario()
assertEquals(listOf("d-4"), searchDocs("1990-07"))
assertEquals(listOf("d-2", "d-1"), searchDocs("1980"))
assertEquals(listOf("d-3"), searchDocs("12-01"))
}
@Test
fun dokumentationssuche_ist_ein_teilstring_abgleich() = runDbTest {
seedScenario()
assertEquals(listOf("d-2", "d-1"), searchDocs("term"))
}
@Test
fun dokumentationssuche_ohne_treffer_liefert_leere_liste() = runDbTest {
seedScenario()
assertTrue(searchDocs("Zzz").isEmpty())
}
@Test
fun dokumentationssuche_liefert_keine_dubletten_wenn_mehrere_spalten_treffen() = runDbTest {
seedScenario()
// "e" trifft bei p-2 gleichzeitig Vor- und Nachnamen (Erika / Musterfrau).
val ids = searchDocs("e")
assertEquals(listOf("d-2", "d-3", "d-1", "d-4"), ids)
assertEquals(ids.size, ids.toSet().size)
}
@Test
fun dokumentationssuche_findet_keine_patienten_ohne_dokumentation() = runDbTest {
seedScenario()
assertTrue(searchDocs("Zora").isEmpty())
}
@Test
fun dokumentationssuche_mit_leerem_text_liefert_alle_dokumentationen_der_schutz_davor_liegt_im_repository() = runDbTest {
seedScenario()
assertEquals(listOf("d-2", "d-3", "d-1", "d-4"), searchDocs(""))
}
@Test
fun dokumentationssuche_sucht_gegen_den_aktuellen_namen_und_nicht_gegen_den_snapshot() = runDbTest {
seedScenario()
patientDao.update(patientDao.getById("p-1")!!.copy(lastName = "Neuname"))
assertEquals(listOf("d-2", "d-1"), searchDocs("Neuname"))
assertTrue(searchDocs("Mustermann").isEmpty())
// Die gelieferten Zeilen zeigen weiterhin den eingefrorenen Namen.
val hits = documentationDao.search("Neuname").first()
assertTrue(hits.all { it.patientLastNameSnapshot == "Mustermann" })
}
@Test
fun dokumentationssuche_behandelt_sql_sonderzeichen_als_normalen_text() = runDbTest {
seedScenario()
assertTrue(searchDocs("'; DROP TABLE patients; --").isEmpty())
assertTrue(searchDocs("\"").isEmpty())
assertTrue(searchDocs("O'Brien").isEmpty())
assertEquals(4, countRows("patients"))
}
@Test
fun dokumentationssuche_aktualisiert_sich_bei_neuen_dokumentationen_und_namensaenderungen() = runDbTest {
seedScenario()
val afterNewDoc = async { documentationDao.search("Muster").awaitMatching { it.size == 4 } }
seedDoc("d-5", "p-1", 4_000L, "Max", "Mustermann", "1980-03-14")
assertEquals("d-5", afterNewDoc.await().first().id)
val afterRename = async { documentationDao.search("Neuname").awaitMatching { it.size == 3 } }
patientDao.update(patientDao.getById("p-1")!!.copy(lastName = "Neuname"))
assertEquals(listOf("d-5", "d-2", "d-1"), afterRename.await().map { it.id })
}
// --- Patientensuche ------------------------------------------------------------------
private suspend fun searchPatients(query: String) = patientDao.searchWithLastDocumentation(query).first()
@Test
fun patientensuche_sortiert_treffer_nach_nachname_und_liefert_letztes_dokumentationsdatum() = runDbTest {
seedScenario()
val result = searchPatients("Muster")
assertEquals(listOf("p-2", "p-1"), result.map { it.patient.id }) // Musterfrau vor Mustermann
assertEquals(listOf(2_000L, 3_000L), result.map { it.lastDocumentationAt })
}
@Test
fun patientensuche_findet_ueber_vorname_nachname_und_geburtsdatum() = runDbTest {
seedScenario()
assertEquals(listOf("p-3"), searchPatients("han").map { it.patient.id })
assertEquals(listOf("p-3"), searchPatients("beispiel").map { it.patient.id })
assertEquals(listOf("p-2"), searchPatients("1975").map { it.patient.id })
assertTrue(searchPatients("Zzz").isEmpty())
}
@Test
fun patientensuche_findet_auch_patienten_ohne_dokumentation_mit_null_datum() = runDbTest {
seedScenario()
val result = searchPatients("Zora").single()
assertEquals("p-4", result.patient.id)
assertNull(result.lastDocumentationAt)
}
@Test
fun patientensuche_liefert_keine_dubletten_trotz_mehrerer_dokumentationen_und_treffer_spalten() = runDbTest {
seedScenario()
val ids = searchPatients("e").map { it.patient.id }
assertEquals(ids.size, ids.toSet().size)
assertEquals(listOf("p-3", "p-2", "p-1", "p-4"), ids) // Beispiel, Musterfrau, Mustermann, Zimmer
}
@Test
fun patientensuche_mit_leerem_text_liefert_alle_patienten_sortiert() = runDbTest {
seedScenario()
assertEquals(listOf("p-3", "p-2", "p-1", "p-4"), searchPatients("").map { it.patient.id })
}
@Test
fun patientensuche_sortiert_bei_gleichem_nachnamen_nach_vornamen() = runDbTest {
seedPatient("p-1", "Max", "Müller", "1980-01-01")
seedPatient("p-2", "Anna", "Müller", "1981-01-01")
assertEquals(listOf("p-2", "p-1"), searchPatients("Müller").map { it.patient.id })
}
@Test
fun patientensuche_behandelt_sql_sonderzeichen_als_normalen_text() = runDbTest {
seedScenario()
assertTrue(searchPatients("'; DROP TABLE patients; --").isEmpty())
assertEquals(4, countRows("patients"))
}
@Test
fun patientensuche_aktualisiert_sich_bei_neuen_patienten_und_dokumentationen() = runDbTest {
seedScenario()
val afterNewPatient = async { patientDao.searchWithLastDocumentation("Muster").awaitMatching { it.size == 3 } }
seedPatient("p-5", "Karl", "Musterkind", "2010-05-05")
assertEquals(3, afterNewPatient.await().size)
val afterNewDoc = async {
patientDao.searchWithLastDocumentation("Musterkind").awaitMatching { list -> list.singleOrNull()?.lastDocumentationAt == 8_000L }
}
seedDoc("d-9", "p-5", 8_000L, "Karl", "Musterkind", "2010-05-05")
afterNewDoc.await()
}
}
@@ -0,0 +1,128 @@
package de.medapp.wunddoku.data.dao
import de.medapp.wunddoku.testutil.RoomTestBase
import de.medapp.wunddoku.testutil.runDbTest
import de.medapp.wunddoku.testutil.woundCarerEntity
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/** DAO-Tests für [WoundCarerDao] (Wundversorger-Register). */
class WoundCarerDaoTest : RoomTestBase() {
// --- insertOrUpdate ------------------------------------------------------------------
@Test
fun insertOrUpdate_legt_neuen_eintrag_an() = runDbTest {
val carer = woundCarerEntity(updatedAt = 5_000L)
woundCarerDao.insertOrUpdate(carer)
assertEquals(carer, woundCarerDao.getMostRecentlyUsed())
}
@Test
fun insertOrUpdate_ersetzt_vorhandenen_eintrag_mit_gleicher_id_vollstaendig() = runDbTest {
woundCarerDao.insertOrUpdate(woundCarerEntity(id = "w-1", name = "Anna Schmidt", signatureFileName = "alt.png", updatedAt = 1_000L))
woundCarerDao.insertOrUpdate(woundCarerEntity(id = "w-1", name = "Anna Schmidt-Neu", signatureFileName = "neu.png", updatedAt = 2_000L))
assertEquals(1, countRows("wound_carers"))
val loaded = woundCarerDao.getMostRecentlyUsed()!!
assertEquals("Anna Schmidt-Neu", loaded.name)
assertEquals("neu.png", loaded.signatureFileName)
assertEquals(2_000L, loaded.updatedAt)
}
@Test
fun insertOrUpdate_mit_verschiedenen_ids_und_gleichem_namen_legt_zwei_eintraege_an() = runDbTest {
// Namens-Eindeutigkeit stellt das Repository (findByName) sicher, nicht die Datenbank.
woundCarerDao.insertOrUpdate(woundCarerEntity(id = "w-1", name = "Anna"))
woundCarerDao.insertOrUpdate(woundCarerEntity(id = "w-2", name = "Anna"))
assertEquals(2, countRows("wound_carers"))
}
// --- findByName ----------------------------------------------------------------------
@Test
fun findByName_ist_ein_exakter_abgleich_ohne_beachtung_der_gross_und_kleinschreibung() = runDbTest {
woundCarerDao.insertOrUpdate(woundCarerEntity(id = "w-1", name = "Anna Schmidt"))
assertEquals("w-1", woundCarerDao.findByName("Anna Schmidt")?.id)
assertEquals("w-1", woundCarerDao.findByName("anna schmidt")?.id)
assertEquals("w-1", woundCarerDao.findByName("ANNA SCHMIDT")?.id)
}
@Test
fun findByName_trifft_nicht_bei_teilstring_oder_praefix() = runDbTest {
woundCarerDao.insertOrUpdate(woundCarerEntity(name = "Anna Schmidt"))
assertNull(woundCarerDao.findByName("Anna"))
assertNull(woundCarerDao.findByName("Schmidt"))
assertNull(woundCarerDao.findByName("Anna Schmidt "))
assertNull(woundCarerDao.findByName("unbekannt"))
}
// --- searchByNamePrefix --------------------------------------------------------------
@Test
fun searchByNamePrefix_liefert_praefix_treffer_ohne_beachtung_der_gross_und_kleinschreibung() = runDbTest {
woundCarerDao.insertOrUpdate(woundCarerEntity(id = "w-1", name = "Anna Schmidt", updatedAt = 1_000L))
woundCarerDao.insertOrUpdate(woundCarerEntity(id = "w-2", name = "Andreas Müller", updatedAt = 2_000L))
woundCarerDao.insertOrUpdate(woundCarerEntity(id = "w-3", name = "Bernd Beispiel", updatedAt = 3_000L))
assertEquals(listOf("w-2", "w-1"), woundCarerDao.searchByNamePrefix("an", 10).map { it.id })
assertEquals(listOf("w-2", "w-1"), woundCarerDao.searchByNamePrefix("AN", 10).map { it.id })
assertEquals(listOf("w-3"), woundCarerDao.searchByNamePrefix("Bern", 10).map { it.id })
}
@Test
fun searchByNamePrefix_ist_kein_teilstring_abgleich() = runDbTest {
woundCarerDao.insertOrUpdate(woundCarerEntity(name = "Anna Schmidt"))
assertTrue(woundCarerDao.searchByNamePrefix("Schmidt", 10).isEmpty())
assertTrue(woundCarerDao.searchByNamePrefix("nna", 10).isEmpty())
}
@Test
fun searchByNamePrefix_sortiert_nach_letzter_verwendung_absteigend_und_beachtet_das_limit() = runDbTest {
woundCarerDao.insertOrUpdate(woundCarerEntity(id = "w-1", name = "Anna A", updatedAt = 1_000L))
woundCarerDao.insertOrUpdate(woundCarerEntity(id = "w-2", name = "Anna B", updatedAt = 4_000L))
woundCarerDao.insertOrUpdate(woundCarerEntity(id = "w-3", name = "Anna C", updatedAt = 3_000L))
woundCarerDao.insertOrUpdate(woundCarerEntity(id = "w-4", name = "Anna D", updatedAt = 2_000L))
assertEquals(listOf("w-2", "w-3", "w-4", "w-1"), woundCarerDao.searchByNamePrefix("Anna", 10).map { it.id })
assertEquals(listOf("w-2", "w-3"), woundCarerDao.searchByNamePrefix("Anna", 2).map { it.id })
assertTrue(woundCarerDao.searchByNamePrefix("Anna", 0).isEmpty())
}
@Test
fun searchByNamePrefix_mit_leerem_praefix_liefert_alle_eintraege_der_schutz_davor_liegt_im_repository() = runDbTest {
woundCarerDao.insertOrUpdate(woundCarerEntity(id = "w-1", name = "Anna"))
woundCarerDao.insertOrUpdate(woundCarerEntity(id = "w-2", name = "Bernd", updatedAt = 2_000L))
assertEquals(2, woundCarerDao.searchByNamePrefix("", 10).size)
}
// --- getMostRecentlyUsed -------------------------------------------------------------
@Test
fun getMostRecentlyUsed_ist_null_bei_leerer_tabelle() = runDbTest {
assertNull(woundCarerDao.getMostRecentlyUsed())
}
@Test
fun getMostRecentlyUsed_liefert_den_eintrag_mit_dem_hoechsten_updatedAt() = runDbTest {
woundCarerDao.insertOrUpdate(woundCarerEntity(id = "w-1", name = "Anna", updatedAt = 1_000L))
woundCarerDao.insertOrUpdate(woundCarerEntity(id = "w-2", name = "Bernd", updatedAt = 3_000L))
woundCarerDao.insertOrUpdate(woundCarerEntity(id = "w-3", name = "Clara", updatedAt = 2_000L))
assertEquals("w-2", woundCarerDao.getMostRecentlyUsed()?.id)
woundCarerDao.insertOrUpdate(woundCarerEntity(id = "w-1", name = "Anna", updatedAt = 9_000L))
assertEquals("w-1", woundCarerDao.getMostRecentlyUsed()?.id)
}
}
@@ -0,0 +1,222 @@
package de.medapp.wunddoku.data.database
import android.database.sqlite.SQLiteConstraintException
import androidx.room.withTransaction
import de.medapp.wunddoku.testutil.RoomTestBase
import de.medapp.wunddoku.testutil.blockStateEntity
import de.medapp.wunddoku.testutil.checkItemStateEntity
import de.medapp.wunddoku.testutil.documentationEntity
import de.medapp.wunddoku.testutil.expectThrows
import de.medapp.wunddoku.testutil.photoEntity
import de.medapp.wunddoku.testutil.runDbTest
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Test
/**
* Transaction-Tests (QA-002): Room-Transaktionen (`db.withTransaction`) müssen Mehrtabellen-
* Schreibvorgänge - wie in DocumentationRepository.createEmpty()/continueDocumentation() -
* atomar machen.
*
* Hinweis: In-Memory-Datenbanken haben nur eine Verbindung. Deshalb wird innerhalb einer
* Transaktion ausschließlich über DAOs gelesen, nie über Roh-SQL des Test-Threads.
*/
class DatabaseTransactionTest : RoomTestBase() {
// --- Commit --------------------------------------------------------------------------
@Test
fun commit_macht_alle_schreibvorgaenge_sichtbar() = runDbTest {
seedPatient()
db.withTransaction {
documentationDao.insert(documentationEntity())
blockStateDao.insertAll(listOf(blockStateEntity()))
checkItemStateDao.insertAll(listOf(checkItemStateEntity()))
photoDao.insert(photoEntity())
}
assertEquals(1, countRows("documentations"))
assertEquals(1, countRows("block_states"))
assertEquals(1, countRows("check_item_states"))
assertEquals(1, countRows("photos"))
}
@Test
fun transaktion_gibt_das_ergebnis_des_blocks_zurueck() = runDbTest {
val result = db.withTransaction {
seedPatient()
42
}
assertEquals(42, result)
assertNotNull(patientDao.getById("p-1"))
}
@Test
fun innerhalb_der_transaktion_sind_die_eigenen_schreibvorgaenge_bereits_lesbar() = runDbTest {
seedPatient()
db.withTransaction {
documentationDao.insert(documentationEntity())
assertNotNull(documentationDao.getById("d-1"))
blockStateDao.insertAll(listOf(blockStateEntity()))
assertEquals(1, blockStateDao.getForDocumentation("d-1").size)
}
}
// --- Rollback ------------------------------------------------------------------------
@Test
fun exception_im_block_verwirft_alle_schreibvorgaenge_und_wird_weitergereicht() = runDbTest {
seedPatient()
val error = expectThrows<IllegalStateException> {
db.withTransaction {
documentationDao.insert(documentationEntity())
blockStateDao.insertAll(listOf(blockStateEntity()))
checkItemStateDao.insertAll(listOf(checkItemStateEntity()))
error("Abbruch mitten in der Transaktion")
}
}
assertEquals("Abbruch mitten in der Transaktion", error.message)
assertNull(documentationDao.getById("d-1"))
assertEquals(0, countRows("block_states"))
assertEquals(0, countRows("check_item_states"))
assertNotNull("Daten vor der Transaktion bleiben bestehen", patientDao.getById("p-1"))
}
@Test
fun constraint_verletzung_in_einem_spaeteren_schritt_verwirft_auch_die_frueheren_inserts() = runDbTest {
seedPatient()
expectThrows<SQLiteConstraintException> {
db.withTransaction {
documentationDao.insert(documentationEntity())
photoDao.insert(photoEntity(documentationId = "gibt-es-nicht")) // FK-Verletzung
}
}
assertNull(documentationDao.getById("d-1"))
assertEquals(0, countRows("photos"))
}
@Test
fun insertAll_ist_atomar_ein_fehlerhafter_eintrag_verwirft_die_ganze_liste() = runDbTest {
seedDocumentation()
expectThrows<SQLiteConstraintException> {
blockStateDao.insertAll(
listOf(
blockStateEntity(id = "b-1", blockDefinitionId = "wundart"),
blockStateEntity(id = "b-2", blockDefinitionId = "groesse"),
blockStateEntity(id = "b-3", blockDefinitionId = "wundart"), // verletzt UNIQUE
)
)
}
assertEquals(0, countRows("block_states"))
}
@Test
fun rollback_nimmt_ein_cascade_loeschen_wieder_zurueck() = runDbTest {
val doc = seedDocumentation()
blockStateDao.insertAll(listOf(blockStateEntity()))
checkItemStateDao.insertAll(listOf(checkItemStateEntity()))
photoDao.insert(photoEntity())
expectThrows<IllegalStateException> {
db.withTransaction {
documentationDao.delete(doc)
assertNull(documentationDao.getById("d-1"))
error("Abbruch nach dem Löschen")
}
}
assertNotNull(documentationDao.getById("d-1"))
assertEquals(1, countRows("block_states"))
assertEquals(1, countRows("check_item_states"))
assertEquals(1, countRows("photos"))
}
@Test
fun datenbank_bleibt_nach_einer_fehlgeschlagenen_transaktion_voll_nutzbar() = runDbTest {
seedPatient()
expectThrows<IllegalStateException> {
db.withTransaction {
documentationDao.insert(documentationEntity(id = "d-verloren"))
error("Abbruch")
}
}
db.withTransaction { documentationDao.insert(documentationEntity(id = "d-ok")) }
documentationDao.insert(documentationEntity(id = "d-direkt"))
assertNull(documentationDao.getById("d-verloren"))
assertNotNull(documentationDao.getById("d-ok"))
assertNotNull(documentationDao.getById("d-direkt"))
}
// --- Verschachtelung -----------------------------------------------------------------
@Test
fun verschachtelte_transaktionen_commiten_gemeinsam() = runDbTest {
seedPatient()
db.withTransaction {
documentationDao.insert(documentationEntity(id = "d-aussen"))
db.withTransaction { documentationDao.insert(documentationEntity(id = "d-innen")) }
}
assertEquals(2, countRows("documentations"))
}
@Test
fun fehler_in_der_inneren_transaktion_rollt_auch_die_aeussere_zurueck_selbst_wenn_er_gefangen_wird() = runDbTest {
// SQLite/Android: Wird eine verschachtelte Transaktion nicht als erfolgreich beendet,
// wird die äußere ebenfalls zurückgerollt (SQLiteDatabase.beginTransaction-Doku).
seedPatient()
db.withTransaction {
documentationDao.insert(documentationEntity(id = "d-aussen"))
try {
db.withTransaction {
documentationDao.insert(documentationEntity(id = "d-innen"))
error("Fehler innen")
}
} catch (e: IllegalStateException) {
// bewusst gefangen: "e" ist unbenutzt, der Fehler soll die äußere Transaktion nicht abbrechen
}
}
assertEquals(0, countRows("documentations"))
}
// --- Nebenläufigkeit -----------------------------------------------------------------
@Test
fun parallele_transaktionen_werden_serialisiert_und_verlieren_keine_schreibvorgaenge() = runDbTest {
seedDocumentation()
coroutineScope {
listOf("a", "b", "c").map { prefix ->
async(Dispatchers.Default) {
repeat(20) { i ->
db.withTransaction {
photoDao.insert(photoEntity(id = "$prefix-$i", fileName = "$prefix$i.jpg", orderIndex = i))
}
}
}
}.awaitAll()
}
assertEquals(60, countRows("photos"))
assertEquals(60, photoDao.getForDocumentation("d-1").size)
}
}
@@ -0,0 +1,378 @@
package de.medapp.wunddoku.data.repository
import android.net.Uri
import androidx.test.ext.junit.runners.AndroidJUnit4
import de.medapp.wunddoku.data.entity.BlockStateEntity
import de.medapp.wunddoku.data.entity.CheckItemStateEntity
import de.medapp.wunddoku.data.entity.DocumentationEntity
import de.medapp.wunddoku.domain.catalog.DocumentationCatalog
import de.medapp.wunddoku.domain.model.CheckItem
import de.medapp.wunddoku.domain.model.Documentation
import de.medapp.wunddoku.domain.model.DocumentationStatus
import de.medapp.wunddoku.testutil.DuplicateCall
import de.medapp.wunddoku.testutil.TestEnvironment
import de.medapp.wunddoku.testutil.expectThrows
import de.medapp.wunddoku.testutil.firstPlainItem
import de.medapp.wunddoku.testutil.runDbTest
import de.medapp.wunddoku.testutil.signatureBitmap
import kotlinx.coroutines.flow.first
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import java.io.IOException
import java.time.LocalDate
/**
* Fortsetzen-Tests: "Dokumentation fortsetzen" erzeugt aus dem Stand einer vorhandenen
* Dokumentation eine NEUE; die Quelle bleibt unverändert.
*/
@RunWith(AndroidJUnit4::class)
class ContinueDocumentationTest {
private lateinit var env: TestEnvironment
private val repo get() = env.documentationRepository
@Before
fun setUp() {
env = TestEnvironment()
}
@After
fun tearDown() {
env.close()
}
/** Alle Items eines Dokuments als Map definitionId (inkl. Blockbezug) -> Item. */
private fun Documentation.itemsByKey(): Map<String, CheckItem> =
blocks.flatMap { block -> block.items.map { "${block.definitionId}/${it.definitionId}" to it } }.toMap()
/** Bereitet eine Quelle mit einigen Eingaben vor und liefert sie frisch geladen zurück. */
private suspend fun prepareSourceWithInput(): Documentation {
env.insertPatient()
val created = repo.createEmpty("patient-1")
val firstBlock = created.blocks.first()
repo.setChecked(firstBlock.firstPlainItem().checkItemStateId, true)
val freeText = firstBlock.items.last()
repo.setChecked(freeText.checkItemStateId, true)
repo.updateFreeText(freeText.checkItemStateId, "Rötung am Wundrand")
return repo.getFullDocumentation(created.id)!!
}
// --- Inhalt wird übernommen ----------------------------------------------------------
@Test
fun fortsetzen_uebernimmt_bloecke_und_optionen_inklusive_haken_und_freitext() = runDbTest {
val source = prepareSourceWithInput()
val copy = repo.continueDocumentation(source.id)
assertEquals(source.blocks.size, copy.blocks.size)
for (sourceBlock in source.blocks) {
val copyBlock = copy.blocks.single { it.definitionId == sourceBlock.definitionId }
assertEquals(sourceBlock.title, copyBlock.title)
assertEquals(sourceBlock.order, copyBlock.order)
assertEquals(sourceBlock.isEdited, copyBlock.isEdited)
}
// Option für Option: gleiche Inhalte (Label, Reihenfolge, Haken, Freitext, Hinweis, strukturierte Eingabe).
val sourceItems = source.itemsByKey()
val copyItems = copy.itemsByKey()
assertEquals(sourceItems.keys, copyItems.keys)
for ((key, sourceItem) in sourceItems) {
val copyItem = copyItems.getValue(key)
assertEquals(key, sourceItem.label, copyItem.label)
assertEquals(key, sourceItem.order, copyItem.order)
assertEquals(key, sourceItem.checked, copyItem.checked)
assertEquals(key, sourceItem.isFreeText, copyItem.isFreeText)
assertEquals(key, sourceItem.freeTextValue, copyItem.freeTextValue)
assertEquals(key, sourceItem.isStaticHint, copyItem.isStaticHint)
assertEquals(key, sourceItem.inputSpec, copyItem.inputSpec)
}
assertEquals(1, copy.blocks.count { it.isEdited })
assertEquals("Rötung am Wundrand", copy.blocks.first().items.last().freeTextValue)
}
@Test
fun fortsetzen_erzeugt_ausschliesslich_neue_ids() = runDbTest {
val source = prepareSourceWithInput()
val copy = repo.continueDocumentation(source.id)
assertNotEquals(source.id, copy.id)
val sourceBlockIds = source.blocks.map { it.blockStateId }.toSet()
val sourceItemIds = source.blocks.flatMap { b -> b.items.map { it.checkItemStateId } }.toSet()
assertTrue(copy.blocks.none { it.blockStateId in sourceBlockIds })
assertTrue(copy.blocks.flatMap { b -> b.items.map { it.checkItemStateId } }.none { it in sourceItemIds })
}
@Test
fun fortsetzen_setzt_metadaten_der_neuen_dokumentation_korrekt() = runDbTest {
val source = prepareSourceWithInput()
env.setStatus(source.id, DocumentationStatus.COMPLETED)
val copy = repo.continueDocumentation(source.id)
assertEquals(DocumentationStatus.IN_PROGRESS, copy.status)
assertEquals(source.id, copy.continuedFromDocumentationId)
assertEquals(source.patientId, copy.patientId)
assertEquals(copy.createdAt, copy.updatedAt)
assertEquals(DocumentationStatus.COMPLETED, repo.getFullDocumentation(source.id)!!.status)
assertEquals(2, env.db.documentationDao().countForPatient("patient-1"))
assertTrue(env.patientRepository.hasDocumentations("patient-1"))
}
// --- Quelle bleibt unverändert / Unabhängigkeit --------------------------------------
@Test
fun fortsetzen_laesst_die_quelldokumentation_unveraendert() = runDbTest {
val source = prepareSourceWithInput()
repo.continueDocumentation(source.id)
val sourceAfter = repo.getFullDocumentation(source.id)!!
assertEquals(source.status, sourceAfter.status)
assertEquals(source.createdAt, sourceAfter.createdAt)
assertEquals(source.updatedAt, sourceAfter.updatedAt)
assertNull(sourceAfter.continuedFromDocumentationId)
assertEquals(source.blocks, sourceAfter.blocks)
assertEquals(source.photos, sourceAfter.photos)
assertEquals(source.signatures, sourceAfter.signatures)
}
@Test
fun aenderungen_an_der_kopie_veraendern_die_quelle_nicht_und_umgekehrt() = runDbTest {
val source = prepareSourceWithInput()
val copy = repo.continueDocumentation(source.id)
// Kopie: den in der Quelle gesetzten Haken entfernen und Freitext ändern.
val copyFirstBlock = copy.blocks.first()
repo.setChecked(copyFirstBlock.firstPlainItem().checkItemStateId, false)
repo.updateFreeText(copyFirstBlock.items.last().checkItemStateId, "Neuer Text")
val sourceAfter = repo.getFullDocumentation(source.id)!!
assertTrue(sourceAfter.blocks.first().firstPlainItem().checked)
assertEquals("Rötung am Wundrand", sourceAfter.blocks.first().items.last().freeTextValue)
// Quelle: weiteren Haken in einem anderen Block setzen.
val otherSourceBlock = source.blocks[1]
repo.setChecked(otherSourceBlock.firstPlainItem().checkItemStateId, true)
val copyAfter = repo.getFullDocumentation(copy.id)!!
assertFalse(copyAfter.blocks.single { it.definitionId == otherSourceBlock.definitionId }.isEdited)
assertEquals("Neuer Text", copyAfter.blocks.first().items.last().freeTextValue)
}
// --- Patienten-Snapshot --------------------------------------------------------------
@Test
fun fortsetzen_bildet_den_patienten_snapshot_frisch_aus_dem_aktuellen_patienten() = runDbTest {
val source = prepareSourceWithInput()
val patientDao = env.db.patientDao()
patientDao.update(
patientDao.getById("patient-1")!!.copy(
lastName = "Musterfrau", street = "Neue Straße 7", postalCode = "99999", city = "Neustadt",
)
)
val copy = repo.continueDocumentation(source.id)
assertEquals("Musterfrau", copy.patientSnapshot.lastName)
assertEquals("Neue Straße 7", copy.patientSnapshot.street)
assertEquals("99999", copy.patientSnapshot.postalCode)
assertEquals("Neustadt", copy.patientSnapshot.city)
assertEquals(LocalDate.of(1980, 3, 14), copy.patientSnapshot.birthDate)
// Die Quelle behält ihren damaligen Stand.
val sourceSnapshot = repo.getFullDocumentation(source.id)!!.patientSnapshot
assertEquals("Mustermann", sourceSnapshot.lastName)
assertEquals("Musterstraße 1", sourceSnapshot.street)
assertEquals("Musterstadt", sourceSnapshot.city)
}
// --- Historische Snapshots / Katalogversion ------------------------------------------
@Test
fun fortsetzen_bewahrt_historische_titel_labels_und_katalogversion() = runDbTest {
env.insertPatient()
val now = 5_000L
val historicDocId = "historic-doc"
env.db.documentationDao().insert(
DocumentationEntity(
id = historicDocId, patientId = "patient-1", status = DocumentationStatus.COMPLETED,
createdAt = now, updatedAt = now, continuedFromDocumentationId = null, pdfLastGeneratedAt = null,
catalogVersion = 1,
patientFirstNameSnapshot = "Max", patientLastNameSnapshot = "Mustermann",
patientBirthDateSnapshot = "1980-03-14", patientStreetSnapshot = "Musterstraße 1",
patientPostalCodeSnapshot = "12345", patientCitySnapshot = "Musterstadt",
)
)
env.db.blockStateDao().insertAll(
listOf(BlockStateEntity("hist-block", historicDocId, "gibt_es_nicht_mehr", "Alter Titel", 1, now))
)
env.db.checkItemStateDao().insertAll(
listOf(
CheckItemStateEntity("hist-item", "hist-block", "alter_eintrag", "Alter Eintrag", 1, checked = true),
)
)
assertNotEquals("Voraussetzung: aktuelle Katalogversion weicht ab", 1, DocumentationCatalog.CATALOG_VERSION)
val copy = repo.continueDocumentation(historicDocId)
assertEquals(1, copy.catalogVersion)
val block = copy.blocks.single()
assertEquals("gibt_es_nicht_mehr", block.definitionId)
assertEquals("Alter Titel", block.title)
assertEquals("Alter Eintrag", block.items.single().label)
assertTrue(block.items.single().checked)
}
// --- Unterschriften ------------------------------------------------------------------
@Test
fun fortsetzen_kopiert_keine_unterschriften() = runDbTest {
val source = prepareSourceWithInput()
repo.saveSignature(source.id, "Anna Schmidt", signatureBitmap())
val copy = repo.continueDocumentation(source.id)
assertTrue(copy.signatures.isEmpty())
assertEquals(1, repo.getFullDocumentation(source.id)!!.signatures.size)
assertEquals(1, env.signatureStorage.saved.size) // keine zusätzliche Datei angelegt
}
// --- Fotos ---------------------------------------------------------------------------
@Test
fun fortsetzen_kopiert_fotos_standardmaessig_als_eigenstaendige_dateien() = runDbTest {
val source = prepareSourceWithInput()
val p1 = repo.addPhoto(source.id, Uri.parse("content://test/1"))
val p2 = repo.addPhoto(source.id, Uri.parse("content://test/2"))
val copy = repo.continueDocumentation(source.id)
assertEquals(listOf(0, 1), copy.photos.map { it.orderIndex })
assertTrue(copy.photos.none { it.id == p1.id || it.id == p2.id })
assertTrue("Dateien müssen physisch eigenständig sein", copy.photos.none { it.fileName == p1.fileName || it.fileName == p2.fileName })
assertTrue(copy.photos.all { it.documentationId == copy.id })
assertEquals(
setOf(
DuplicateCall(source.id, p1.fileName, copy.id),
DuplicateCall(source.id, p2.fileName, copy.id),
),
env.photoStorage.duplicated.toSet(),
)
assertEquals(2, env.photoStorage.duplicated.size)
// Quelle behält ihre Fotos.
assertEquals(listOf(p1.fileName, p2.fileName), repo.getFullDocumentation(source.id)!!.photos.map { it.fileName })
}
@Test
fun fortsetzen_mit_copyPhotos_false_uebernimmt_keine_fotos() = runDbTest {
val source = prepareSourceWithInput()
repo.addPhoto(source.id, Uri.parse("content://test/1"))
val copy = repo.continueDocumentation(source.id, copyPhotos = false)
assertTrue(copy.photos.isEmpty())
assertTrue(env.photoStorage.duplicated.isEmpty())
}
@Test
fun foto_loeschen_in_der_kopie_betrifft_die_quelle_nicht() = runDbTest {
val source = prepareSourceWithInput()
repo.addPhoto(source.id, Uri.parse("content://test/1"))
val copy = repo.continueDocumentation(source.id)
repo.removePhoto(copy.photos.single().id)
assertTrue(repo.getFullDocumentation(copy.id)!!.photos.isEmpty())
assertEquals(1, repo.getFullDocumentation(source.id)!!.photos.size)
}
// --- Fehlerfälle / Atomarität --------------------------------------------------------
@Test
fun fortsetzen_einer_unbekannten_dokumentation_wirft_und_legt_nichts_an() = runDbTest {
env.insertPatient()
val error = expectThrows<IllegalStateException> { repo.continueDocumentation("unbekannt") }
assertTrue(error.message.orEmpty().contains("unbekannt"))
assertTrue(env.db.documentationDao().observeAll().first().isEmpty())
}
@Test
fun fortsetzen_ist_atomar_ein_fehler_beim_foto_kopieren_hinterlaesst_keine_halbe_dokumentation() = runDbTest {
val source = prepareSourceWithInput()
repo.addPhoto(source.id, Uri.parse("content://test/1"))
repo.addPhoto(source.id, Uri.parse("content://test/2"))
env.photoStorage.failOnDuplicateCall = 2 // zweites Foto schlägt fehl
expectThrows<IOException> { repo.continueDocumentation(source.id) }
val all = env.db.documentationDao().observeAll().first()
assertEquals(listOf(source.id), all.map { it.id })
assertEquals(1, env.db.documentationDao().countForPatient("patient-1"))
}
@Test
fun loeschen_der_quelle_laesst_die_fortgesetzte_dokumentation_bestehen() = runDbTest {
val source = prepareSourceWithInput()
repo.addPhoto(source.id, Uri.parse("content://test/1"))
val copy = repo.continueDocumentation(source.id)
repo.delete(source.id)
val copyAfter = repo.getFullDocumentation(copy.id)!!
assertNull("Verweis auf die gelöschte Quelle wird auf null gesetzt", copyAfter.continuedFromDocumentationId)
assertEquals(copy.blocks.size, copyAfter.blocks.size)
assertEquals(1, copyAfter.photos.size)
assertEquals(listOf(source.id), env.photoStorage.deletedAll) // nur der Ordner der Quelle
}
@Test
fun mehrfaches_fortsetzen_bildet_eine_kette() = runDbTest {
val first = prepareSourceWithInput()
val second = repo.continueDocumentation(first.id)
val third = repo.continueDocumentation(second.id)
assertEquals(first.id, second.continuedFromDocumentationId)
assertEquals(second.id, third.continuedFromDocumentationId)
assertEquals(1, third.blocks.count { it.isEdited })
assertEquals(3, env.db.documentationDao().countForPatient("patient-1"))
}
// --- continueLatestForPatient --------------------------------------------------------
@Test
fun continueLatestForPatient_liefert_null_wenn_der_patient_noch_keine_dokumentation_hat() = runDbTest {
env.insertPatient()
assertNull(repo.continueLatestForPatient("patient-1"))
assertTrue(env.db.documentationDao().observeAll().first().isEmpty())
}
@Test
fun continueLatestForPatient_setzt_die_zuletzt_erstellte_dokumentation_des_patienten_fort() = runDbTest {
env.insertPatient()
env.insertPatient(id = "patient-2", firstName = "Erika", lastName = "Musterfrau")
val older = repo.createEmpty("patient-1")
val newer = repo.createEmpty("patient-1")
val otherPatientsDoc = repo.createEmpty("patient-2")
env.setCreatedAt(older.id, 1_000L)
env.setCreatedAt(newer.id, 2_000L)
env.setCreatedAt(otherPatientsDoc.id, 3_000L) // neuer, aber anderer Patient
repo.setChecked(newer.blocks.first().firstPlainItem().checkItemStateId, true)
val copy = repo.continueLatestForPatient("patient-1")!!
assertEquals(newer.id, copy.continuedFromDocumentationId)
assertEquals("patient-1", copy.patientId)
assertEquals(1, copy.blocks.count { it.isEdited })
}
}
@@ -0,0 +1,545 @@
package de.medapp.wunddoku.data.repository
import android.net.Uri
import androidx.test.ext.junit.runners.AndroidJUnit4
import de.medapp.wunddoku.domain.catalog.DocumentationCatalog
import de.medapp.wunddoku.domain.model.DocumentationStatus
import de.medapp.wunddoku.domain.model.InputSpecCodec
import de.medapp.wunddoku.pdf.PdfExportError
import de.medapp.wunddoku.pdf.PdfExportResult
import de.medapp.wunddoku.pdf.PdfToImagesResult
import de.medapp.wunddoku.testutil.TestEnvironment
import de.medapp.wunddoku.testutil.blankBitmap
import de.medapp.wunddoku.testutil.expectThrows
import de.medapp.wunddoku.testutil.firstPlainItem
import de.medapp.wunddoku.testutil.runDbTest
import de.medapp.wunddoku.testutil.signatureBitmap
import kotlinx.coroutines.flow.first
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import java.io.File
import java.time.LocalDate
import java.time.ZoneId
/**
* Repository-Tests für [DocumentationRepository] gegen eine echte In-Memory-Room-Datenbank.
* Dateispeicher und PDF-Erzeugung sind durch Fakes ersetzt (s. testutil/Fakes.kt).
* "Fortsetzen" wird separat in [ContinueDocumentationTest] getestet.
*/
@RunWith(AndroidJUnit4::class)
class DocumentationRepositoryTest {
private lateinit var env: TestEnvironment
private val repo get() = env.documentationRepository
@Before
fun setUp() {
env = TestEnvironment()
}
@After
fun tearDown() {
env.close()
}
// --- createEmpty ---------------------------------------------------------------------
@Test
fun createEmpty_legt_neue_dokumentation_im_status_in_bearbeitung_an() = runDbTest {
env.insertPatient()
val doc = repo.createEmpty("patient-1")
assertEquals("patient-1", doc.patientId)
assertEquals(DocumentationStatus.IN_PROGRESS, doc.status)
assertNull(doc.continuedFromDocumentationId)
assertEquals(DocumentationCatalog.CATALOG_VERSION, doc.catalogVersion)
assertEquals(doc.createdAt, doc.updatedAt)
assertTrue(doc.photos.isEmpty())
assertTrue(doc.signatures.isEmpty())
assertEquals(doc.id, repo.getFullDocumentation(doc.id)?.id)
}
@Test
fun createEmpty_uebernimmt_den_patienten_snapshot() = runDbTest {
env.insertPatient(
firstName = "Erika", lastName = "Musterfrau", birthDate = "1975-12-01",
street = "Hauptstraße 5", postalCode = "54321", city = "Beispielstadt",
)
val snapshot = repo.createEmpty("patient-1").patientSnapshot
assertEquals("Erika", snapshot.firstName)
assertEquals("Musterfrau", snapshot.lastName)
assertEquals(LocalDate.of(1975, 12, 1), snapshot.birthDate)
assertEquals("Hauptstraße 5", snapshot.street)
assertEquals("54321", snapshot.postalCode)
assertEquals("Beispielstadt", snapshot.city)
}
@Test
fun createEmpty_erzeugt_pro_katalogblock_einen_block_mit_titel_und_reihenfolge_snapshot() = runDbTest {
env.insertPatient()
val doc = repo.createEmpty("patient-1")
assertEquals(DocumentationCatalog.blocks.size, doc.blocks.size)
for (def in DocumentationCatalog.blocks) {
val block = doc.blocks.single { it.definitionId == def.id }
assertEquals(def.title, block.title)
assertEquals(def.order, block.order)
}
}
@Test
fun createEmpty_haengt_pro_block_die_generische_freitext_option_als_letztes_element_an() = runDbTest {
env.insertPatient()
val doc = repo.createEmpty("patient-1")
for (def in DocumentationCatalog.blocks) {
val block = doc.blocks.single { it.definitionId == def.id }
assertEquals(def.items.size + 1, block.items.size)
val generic = block.items.last()
assertEquals(def.freeTextDefinitionId, generic.definitionId)
assertEquals(DocumentationCatalog.FREE_TEXT_LABEL, generic.label)
assertEquals((def.items.maxOfOrNull { it.order } ?: 0) + 1, generic.order)
assertTrue(generic.isFreeText)
assertFalse(generic.isStaticHint)
assertFalse(generic.checked)
assertEquals("", generic.freeTextValue)
}
}
@Test
fun createEmpty_uebernimmt_katalogeintraege_1_zu_1_und_hinweistexte_starten_angehakt() = runDbTest {
env.insertPatient()
val doc = repo.createEmpty("patient-1")
for (def in DocumentationCatalog.blocks) {
val block = doc.blocks.single { it.definitionId == def.id }
for (itemDef in def.items) {
val state = block.items.single { it.definitionId == itemDef.id }
assertEquals(itemDef.label, state.label)
assertEquals(itemDef.order, state.order)
assertEquals(itemDef.isFreeText, state.isFreeText)
assertEquals(itemDef.isStaticHint, state.isStaticHint)
// Nur Hinweistexte starten angehakt, alles andere unangehakt/leer.
assertEquals(itemDef.isStaticHint, state.checked)
assertEquals("", state.freeTextValue)
val expectedSpec = itemDef.inputSpec?.let(InputSpecCodec::encode) ?: ""
assertEquals(expectedSpec, state.inputSpec)
}
}
}
@Test
fun createEmpty_ergibt_keinen_bearbeiteten_block_auch_nicht_durch_hinweistexte() = runDbTest {
env.insertPatient()
val doc = repo.createEmpty("patient-1")
assertTrue(doc.blocks.none { it.isEdited })
}
@Test
fun createEmpty_vergibt_fuer_jede_dokumentation_neue_ids() = runDbTest {
env.insertPatient()
val first = repo.createEmpty("patient-1")
val second = repo.createEmpty("patient-1")
assertTrue(first.id != second.id)
val allBlockIds = (first.blocks + second.blocks).map { it.blockStateId }
assertEquals(allBlockIds.size, allBlockIds.toSet().size)
assertEquals(2, env.db.documentationDao().countForPatient("patient-1"))
}
@Test
fun createEmpty_mit_unbekanntem_patienten_wirft_und_legt_nichts_an() = runDbTest {
val error = expectThrows<IllegalStateException> { repo.createEmpty("gibt-es-nicht") }
assertTrue(error.message.orEmpty().contains("gibt-es-nicht"))
assertTrue(env.db.documentationDao().observeAll().first().isEmpty())
}
// --- Lesen / Liste / Suche -----------------------------------------------------------
@Test
fun getFullDocumentation_liefert_null_fuer_unbekannte_id() = runDbTest {
assertNull(repo.getFullDocumentation("unbekannt"))
}
@Test
fun observeAll_liefert_neueste_dokumentation_zuerst_mit_snapshot_namen() = runDbTest {
env.insertPatient()
val older = repo.createEmpty("patient-1")
val newer = repo.createEmpty("patient-1")
env.setCreatedAt(older.id, 1_000L)
env.setCreatedAt(newer.id, 2_000L)
val list = repo.observeAll().first()
assertEquals(listOf(newer.id, older.id), list.map { it.id })
assertEquals("Max Mustermann", list.first().patientFullName)
assertEquals(DocumentationStatus.IN_PROGRESS, list.first().status)
}
@Test
fun search_sucht_gegen_den_aktuellen_patientennamen_zeigt_aber_den_snapshot_namen() = runDbTest {
env.insertPatient()
val doc = repo.createEmpty("patient-1")
val patientDao = env.db.patientDao()
patientDao.update(patientDao.getById("patient-1")!!.copy(lastName = "Musterfrau"))
val found = repo.search("Musterfrau").first()
assertEquals(listOf(doc.id), found.map { it.id })
assertEquals("Max Mustermann", found.single().patientFullName)
assertTrue(repo.search("Mustermann").first().isEmpty())
}
@Test
fun search_findet_auch_ueber_vornamen_und_geburtsdatum() = runDbTest {
env.insertPatient()
val doc = repo.createEmpty("patient-1")
assertEquals(listOf(doc.id), repo.search("Ma").first().map { it.id })
assertEquals(listOf(doc.id), repo.search("1980-03").first().map { it.id })
assertTrue(repo.search("Zzz").first().isEmpty())
}
// --- Autosave / Blockstatus ----------------------------------------------------------
@Test
fun setChecked_persistiert_und_macht_den_block_bearbeitet() = runDbTest {
env.insertPatient()
val doc = repo.createEmpty("patient-1")
val block = doc.blocks.first()
val item = block.firstPlainItem()
repo.setChecked(item.checkItemStateId, true)
val reloaded = repo.getFullDocumentation(doc.id)!!
val reloadedBlock = reloaded.blocks.single { it.blockStateId == block.blockStateId }
assertTrue(reloadedBlock.items.single { it.checkItemStateId == item.checkItemStateId }.checked)
assertTrue(reloadedBlock.isEdited)
assertEquals(1, reloaded.blocks.count { it.isEdited })
}
@Test
fun setChecked_false_nimmt_den_bearbeitet_status_wieder_zurueck() = runDbTest {
env.insertPatient()
val doc = repo.createEmpty("patient-1")
val block = doc.blocks.first()
val item = block.firstPlainItem()
repo.setChecked(item.checkItemStateId, true)
repo.setChecked(item.checkItemStateId, false)
val reloaded = repo.getFullDocumentation(doc.id)!!
assertTrue(reloaded.blocks.none { it.isEdited })
}
@Test
fun freitext_wird_gespeichert_und_zaehlt_erst_mit_haken_als_bearbeitung() = runDbTest {
env.insertPatient()
val doc = repo.createEmpty("patient-1")
val block = doc.blocks.first()
val freeText = block.items.last()
val text = "Größe 3,5 × 2 cm\nzweite Zeile – Rötung"
repo.updateFreeText(freeText.checkItemStateId, text)
val textOnly = repo.getFullDocumentation(doc.id)!!
assertEquals(text, textOnly.blocks.single { it.blockStateId == block.blockStateId }.items.last().freeTextValue)
assertTrue("Freitext ohne Haken darf den Block nicht bearbeitet machen", textOnly.blocks.none { it.isEdited })
repo.setChecked(freeText.checkItemStateId, true)
val checked = repo.getFullDocumentation(doc.id)!!
val reloadedBlock = checked.blocks.single { it.blockStateId == block.blockStateId }
assertTrue(reloadedBlock.items.last().checked)
assertEquals(text, reloadedBlock.items.last().freeTextValue)
assertTrue(reloadedBlock.isEdited)
}
@Test
fun aenderungen_in_einer_dokumentation_beruehren_eine_andere_nicht() = runDbTest {
env.insertPatient()
val first = repo.createEmpty("patient-1")
val second = repo.createEmpty("patient-1")
repo.setChecked(first.blocks.first().firstPlainItem().checkItemStateId, true)
assertTrue(repo.getFullDocumentation(second.id)!!.blocks.none { it.isEdited })
}
// --- Fotos ---------------------------------------------------------------------------
@Test
fun addPhoto_vergibt_fortlaufenden_orderIndex_und_speichert_den_storage_dateinamen() = runDbTest {
env.insertPatient()
val doc = repo.createEmpty("patient-1")
val uri1 = Uri.parse("content://test/1")
val uri2 = Uri.parse("content://test/2")
val p1 = repo.addPhoto(doc.id, uri1)
val p2 = repo.addPhoto(doc.id, uri2)
assertEquals(0, p1.orderIndex)
assertEquals(1, p2.orderIndex)
assertEquals(listOf(doc.id to uri1, doc.id to uri2), env.photoStorage.imported)
val photos = repo.getFullDocumentation(doc.id)!!.photos
assertEquals(listOf(p1.fileName, p2.fileName), photos.map { it.fileName })
assertEquals(listOf(0, 1), photos.map { it.orderIndex })
}
@Test
fun removePhoto_loescht_db_zeile_und_datei_und_vergibt_orderIndex_nicht_neu() = runDbTest {
env.insertPatient()
val doc = repo.createEmpty("patient-1")
val p1 = repo.addPhoto(doc.id, Uri.parse("content://test/1"))
val p2 = repo.addPhoto(doc.id, Uri.parse("content://test/2"))
repo.removePhoto(p1.id)
assertEquals(listOf(p2.id), repo.getFullDocumentation(doc.id)!!.photos.map { it.id })
assertEquals(listOf(doc.id to p1.fileName), env.photoStorage.deleted)
val p3 = repo.addPhoto(doc.id, Uri.parse("content://test/3"))
assertEquals(2, p3.orderIndex)
}
@Test
fun removePhoto_mit_unbekannter_id_tut_nichts() = runDbTest {
env.insertPatient()
repo.createEmpty("patient-1")
repo.removePhoto("unbekannt")
assertTrue(env.photoStorage.deleted.isEmpty())
}
@Test
fun removePhoto_entfernt_die_db_zeile_auch_wenn_die_datei_nicht_geloescht_werden_kann() = runDbTest {
env.insertPatient()
val doc = repo.createEmpty("patient-1")
val photo = repo.addPhoto(doc.id, Uri.parse("content://test/1"))
env.photoStorage.failOnDelete = true
repo.removePhoto(photo.id)
assertTrue(repo.getFullDocumentation(doc.id)!!.photos.isEmpty())
}
// --- Signaturen ----------------------------------------------------------------------
@Test
fun saveSignature_trimmt_den_namen_und_vergibt_fortlaufenden_orderIndex() = runDbTest {
env.insertPatient()
val doc = repo.createEmpty("patient-1")
val first = repo.saveSignature(doc.id, " Anna Schmidt ", signatureBitmap())
val second = repo.saveSignature(doc.id, "Bernd Beispiel", signatureBitmap())
assertEquals("Anna Schmidt", first.signerName)
assertEquals(0, first.orderIndex)
assertEquals(1, second.orderIndex)
val signatures = repo.getFullDocumentation(doc.id)!!.signatures
assertEquals(listOf("Anna Schmidt", "Bernd Beispiel"), signatures.map { it.signerName })
assertEquals(listOf(first.fileName, second.fileName), signatures.map { it.fileName })
assertEquals(listOf(doc.id to first.fileName, doc.id to second.fileName), env.signatureStorage.saved)
}
@Test
fun saveSignature_aktualisiert_das_wundversorger_register() = runDbTest {
env.insertPatient()
val doc = repo.createEmpty("patient-1")
repo.saveSignature(doc.id, "Anna Schmidt", signatureBitmap())
assertEquals("Anna Schmidt", env.woundCarerRepository.getMostRecentlyUsed()?.name)
assertEquals(1, env.woundCarerSignatureStorage.saved.size)
}
@Test
fun saveSignature_mit_leerem_namen_wirft_und_hinterlaesst_keine_datei() = runDbTest {
env.insertPatient()
val doc = repo.createEmpty("patient-1")
expectThrows<IllegalArgumentException> { repo.saveSignature(doc.id, " ", signatureBitmap()) }
assertTrue(env.signatureStorage.saved.isEmpty())
assertTrue(env.db.signatureDao().getForDocumentation(doc.id).isEmpty())
assertNull(env.woundCarerRepository.getMostRecentlyUsed())
}
@Test
fun saveSignature_mit_leerer_zeichnung_wirft_und_hinterlaesst_keine_datei() = runDbTest {
env.insertPatient()
val doc = repo.createEmpty("patient-1")
expectThrows<IllegalArgumentException> { repo.saveSignature(doc.id, "Anna Schmidt", blankBitmap()) }
assertTrue(env.signatureStorage.saved.isEmpty())
assertTrue(env.db.signatureDao().getForDocumentation(doc.id).isEmpty())
assertNull(env.woundCarerRepository.getMostRecentlyUsed())
}
@Test
fun removeSignature_loescht_db_zeile_und_datei() = runDbTest {
env.insertPatient()
val doc = repo.createEmpty("patient-1")
val signature = repo.saveSignature(doc.id, "Anna Schmidt", signatureBitmap())
repo.removeSignature(signature.id)
assertTrue(repo.getFullDocumentation(doc.id)!!.signatures.isEmpty())
assertEquals(listOf(doc.id to signature.fileName), env.signatureStorage.deleted)
}
@Test
fun removeSignature_mit_unbekannter_id_tut_nichts_und_toleriert_storage_fehler() = runDbTest {
env.insertPatient()
val doc = repo.createEmpty("patient-1")
repo.removeSignature("unbekannt")
assertTrue(env.signatureStorage.deleted.isEmpty())
val signature = repo.saveSignature(doc.id, "Anna Schmidt", signatureBitmap())
env.signatureStorage.failOnDelete = true
repo.removeSignature(signature.id)
assertTrue(repo.getFullDocumentation(doc.id)!!.signatures.isEmpty())
}
// --- Löschen -------------------------------------------------------------------------
@Test
fun delete_entfernt_dokumentation_samt_bloecken_fotos_und_signaturen_sowie_dateiordner() = runDbTest {
env.insertPatient()
val doc = repo.createEmpty("patient-1")
repo.addPhoto(doc.id, Uri.parse("content://test/1"))
repo.saveSignature(doc.id, "Anna Schmidt", signatureBitmap())
val blockStateIds = doc.blocks.map { it.blockStateId }
repo.delete(doc.id)
assertNull(repo.getFullDocumentation(doc.id))
assertTrue(env.db.blockStateDao().getForDocumentation(doc.id).isEmpty())
assertTrue(env.db.photoDao().getForDocumentation(doc.id).isEmpty())
assertTrue(env.db.signatureDao().getForDocumentation(doc.id).isEmpty())
for (blockStateId in blockStateIds) {
assertTrue(env.db.checkItemStateDao().getForBlockState(blockStateId).isEmpty())
}
assertEquals(listOf(doc.id), env.photoStorage.deletedAll)
assertEquals(listOf(doc.id), env.signatureStorage.deletedAll)
assertNotNull("Der Patient darf nicht mitgelöscht werden", env.patientRepository.getById("patient-1"))
}
@Test
fun delete_beruehrt_das_wundversorger_register_nicht() = runDbTest {
env.insertPatient()
val doc = repo.createEmpty("patient-1")
repo.saveSignature(doc.id, "Anna Schmidt", signatureBitmap())
repo.delete(doc.id)
assertEquals("Anna Schmidt", env.woundCarerRepository.getMostRecentlyUsed()?.name)
assertTrue(env.woundCarerSignatureStorage.deletedAll.isEmpty())
assertTrue(env.woundCarerSignatureStorage.deleted.isEmpty())
}
@Test
fun delete_mit_unbekannter_id_tut_nichts() = runDbTest {
repo.delete("unbekannt")
assertTrue(env.photoStorage.deletedAll.isEmpty())
assertTrue(env.signatureStorage.deletedAll.isEmpty())
}
@Test
fun delete_entfernt_die_dokumentation_auch_wenn_dateiordner_nicht_geloescht_werden_koennen() = runDbTest {
env.insertPatient()
val doc = repo.createEmpty("patient-1")
env.photoStorage.failOnDeleteAll = true
env.signatureStorage.failOnDeleteAll = true
repo.delete(doc.id)
assertNull(repo.getFullDocumentation(doc.id))
}
// --- PDF-Export ----------------------------------------------------------------------
@Test
fun exportPdf_fuer_unbekannte_dokumentation_liefert_fachlichen_fehler_ohne_exporter_aufruf() = runDbTest {
val result = repo.exportPdf("unbekannt")
assertEquals(PdfExportResult.Failure(PdfExportError.DocumentationNotFound), result)
assertTrue(env.pdfExporter.models.isEmpty())
}
@Test
fun exportPdf_uebergibt_nur_bearbeitete_bloecke_und_den_schemakonformen_dateinamen() = runDbTest {
env.insertPatient()
val doc = repo.createEmpty("patient-1")
val block = doc.blocks.first()
val item = block.firstPlainItem()
repo.setChecked(item.checkItemStateId, true)
val result = repo.exportPdf(doc.id)
assertTrue(result is PdfExportResult.Success)
val model = env.pdfExporter.models.single()
assertEquals(doc.id, model.documentationId)
assertEquals(listOf(block.title), model.blocks.map { it.title })
assertTrue(model.blocks.single().items.any { it.label == item.label })
val createdDate = doc.createdAt.atZone(ZoneId.systemDefault()).toLocalDate()
assertEquals("${createdDate}_Max_Mustermann_1980-03-14.pdf", model.fileName)
}
@Test
fun exportPdf_verwendet_den_snapshot_und_nicht_den_aktuellen_patientendatensatz() = runDbTest {
env.insertPatient()
val doc = repo.createEmpty("patient-1")
val patientDao = env.db.patientDao()
patientDao.update(patientDao.getById("patient-1")!!.copy(lastName = "Musterfrau", city = "Neustadt"))
repo.exportPdf(doc.id)
val model = env.pdfExporter.models.single()
assertEquals("Max Mustermann", model.patientFullName)
assertEquals("Musterstraße 1, 12345 Musterstadt", model.patientDisplayAddress)
}
@Test
fun exportPdf_reicht_das_exporter_ergebnis_unveraendert_durch() = runDbTest {
env.insertPatient()
val doc = repo.createEmpty("patient-1")
val failure = PdfExportResult.Failure(PdfExportError.WriteFailed("Speicher voll"))
env.pdfExporter.result = { failure }
assertEquals(failure, repo.exportPdf(doc.id))
}
@Test
fun convertPdfToImages_delegiert_an_den_converter() = runDbTest {
val pdf = File("/fake/pdfs/test.pdf")
env.pdfToImagesConverter.result = PdfToImagesResult.Failure.FileNotFound
val result = repo.convertPdfToImages("doc-1", pdf)
assertEquals(PdfToImagesResult.Failure.FileNotFound, result)
assertEquals(listOf("doc-1" to pdf), env.pdfToImagesConverter.calls)
}
}
@@ -0,0 +1,223 @@
package de.medapp.wunddoku.data.repository
import android.database.sqlite.SQLiteConstraintException
import androidx.test.ext.junit.runners.AndroidJUnit4
import de.medapp.wunddoku.domain.model.Patient
import de.medapp.wunddoku.testutil.TestEnvironment
import de.medapp.wunddoku.testutil.expectThrows
import de.medapp.wunddoku.testutil.runDbTest
import kotlinx.coroutines.flow.first
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import java.time.Instant
import java.time.LocalDate
import java.util.UUID
/** Repository-Tests für [PatientRepository] gegen eine echte In-Memory-Room-Datenbank. */
@RunWith(AndroidJUnit4::class)
class PatientRepositoryTest {
private lateinit var env: TestEnvironment
private val repo get() = env.patientRepository
@Before
fun setUp() {
env = TestEnvironment()
}
@After
fun tearDown() {
env.close()
}
private fun patient(
id: String = "p-1",
firstName: String = "Max",
lastName: String = "Mustermann",
birthDate: LocalDate = LocalDate.of(1980, 3, 14),
) = Patient(
id = id,
firstName = firstName,
lastName = lastName,
birthDate = birthDate,
street = "Musterstraße 1",
postalCode = "12345",
city = "Musterstadt",
phone1 = "0123 456789",
phone2 = "0987 654321",
phone3 = null,
createdAt = Instant.EPOCH,
updatedAt = Instant.EPOCH,
)
// --- upsert / getById ----------------------------------------------------------------
@Test
fun upsert_legt_neuen_patienten_an_und_setzt_zeitstempel_zentral() = runDbTest {
val before = Instant.now().toEpochMilli()
val saved = repo.upsert(patient())
assertEquals(saved.createdAt, saved.updatedAt)
assertTrue(saved.createdAt.toEpochMilli() >= before)
val loaded = repo.getById("p-1")!!
assertEquals("Max", loaded.firstName)
assertEquals("Mustermann", loaded.lastName)
assertEquals(LocalDate.of(1980, 3, 14), loaded.birthDate)
assertEquals("Musterstraße 1", loaded.street)
assertEquals("12345", loaded.postalCode)
assertEquals("Musterstadt", loaded.city)
assertEquals("0123 456789", loaded.phone1)
assertEquals("0987 654321", loaded.phone2)
assertNull(loaded.phone3)
// Die Datenbank speichert Millisekunden, upsert() liefert Instant.now() mit höherer Genauigkeit.
assertEquals(saved.createdAt.toEpochMilli(), loaded.createdAt.toEpochMilli())
}
@Test
fun upsert_aktualisiert_bestehenden_patienten_und_behaelt_createdAt() = runDbTest {
val first = repo.upsert(patient())
// createdAt im übergebenen Objekt wird bewusst ignoriert (Instant.EPOCH aus dem Helper).
val second = repo.upsert(patient(lastName = "Musterfrau"))
assertEquals(first.createdAt.toEpochMilli(), second.createdAt.toEpochMilli())
assertTrue(second.updatedAt.toEpochMilli() >= first.updatedAt.toEpochMilli())
val loaded = repo.getById("p-1")!!
assertEquals("Musterfrau", loaded.lastName)
assertEquals(first.createdAt.toEpochMilli(), loaded.createdAt.toEpochMilli())
}
@Test
fun upsert_erzeugt_bei_unterschiedlichen_ids_getrennte_datensaetze() = runDbTest {
repo.upsert(patient(id = "p-1"))
repo.upsert(patient(id = "p-2", firstName = "Erika", lastName = "Musterfrau"))
assertEquals("Max", repo.getById("p-1")!!.firstName)
assertEquals("Erika", repo.getById("p-2")!!.firstName)
}
@Test
fun getById_liefert_null_fuer_unbekannte_id() = runDbTest {
assertNull(repo.getById("unbekannt"))
}
@Test
fun newId_liefert_gueltige_und_eindeutige_uuids() {
val a = repo.newId()
val b = repo.newId()
assertEquals(a, UUID.fromString(a).toString())
assertTrue(a != b)
}
// --- findExisting --------------------------------------------------------------------
@Test
fun findExisting_trifft_nur_bei_gleichem_namen_und_geburtsdatum() = runDbTest {
repo.upsert(patient())
assertEquals("p-1", repo.findExisting("Max", "Mustermann", LocalDate.of(1980, 3, 14))?.id)
assertNull(repo.findExisting("Max", "Mustermann", LocalDate.of(1980, 3, 15)))
assertNull(repo.findExisting("Moritz", "Mustermann", LocalDate.of(1980, 3, 14)))
assertNull(repo.findExisting("Max", "Musterfrau", LocalDate.of(1980, 3, 14)))
}
// --- Dokumentationen / Löschen -------------------------------------------------------
@Test
fun hasDocumentations_ist_erst_nach_dem_anlegen_einer_dokumentation_true() = runDbTest {
repo.upsert(patient())
assertFalse(repo.hasDocumentations("p-1"))
env.documentationRepository.createEmpty("p-1")
assertTrue(repo.hasDocumentations("p-1"))
}
@Test
fun delete_entfernt_patienten_ohne_dokumentation() = runDbTest {
repo.upsert(patient())
repo.delete("p-1")
assertNull(repo.getById("p-1"))
}
@Test
fun delete_wird_bei_vorhandener_dokumentation_von_der_datenbank_verhindert() = runDbTest {
repo.upsert(patient())
val doc = env.documentationRepository.createEmpty("p-1")
expectThrows<SQLiteConstraintException> { repo.delete("p-1") }
assertNotNull(repo.getById("p-1"))
assertNotNull(env.documentationRepository.getFullDocumentation(doc.id))
}
@Test
fun delete_mit_unbekannter_id_tut_nichts() = runDbTest {
repo.upsert(patient())
repo.delete("unbekannt")
assertNotNull(repo.getById("p-1"))
}
// --- Liste / Suche -------------------------------------------------------------------
@Test
fun observeAll_sortiert_nach_nachname_dann_vorname() = runDbTest {
repo.upsert(patient(id = "p-1", firstName = "Max", lastName = "Müller"))
repo.upsert(patient(id = "p-2", firstName = "Anna", lastName = "Müller"))
repo.upsert(patient(id = "p-3", firstName = "Zoe", lastName = "Adler"))
val list = repo.observeAll().first()
assertEquals(listOf("p-3", "p-2", "p-1"), list.map { it.id })
assertEquals("Zoe Adler", list.first().fullName)
}
@Test
fun observeAll_zeigt_datum_der_letzten_dokumentation_oder_null() = runDbTest {
repo.upsert(patient(id = "p-1"))
repo.upsert(patient(id = "p-2", firstName = "Erika", lastName = "Musterfrau"))
val older = env.documentationRepository.createEmpty("p-1")
val newer = env.documentationRepository.createEmpty("p-1")
env.setCreatedAt(older.id, 1_000L)
env.setCreatedAt(newer.id, 2_000L)
val byId = repo.observeAll().first().associateBy { it.id }
assertEquals(Instant.ofEpochMilli(2_000L), byId.getValue("p-1").lastDocumentationAt)
assertNull(byId.getValue("p-2").lastDocumentationAt)
}
@Test
fun search_findet_ueber_nachname_vorname_und_geburtsdatum() = runDbTest {
repo.upsert(patient(id = "p-1", firstName = "Max", lastName = "Mustermann"))
repo.upsert(patient(id = "p-2", firstName = "Erika", lastName = "Musterfrau", birthDate = LocalDate.of(1975, 12, 1)))
// Beide Nachnamen beginnen mit "Muster": sortiert nach Nachname -> Musterfrau vor Mustermann.
assertEquals(listOf("p-2", "p-1"), repo.search("Muster").first().map { it.id })
assertEquals(listOf("p-1"), repo.search("Max").first().map { it.id })
assertEquals(listOf("p-2"), repo.search("Erika").first().map { it.id })
assertEquals(listOf("p-2"), repo.search("1975-12").first().map { it.id })
assertTrue(repo.search("Zzz").first().isEmpty())
}
@Test
fun search_ist_unabhaengig_von_gross_und_kleinschreibung() = runDbTest {
repo.upsert(patient())
assertEquals(listOf("p-1"), repo.search("mustermann").first().map { it.id })
assertEquals(listOf("p-1"), repo.search("MAX").first().map { it.id })
}
}
@@ -0,0 +1,156 @@
package de.medapp.wunddoku.data.repository
import androidx.test.ext.junit.runners.AndroidJUnit4
import de.medapp.wunddoku.testutil.TestEnvironment
import de.medapp.wunddoku.testutil.runDbTest
import de.medapp.wunddoku.testutil.signatureBitmap
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
/** Repository-Tests für das Wundversorger-Register ([WoundCarerRepository]). */
@RunWith(AndroidJUnit4::class)
class WoundCarerRepositoryTest {
private lateinit var env: TestEnvironment
private val repo get() = env.woundCarerRepository
private val storage get() = env.woundCarerSignatureStorage
@Before
fun setUp() {
env = TestEnvironment()
}
@After
fun tearDown() {
env.close()
}
/** updatedAt basiert auf System.currentTimeMillis() - kurze Pause für eindeutige Reihenfolge. */
private fun tick() = Thread.sleep(5)
@Test
fun getMostRecentlyUsed_ist_null_solange_nie_eine_signatur_gespeichert_wurde() = runDbTest {
assertNull(repo.getMostRecentlyUsed())
}
@Test
fun recordSignatureUsage_legt_neuen_eintrag_mit_eigener_signaturdatei_an() = runDbTest {
repo.recordSignatureUsage("Anna Schmidt", signatureBitmap())
val carer = repo.getMostRecentlyUsed()!!
assertEquals("Anna Schmidt", carer.name)
assertEquals(1, storage.saved.size)
assertEquals(carer.id to carer.signatureFileName, storage.saved.single())
}
@Test
fun recordSignatureUsage_aktualisiert_bekannten_namen_case_insensitiv_und_ersetzt_die_signatur() = runDbTest {
repo.recordSignatureUsage("Anna Schmidt", signatureBitmap())
val first = repo.getMostRecentlyUsed()!!
tick()
repo.recordSignatureUsage("anna schmidt", signatureBitmap())
val second = repo.getMostRecentlyUsed()!!
assertEquals(first.id, second.id)
assertEquals("anna schmidt", second.name) // zuletzt getippte Schreibweise
assertNotEquals(first.signatureFileName, second.signatureFileName)
assertTrue(second.updatedAt >= first.updatedAt)
assertEquals(1, repo.search("a").size)
// Alte Signaturdatei wurde entfernt, genau eine Datei bleibt zugeordnet.
assertEquals(listOf(first.id to first.signatureFileName), storage.deleted)
}
@Test
fun recordSignatureUsage_ignoriert_fehler_beim_loeschen_der_alten_signaturdatei() = runDbTest {
repo.recordSignatureUsage("Anna Schmidt", signatureBitmap())
storage.failOnDelete = true
tick()
repo.recordSignatureUsage("Anna Schmidt", signatureBitmap())
assertEquals(1, repo.search("Anna").size)
}
@Test
fun getMostRecentlyUsed_liefert_den_zuletzt_aktualisierten_wundversorger() = runDbTest {
repo.recordSignatureUsage("Anna Schmidt", signatureBitmap())
tick()
repo.recordSignatureUsage("Bernd Beispiel", signatureBitmap())
assertEquals("Bernd Beispiel", repo.getMostRecentlyUsed()?.name)
tick()
repo.recordSignatureUsage("Anna Schmidt", signatureBitmap())
assertEquals("Anna Schmidt", repo.getMostRecentlyUsed()?.name)
}
// --- search --------------------------------------------------------------------------
@Test
fun search_liefert_praefix_treffer_case_insensitiv_neueste_zuerst() = runDbTest {
repo.recordSignatureUsage("Anna Schmidt", signatureBitmap())
tick()
repo.recordSignatureUsage("Andreas Müller", signatureBitmap())
tick()
repo.recordSignatureUsage("Bernd Beispiel", signatureBitmap())
val result = repo.search("an")
assertEquals(listOf("Andreas Müller", "Anna Schmidt"), result.map { it.name })
}
@Test
fun search_trimmt_die_eingabe_und_liefert_bei_leerer_eingabe_nichts() = runDbTest {
repo.recordSignatureUsage("Anna Schmidt", signatureBitmap())
assertEquals(listOf("Anna Schmidt"), repo.search(" Ann ").map { it.name })
assertTrue(repo.search("").isEmpty())
assertTrue(repo.search(" ").isEmpty())
}
@Test
fun search_ist_ein_praefix_und_kein_teilstring_abgleich() = runDbTest {
repo.recordSignatureUsage("Anna Schmidt", signatureBitmap())
assertTrue(repo.search("Schmidt").isEmpty())
}
@Test
fun search_beachtet_das_limit() = runDbTest {
for (name in listOf("Anna A", "Anna B", "Anna C", "Anna D")) {
repo.recordSignatureUsage(name, signatureBitmap())
tick()
}
assertEquals(2, repo.search("Anna", limit = 2).size)
assertEquals(4, repo.search("Anna").size) // Standardlimit 5 deckt alle vier ab
assertEquals(listOf("Anna D", "Anna C"), repo.search("Anna", limit = 2).map { it.name })
}
// --- loadSignatureBitmap -------------------------------------------------------------
@Test
fun loadSignatureBitmap_delegiert_an_den_wundversorger_speicher() = runDbTest {
val bitmap = signatureBitmap()
storage.decodeResult = bitmap
val result = repo.loadSignatureBitmap("carer-1", "sig.png")
assertEquals(bitmap, result)
assertEquals(listOf("carer-1" to "sig.png"), storage.decodeCalls)
}
@Test
fun loadSignatureBitmap_liefert_null_wenn_die_datei_fehlt() = runDbTest {
storage.decodeResult = null
assertNull(repo.loadSignatureBitmap("carer-1", "fehlt.png"))
}
}
@@ -0,0 +1,154 @@
package de.medapp.wunddoku.testutil
import de.medapp.wunddoku.data.entity.BlockStateEntity
import de.medapp.wunddoku.data.entity.CheckItemStateEntity
import de.medapp.wunddoku.data.entity.DocumentationEntity
import de.medapp.wunddoku.data.entity.PatientEntity
import de.medapp.wunddoku.data.entity.PhotoEntity
import de.medapp.wunddoku.data.entity.SignatureEntity
import de.medapp.wunddoku.data.entity.WoundCarerEntity
import de.medapp.wunddoku.domain.model.DocumentationStatus
/** Builder für Room-Entities mit sinnvollen Standardwerten - Tests überschreiben nur, was sie brauchen. */
fun patientEntity(
id: String = "p-1",
firstName: String = "Max",
lastName: String = "Mustermann",
birthDate: String = "1980-03-14",
street: String = "Musterstraße 1",
postalCode: String = "12345",
city: String = "Musterstadt",
phone1: String = "0123 456789",
phone2: String? = null,
phone3: String? = null,
createdAt: Long = 1_000L,
updatedAt: Long = createdAt,
) = PatientEntity(
id = id,
firstName = firstName,
lastName = lastName,
birthDate = birthDate,
street = street,
postalCode = postalCode,
city = city,
phone1 = phone1,
phone2 = phone2,
phone3 = phone3,
createdAt = createdAt,
updatedAt = updatedAt,
)
fun documentationEntity(
id: String = "d-1",
patientId: String = "p-1",
status: DocumentationStatus = DocumentationStatus.IN_PROGRESS,
createdAt: Long = 1_000L,
updatedAt: Long = createdAt,
continuedFromDocumentationId: String? = null,
pdfLastGeneratedAt: Long? = null,
catalogVersion: Int = 4,
firstNameSnapshot: String = "Max",
lastNameSnapshot: String = "Mustermann",
birthDateSnapshot: String = "1980-03-14",
streetSnapshot: String = "Musterstraße 1",
postalCodeSnapshot: String = "12345",
citySnapshot: String = "Musterstadt",
) = DocumentationEntity(
id = id,
patientId = patientId,
status = status,
createdAt = createdAt,
updatedAt = updatedAt,
continuedFromDocumentationId = continuedFromDocumentationId,
pdfLastGeneratedAt = pdfLastGeneratedAt,
catalogVersion = catalogVersion,
patientFirstNameSnapshot = firstNameSnapshot,
patientLastNameSnapshot = lastNameSnapshot,
patientBirthDateSnapshot = birthDateSnapshot,
patientStreetSnapshot = streetSnapshot,
patientPostalCodeSnapshot = postalCodeSnapshot,
patientCitySnapshot = citySnapshot,
)
fun blockStateEntity(
id: String = "b-1",
documentationId: String = "d-1",
blockDefinitionId: String = "wundart",
title: String = "Wundart",
order: Int = 1,
updatedAt: Long = 1_000L,
) = BlockStateEntity(
id = id,
documentationId = documentationId,
blockDefinitionId = blockDefinitionId,
titleSnapshot = title,
blockOrder = order,
updatedAt = updatedAt,
)
fun checkItemStateEntity(
id: String = "c-1",
blockStateId: String = "b-1",
definitionId: String = "item_1",
label: String = "Eintrag 1",
order: Int = 1,
checked: Boolean = false,
isFreeText: Boolean = false,
freeTextValue: String = "",
isStaticHint: Boolean = false,
inputSpec: String = "",
) = CheckItemStateEntity(
id = id,
blockStateId = blockStateId,
checkItemDefinitionId = definitionId,
labelSnapshot = label,
itemOrder = order,
checked = checked,
isFreeText = isFreeText,
freeTextValue = freeTextValue,
isStaticHint = isStaticHint,
inputSpec = inputSpec,
)
fun photoEntity(
id: String = "ph-1",
documentationId: String = "d-1",
fileName: String = "foto.jpg",
orderIndex: Int = 0,
createdAt: Long = 1_000L,
) = PhotoEntity(
id = id,
documentationId = documentationId,
fileName = fileName,
orderIndex = orderIndex,
createdAt = createdAt,
)
fun signatureEntity(
id: String = "s-1",
documentationId: String = "d-1",
signerName: String = "Anna Schmidt",
fileName: String = "unterschrift.png",
orderIndex: Int = 0,
createdAt: Long = 1_000L,
) = SignatureEntity(
id = id,
documentationId = documentationId,
signerName = signerName,
fileName = fileName,
orderIndex = orderIndex,
createdAt = createdAt,
)
fun woundCarerEntity(
id: String = "w-1",
name: String = "Anna Schmidt",
signatureFileName: String = "w-sig.png",
updatedAt: Long = 1_000L,
) = WoundCarerEntity(
id = id,
name = name,
signatureFileName = signatureFileName,
updatedAt = updatedAt,
)
@@ -0,0 +1,138 @@
package de.medapp.wunddoku.testutil
import android.graphics.Bitmap
import android.net.Uri
import de.medapp.wunddoku.media.PhotoStorage
import de.medapp.wunddoku.pdf.DocumentationPdfExporter
import de.medapp.wunddoku.pdf.PdfExportModel
import de.medapp.wunddoku.pdf.PdfExportResult
import de.medapp.wunddoku.pdf.PdfToImagesConverter
import de.medapp.wunddoku.pdf.PdfToImagesResult
import de.medapp.wunddoku.signature.SignatureStorage
import java.io.File
import java.io.IOException
/** Aufgezeichneter Aufruf von [PhotoStorage.duplicate]. */
data class DuplicateCall(
val sourceDocumentationId: String,
val sourceFileName: String,
val targetDocumentationId: String,
)
/**
* In-Memory-Fake für [PhotoStorage]: schreibt keine Dateien, zeichnet aber alle Aufrufe auf und
* vergibt eindeutige Dateinamen. Fehler lassen sich gezielt auslösen.
*/
class FakePhotoStorage : PhotoStorage {
val imported = mutableListOf<Pair<String, Uri>>()
val duplicated = mutableListOf<DuplicateCall>()
val deleted = mutableListOf<Pair<String, String>>()
val deletedAll = mutableListOf<String>()
var failOnDelete = false
var failOnDeleteAll = false
/** 1-basierte Nummer des [duplicate]-Aufrufs, der mit einer [IOException] fehlschlagen soll. */
var failOnDuplicateCall: Int? = null
private var counter = 0
override suspend fun importFrom(documentationId: String, sourceUri: Uri): String {
imported += documentationId to sourceUri
return "imported_${++counter}.jpg"
}
override suspend fun duplicate(
sourceDocumentationId: String,
sourceFileName: String,
targetDocumentationId: String,
): String {
duplicated += DuplicateCall(sourceDocumentationId, sourceFileName, targetDocumentationId)
if (failOnDuplicateCall == duplicated.size) throw IOException("Kopieren fehlgeschlagen (Test)")
return "copy_${++counter}.jpg"
}
override suspend fun delete(documentationId: String, fileName: String) {
if (failOnDelete) throw IOException("Löschen fehlgeschlagen (Test)")
deleted += documentationId to fileName
}
override suspend fun deleteAll(documentationId: String) {
if (failOnDeleteAll) throw IOException("Ordner löschen fehlgeschlagen (Test)")
deletedAll += documentationId
}
override fun resolve(documentationId: String, fileName: String): File =
File("/fake/photos/$documentationId/$fileName")
override suspend fun decodeThumbnail(documentationId: String, fileName: String, maxDimensionPx: Int): Bitmap? = null
override suspend fun decodeFitted(
documentationId: String,
fileName: String,
maxWidthPx: Int,
maxHeightPx: Int,
): Bitmap? = null
}
/** In-Memory-Fake für [SignatureStorage] (analog [FakePhotoStorage]). */
class FakeSignatureStorage : SignatureStorage {
val saved = mutableListOf<Pair<String, String>>() // key (documentationId bzw. woundCarerId) -> fileName
val deleted = mutableListOf<Pair<String, String>>()
val deletedAll = mutableListOf<String>()
val decodeCalls = mutableListOf<Pair<String, String>>()
var failOnDelete = false
var failOnDeleteAll = false
var decodeResult: Bitmap? = null
private var counter = 0
override suspend fun save(documentationId: String, bitmap: Bitmap): String {
val fileName = "sig_${++counter}.png"
saved += documentationId to fileName
return fileName
}
override suspend fun delete(documentationId: String, fileName: String) {
if (failOnDelete) throw IOException("Löschen fehlgeschlagen (Test)")
deleted += documentationId to fileName
}
override suspend fun deleteAll(documentationId: String) {
if (failOnDeleteAll) throw IOException("Ordner löschen fehlgeschlagen (Test)")
deletedAll += documentationId
}
override fun resolve(documentationId: String, fileName: String): File =
File("/fake/signatures/$documentationId/$fileName")
override suspend fun decode(documentationId: String, fileName: String): Bitmap? {
decodeCalls += documentationId to fileName
return decodeResult
}
}
/** Zeichnet die an den Exporter übergebenen Modelle auf und liefert ein konfigurierbares Ergebnis. */
class FakePdfExporter : DocumentationPdfExporter {
val models = mutableListOf<PdfExportModel>()
var result: ((PdfExportModel) -> PdfExportResult) = { model ->
PdfExportResult.Success(File("/fake/pdfs/${model.fileName}"))
}
override suspend fun export(model: PdfExportModel): PdfExportResult {
models += model
return result(model)
}
}
/** Zeichnet Aufrufe auf und liefert ein konfigurierbares Ergebnis. */
class FakePdfToImagesConverter : PdfToImagesConverter {
val calls = mutableListOf<Pair<String, File>>()
var result: PdfToImagesResult = PdfToImagesResult.Success(emptyList())
override suspend fun convert(documentationId: String, pdfFile: File): PdfToImagesResult {
calls += documentationId to pdfFile
return result
}
}
@@ -0,0 +1,107 @@
package de.medapp.wunddoku.testutil
import androidx.room.Room
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import de.medapp.wunddoku.data.dao.BlockStateDao
import de.medapp.wunddoku.data.dao.CheckItemStateDao
import de.medapp.wunddoku.data.dao.DocumentationDao
import de.medapp.wunddoku.data.dao.PatientDao
import de.medapp.wunddoku.data.dao.PhotoDao
import de.medapp.wunddoku.data.dao.SignatureDao
import de.medapp.wunddoku.data.dao.WoundCarerDao
import de.medapp.wunddoku.data.database.AppDatabase
import de.medapp.wunddoku.data.entity.DocumentationEntity
import de.medapp.wunddoku.data.entity.PatientEntity
import de.medapp.wunddoku.domain.model.DocumentationStatus
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withTimeout
import org.junit.After
import org.junit.Before
import org.junit.runner.RunWith
/**
* Basisklasse für alle Room-Tests (QA-002): echte In-Memory-Datenbank mit demselben Schema,
* denselben Foreign Keys und denselben Indizes wie in Produktion. Jeder Test bekommt eine
* frische, leere Datenbank.
*/
@RunWith(AndroidJUnit4::class)
abstract class RoomTestBase {
protected lateinit var db: AppDatabase
protected val patientDao: PatientDao get() = db.patientDao()
protected val documentationDao: DocumentationDao get() = db.documentationDao()
protected val blockStateDao: BlockStateDao get() = db.blockStateDao()
protected val checkItemStateDao: CheckItemStateDao get() = db.checkItemStateDao()
protected val photoDao: PhotoDao get() = db.photoDao()
protected val signatureDao: SignatureDao get() = db.signatureDao()
protected val woundCarerDao: WoundCarerDao get() = db.woundCarerDao()
@Before
fun createDatabase() {
db = Room.inMemoryDatabaseBuilder(
InstrumentationRegistry.getInstrumentation().targetContext,
AppDatabase::class.java,
).build()
}
@After
fun closeDatabase() {
db.close()
}
// --- Seed-Helfer ---------------------------------------------------------------------
protected suspend fun seedPatient(
id: String = "p-1",
firstName: String = "Max",
lastName: String = "Mustermann",
birthDate: String = "1980-03-14",
): PatientEntity {
val entity = patientEntity(id = id, firstName = firstName, lastName = lastName, birthDate = birthDate)
patientDao.insert(entity)
return entity
}
/** Legt eine Dokumentation an; fehlt der Patient, wird er mit Standarddaten angelegt. */
protected suspend fun seedDocumentation(
id: String = "d-1",
patientId: String = "p-1",
createdAt: Long = 1_000L,
status: DocumentationStatus = DocumentationStatus.IN_PROGRESS,
): DocumentationEntity {
if (patientDao.getById(patientId) == null) seedPatient(id = patientId)
val entity = documentationEntity(id = id, patientId = patientId, createdAt = createdAt, status = status)
documentationDao.insert(entity)
return entity
}
// --- Roh-SQL-Helfer (unabhängig von DAO-Logik prüfen, was wirklich in der DB steht) ----
protected fun queryInt(sql: String): Int =
db.openHelper.readableDatabase.query(sql).use { cursor ->
cursor.moveToFirst()
cursor.getInt(0)
}
protected fun queryString(sql: String): String =
db.openHelper.readableDatabase.query(sql).use { cursor ->
cursor.moveToFirst()
cursor.getString(0)
}
protected fun countRows(table: String): Int = queryInt("SELECT COUNT(*) FROM $table")
protected fun execSql(sql: String) {
db.openHelper.writableDatabase.execSQL(sql)
}
}
/**
* Wartet (mit Timeout), bis ein Flow einen Wert liefert, der [predicate] erfüllt.
* Robust für Reaktivitäts-Tests, egal ob Änderung vor oder nach dem ersten Emit passiert.
*/
suspend fun <T> Flow<T>.awaitMatching(timeoutMs: Long = 5_000L, predicate: (T) -> Boolean): T =
withTimeout(timeoutMs) { first { predicate(it) } }
@@ -0,0 +1,133 @@
package de.medapp.wunddoku.testutil
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import androidx.room.Room
import androidx.test.platform.app.InstrumentationRegistry
import de.medapp.wunddoku.data.database.AppDatabase
import de.medapp.wunddoku.data.entity.PatientEntity
import de.medapp.wunddoku.data.repository.DocumentationRepository
import de.medapp.wunddoku.data.repository.PatientRepository
import de.medapp.wunddoku.data.repository.WoundCarerRepository
import de.medapp.wunddoku.domain.model.CheckItem
import de.medapp.wunddoku.domain.model.DocumentationBlock
import de.medapp.wunddoku.domain.model.DocumentationStatus
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.runBlocking
/**
* Gemeinsame Testumgebung für alle Instrumented-Tests: echte Room-Datenbank im Arbeitsspeicher
* (gleiche Entities/DAOs/Foreign Keys wie in Produktion), aber Fakes für Dateispeicher und
* PDF-Erzeugung. Jeder Test erzeugt eine eigene Instanz und ruft am Ende [close] auf.
*/
class TestEnvironment {
val db: AppDatabase = Room.inMemoryDatabaseBuilder(
InstrumentationRegistry.getInstrumentation().targetContext,
AppDatabase::class.java,
).build()
val photoStorage = FakePhotoStorage()
val signatureStorage = FakeSignatureStorage()
val woundCarerSignatureStorage = FakeSignatureStorage()
val pdfExporter = FakePdfExporter()
val pdfToImagesConverter = FakePdfToImagesConverter()
val patientRepository = PatientRepository(patientDao = db.patientDao())
val woundCarerRepository = WoundCarerRepository(
woundCarerDao = db.woundCarerDao(),
woundCarerSignatureStorage = woundCarerSignatureStorage,
)
val documentationRepository = DocumentationRepository(
documentationDao = db.documentationDao(),
blockStateDao = db.blockStateDao(),
checkItemStateDao = db.checkItemStateDao(),
photoDao = db.photoDao(),
signatureDao = db.signatureDao(),
patientDao = db.patientDao(),
photoStorage = photoStorage,
signatureStorage = signatureStorage,
woundCarerRepository = woundCarerRepository,
pdfExporter = pdfExporter,
pdfToImagesConverter = pdfToImagesConverter,
db = db,
)
fun close() = db.close()
/** Legt direkt über das DAO einen Patienten an (ohne Repository-Logik). */
suspend fun insertPatient(
id: String = "patient-1",
firstName: String = "Max",
lastName: String = "Mustermann",
birthDate: String = "1980-03-14",
street: String = "Musterstraße 1",
postalCode: String = "12345",
city: String = "Musterstadt",
): PatientEntity {
val entity = PatientEntity(
id = id,
firstName = firstName,
lastName = lastName,
birthDate = birthDate,
street = street,
postalCode = postalCode,
city = city,
phone1 = "0123 456789",
phone2 = null,
phone3 = null,
createdAt = 1_000L,
updatedAt = 1_000L,
)
db.patientDao().insert(entity)
return entity
}
/** Setzt createdAt einer Dokumentation gezielt (vermeidet zeitabhängige Tests). */
suspend fun setCreatedAt(documentationId: String, createdAt: Long) {
val dao = db.documentationDao()
dao.update(dao.getById(documentationId)!!.copy(createdAt = createdAt))
}
suspend fun setStatus(documentationId: String, status: DocumentationStatus) {
val dao = db.documentationDao()
dao.update(dao.getById(documentationId)!!.copy(status = status))
}
}
/** Führt einen Test mit Suspend-Aufrufen aus; Rückgabetyp Unit, damit JUnit die Methode akzeptiert. */
fun runDbTest(block: suspend CoroutineScope.() -> Unit) {
runBlocking { block() }
}
/** Erstes Item eines Blocks, das eine normale Checkbox ist (kein Freitext, hint oder strukturierte Eingabe). */
fun DocumentationBlock.firstPlainItem(): CheckItem =
items.first { !it.isFreeText && !it.isStaticHint && it.inputSpec.isEmpty() }
/** Weißes Bitmap ohne jeden Strich - gilt als "leere Signatur". */
fun blankBitmap(): Bitmap =
Bitmap.createBitmap(40, 20, Bitmap.Config.ARGB_8888).apply { eraseColor(Color.WHITE) }
/** Weißes Bitmap mit einem schwarzen Strich - gilt als gültige Signatur. */
fun signatureBitmap(): Bitmap = blankBitmap().also { bitmap ->
val paint = Paint().apply {
color = Color.BLACK
strokeWidth = 3f
}
Canvas(bitmap).drawLine(2f, 2f, 38f, 18f, paint)
}
/** Erwartet, dass [block] eine Exception vom Typ [T] wirft, und liefert sie zurück. */
inline fun <reified T : Throwable> expectThrows(block: () -> Unit): T {
try {
block()
} catch (e: Throwable) {
if (e is T) return e
throw e
}
throw AssertionError("Erwartet: ${T::class.java.simpleName}, es wurde aber keine Exception geworfen")
}
@@ -48,6 +48,11 @@ import de.medapp.wunddoku.data.entity.converter.DocumentationStatusConverter
* Version 5 (vorher 4, finale fachliche Katalog-Inhalte): `check_item_states.isStaticHint` neu * Version 5 (vorher 4, finale fachliche Katalog-Inhalte): `check_item_states.isStaticHint` neu
* (nicht editierbarer Hinweistext, z. B. "+ bei Bedarf") - rein additive Spalte mit * (nicht editierbarer Hinweistext, z. B. "+ bei Bedarf") - rein additive Spalte mit
* Default-Wert ([MIGRATION_4_5]), keine bestehenden Daten verändert. * Default-Wert ([MIGRATION_4_5]), keine bestehenden Daten verändert.
*
* Version 6 (vorher 5, Änderungswunsch): `check_item_states.inputSpec` neu (strukturierte
* Eingabefelder wie Zahl/Auswahlliste statt Freitext, s. [de.medapp.wunddoku.domain.model.InputSpec])
* - ebenfalls rein additive Spalte mit Default-Wert ([MIGRATION_5_6]), keine bestehenden Daten
* verändert.
*/ */
@Database( @Database(
entities = [ entities = [
@@ -59,7 +64,7 @@ import de.medapp.wunddoku.data.entity.converter.DocumentationStatusConverter
SignatureEntity::class, SignatureEntity::class,
WoundCarerEntity::class, WoundCarerEntity::class,
], ],
version = 5, version = 6,
exportSchema = true, exportSchema = true,
) )
@TypeConverters(DocumentationStatusConverter::class) @TypeConverters(DocumentationStatusConverter::class)
@@ -87,7 +92,7 @@ abstract class AppDatabase : RoomDatabase() {
DATABASE_NAME, DATABASE_NAME,
) )
// Kein fallbackToDestructiveMigration() mehr ab Version 3 - s. Klassenkommentar. // Kein fallbackToDestructiveMigration() mehr ab Version 3 - s. Klassenkommentar.
.addMigrations(MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5) .addMigrations(MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6)
.build().also { instance = it } .build().also { instance = it }
} }
} }
@@ -131,3 +131,20 @@ val MIGRATION_4_5 = object : Migration(4, 5) {
) )
} }
} }
/**
* Migration von Schema-Version 5 auf 6 (Änderungswunsch: strukturierte Eingabefelder statt
* Freitext, z. B. "Länge/Breite/Tiefe" bei "Größe" oder "Von ... bis ... Uhr" bei "Fistel /
* Tasche" - s. [de.medapp.wunddoku.domain.model.InputSpec]).
*
* Wie schon bei Migration 4->5 rein additiv: eine neue NOT NULL-Spalte mit Default `''` (leerer
* String = "dieser Eintrag hat keine strukturierten Felder", entspricht dem bisherigen
* Verhalten aller bestehenden Zeilen unverändert). Kein Tabellenneubau nötig.
*/
val MIGRATION_5_6 = object : Migration(5, 6) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL(
"ALTER TABLE `check_item_states` ADD COLUMN `inputSpec` TEXT NOT NULL DEFAULT ''"
)
}
}
@@ -20,13 +20,22 @@ import androidx.room.PrimaryKey
* nachrichtlich an") - technisch identisch zur automatisch angehängten Option, nur mit eigener, * nachrichtlich an") - technisch identisch zur automatisch angehängten Option, nur mit eigener,
* fachlich benannter [labelSnapshot] statt des generischen "Freitext"-Labels. * fachlich benannter [labelSnapshot] statt des generischen "Freitext"-Labels.
* *
* [isStaticHint]: NEU. Markiert eine Zeile als reinen, nicht editierbaren Hinweistext (z. B. * [isStaticHint]: Markiert eine Zeile als reinen, nicht editierbaren Hinweistext (z. B.
* "+ bei Bedarf") - keine Checkbox in der UI, kein Umschalten möglich. Solche Zeilen werden * "+ bei Bedarf") - keine Checkbox in der UI, kein Umschalten möglich. Solche Zeilen werden
* beim Anlegen (s. DocumentationRepository.insertBlocksFromCatalog) mit `checked = true` * beim Anlegen (s. DocumentationRepository.insertBlocksFromCatalog) mit `checked = true`
* fest verankert, damit die bestehende, unverändert gebliebene "nur angehakte Elemente"-Regel * fest verankert, damit die bestehende, unverändert gebliebene "nur angehakte Elemente"-Regel
* im PDF-Export sie automatisch mit anzeigt, ohne dass diese Regel selbst angepasst werden * im PDF-Export sie automatisch mit anzeigt, ohne dass diese Regel selbst angepasst werden
* musste. [BlockEditedPolicy] schließt `isStaticHint`-Zeilen dagegen explizit von der * musste. [BlockEditedPolicy] schließt `isStaticHint`-Zeilen dagegen explizit von der
* "bearbeitet"-Prüfung aus, da ein reiner Hinweistext keine Nutzereingabe darstellt. * "bearbeitet"-Prüfung aus, da ein reiner Hinweistext keine Nutzereingabe darstellt.
*
* [inputSpec]: NEU. Nicht-leer, wenn dieser Eintrag statt eines einfachen Häkchens ein oder
* mehrere strukturierte Eingabefelder besitzt (Zahl oder Auswahlliste, z. B. "Von [3] bis [5]
* Uhr" oder "Länge/Breite/Tiefe"), s. [de.medapp.wunddoku.domain.model.InputSpec] und
* [de.medapp.wunddoku.domain.model.InputSpecCodec]. Die eingegebenen Werte selbst liegen -
* mehrere durch ein Trennzeichen kombiniert - weiterhin in [freeTextValue]; es wird also KEINE
* weitere Spalte pro Feld benötigt. [checked] wird bei einem solchen Eintrag automatisch von der
* UI gesetzt, sobald mindestens ein Feld einen Wert hat - dieselbe Checkbox-Semantik wie überall
* sonst, keine zweite "bearbeitet"-Logik.
*/ */
@Entity( @Entity(
tableName = "check_item_states", tableName = "check_item_states",
@@ -53,4 +62,5 @@ data class CheckItemStateEntity(
val isFreeText: Boolean = false, val isFreeText: Boolean = false,
val freeTextValue: String = "", val freeTextValue: String = "",
val isStaticHint: Boolean = false, val isStaticHint: Boolean = false,
val inputSpec: String = "",
) )
@@ -72,6 +72,7 @@ fun CheckItemStateEntity.toDomain(): CheckItem = CheckItem(
checked = checked, checked = checked,
isFreeText = isFreeText, isFreeText = isFreeText,
isStaticHint = isStaticHint, isStaticHint = isStaticHint,
inputSpec = inputSpec,
freeTextValue = freeTextValue, freeTextValue = freeTextValue,
) )
@@ -21,6 +21,7 @@ import de.medapp.wunddoku.domain.catalog.DocumentationCatalog
import de.medapp.wunddoku.domain.model.Documentation import de.medapp.wunddoku.domain.model.Documentation
import de.medapp.wunddoku.domain.model.DocumentationStatus import de.medapp.wunddoku.domain.model.DocumentationStatus
import de.medapp.wunddoku.domain.model.DocumentationSummary import de.medapp.wunddoku.domain.model.DocumentationSummary
import de.medapp.wunddoku.domain.model.InputSpecCodec
import de.medapp.wunddoku.domain.model.Photo import de.medapp.wunddoku.domain.model.Photo
import de.medapp.wunddoku.domain.model.Signature import de.medapp.wunddoku.domain.model.Signature
import de.medapp.wunddoku.media.ImageDownsampler import de.medapp.wunddoku.media.ImageDownsampler
@@ -29,10 +30,13 @@ import de.medapp.wunddoku.pdf.DocumentationPdfExporter
import de.medapp.wunddoku.pdf.DocumentationToPdfExportModelMapper import de.medapp.wunddoku.pdf.DocumentationToPdfExportModelMapper
import de.medapp.wunddoku.pdf.PdfExportError import de.medapp.wunddoku.pdf.PdfExportError
import de.medapp.wunddoku.pdf.PdfExportResult import de.medapp.wunddoku.pdf.PdfExportResult
import de.medapp.wunddoku.pdf.PdfToImagesConverter
import de.medapp.wunddoku.pdf.PdfToImagesResult
import de.medapp.wunddoku.signature.SignatureBitmapValidation import de.medapp.wunddoku.signature.SignatureBitmapValidation
import de.medapp.wunddoku.signature.SignatureStorage import de.medapp.wunddoku.signature.SignatureStorage
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import java.io.File
import java.time.Instant import java.time.Instant
import java.util.UUID import java.util.UUID
@@ -60,6 +64,7 @@ class DocumentationRepository(
private val signatureStorage: SignatureStorage, private val signatureStorage: SignatureStorage,
private val woundCarerRepository: WoundCarerRepository, private val woundCarerRepository: WoundCarerRepository,
private val pdfExporter: DocumentationPdfExporter, private val pdfExporter: DocumentationPdfExporter,
private val pdfToImagesConverter: PdfToImagesConverter,
private val db: AppDatabase, private val db: AppDatabase,
) { ) {
@@ -187,6 +192,7 @@ class DocumentationRepository(
isFreeText = item.isFreeText, isFreeText = item.isFreeText,
isStaticHint = item.isStaticHint, isStaticHint = item.isStaticHint,
freeTextValue = item.freeTextValue, freeTextValue = item.freeTextValue,
inputSpec = item.inputSpec,
) )
} }
) )
@@ -284,6 +290,16 @@ class DocumentationRepository(
return pdfExporter.export(model) return pdfExporter.export(model)
} }
/**
* Rendert eine bereits erzeugte PDF-Datei (s. [exportPdf]) seitenweise in JPEG-Dateien
* (Änderungswunsch: "PDF zusätzlich als JPEG speichern/senden können"). Nimmt bewusst die
* fertige [pdfFile] entgegen, statt die Dokumentation erneut zu laden/zu mappen - der
* Aufrufer hat sie direkt aus einem vorangegangenen [exportPdf]-Aufruf zur Verfügung, ein
* erneutes Laden wäre unnötige Doppelarbeit.
*/
suspend fun convertPdfToImages(documentationId: String, pdfFile: File): PdfToImagesResult =
pdfToImagesConverter.convert(documentationId, pdfFile)
/** /**
* Liefert eine speicherschonende Vorschauversion eines Fotos für die Anzeige im Fotogitter * Liefert eine speicherschonende Vorschauversion eines Fotos für die Anzeige im Fotogitter
* der Dokumentationsbearbeitung (kein Laden der vollen gespeicherten Auflösung). Wird von * der Dokumentationsbearbeitung (kein Laden der vollen gespeicherten Auflösung). Wird von
@@ -385,10 +401,11 @@ class DocumentationRepository(
* nachrichtlich an") - so bestätigt und akzeptiert, auch wenn das für einzelne Blöcke eine * nachrichtlich an") - so bestätigt und akzeptiert, auch wenn das für einzelne Blöcke eine
* zusätzliche, generisch benannte Freitext-Checkbox neben den fachlichen Einträgen bedeutet. * zusätzliche, generisch benannte Freitext-Checkbox neben den fachlichen Einträgen bedeutet.
* *
* Katalog-Einträge werden 1:1 aus [BlockDefinition.items] übernommen: `isFreeText` und * Katalog-Einträge werden 1:1 aus [BlockDefinition.items] übernommen: `isFreeText`,
* `isStaticHint` steuern direkt das Verhalten der jeweiligen Zeile (normale Checkbox, * `isStaticHint` und `inputSpec` steuern direkt das Verhalten der jeweiligen Zeile (normale
* eigene benannte Freitext-Checkbox, oder nicht editierbarer Hinweistext) - keine * Checkbox, eigene benannte Freitext-Checkbox, nicht editierbarer Hinweistext, oder
* block-spezifische Sonderlogik nötig, die Katalogdefinition allein entscheidet. * strukturierte Eingabefelder) - keine block-spezifische Sonderlogik nötig, die
* Katalogdefinition allein entscheidet.
* *
* Hinweistext-Einträge (`isStaticHint = true`) werden bereits HIER mit `checked = true` * Hinweistext-Einträge (`isStaticHint = true`) werden bereits HIER mit `checked = true`
* angelegt (statt änderbar per UI-Checkbox) - dadurch greift die bestehende, unveränderte * angelegt (statt änderbar per UI-Checkbox) - dadurch greift die bestehende, unveränderte
@@ -396,11 +413,14 @@ class DocumentationRepository(
* Regel selbst angepasst werden musste. [de.medapp.wunddoku.domain.usecase.BlockEditedPolicy] * Regel selbst angepasst werden musste. [de.medapp.wunddoku.domain.usecase.BlockEditedPolicy]
* schließt solche Zeilen dagegen explizit aus der "bearbeitet"-Prüfung aus. * schließt solche Zeilen dagegen explizit aus der "bearbeitet"-Prüfung aus.
* *
* Die generische Freitext-Option erscheint als ERSTES Element im Block (itemOrder = 0, * Änderungswunsch: Die generische Freitext-Option erscheint jetzt als LETZTES Element im
* niedriger als jeder Katalog-Eintrag, dessen `order` bei 1 beginnt). Betrifft ausschließlich * Block (itemOrder = höchste vorkommende Katalog-`order` + 1) - zuvor war sie das ERSTE
* NEU erstellte Dokumentationen: bereits bestehende BlockState-/CheckItemState-Zeilen * Element (itemOrder = 0). Vom Katalog selbst definierte, eigens benannte Freitext-Einträge
* behalten ihre historisch gespeicherte itemOrder unverändert (Snapshot-Prinzip, s. * (isFreeText = true) behalten dagegen ihre im Katalog festgelegte Position, sofern der
* Klassendokumentation zu titleSnapshot/labelSnapshot). * jeweilige Block das explizit so vorsieht. Betrifft ausschließlich NEU erstellte
* Dokumentationen: bereits bestehende BlockState-/CheckItemState-Zeilen behalten ihre
* historisch gespeicherte itemOrder unverändert (Snapshot-Prinzip, s. Klassendokumentation
* zu titleSnapshot/labelSnapshot).
*/ */
private suspend fun insertBlocksFromCatalog(documentationId: String, now: Long) { private suspend fun insertBlocksFromCatalog(documentationId: String, now: Long) {
for (blockDef in DocumentationCatalog.blocks) { for (blockDef in DocumentationCatalog.blocks) {
@@ -426,24 +446,29 @@ class DocumentationRepository(
labelSnapshot = itemDef.label, labelSnapshot = itemDef.label,
itemOrder = itemDef.order, itemOrder = itemDef.order,
// Hinweistexte starten bereits "angehakt" (s. Methodenkommentar) - alle // Hinweistexte starten bereits "angehakt" (s. Methodenkommentar) - alle
// anderen Einträge (Checkbox oder eigene Freitext-Checkbox) starten // anderen Einträge (Checkbox, eigene Freitext-Checkbox oder strukturierte
// unangehakt/leer, wie bisher. // Eingabe) starten unangehakt/leer, wie bisher.
checked = itemDef.isStaticHint, checked = itemDef.isStaticHint,
isFreeText = itemDef.isFreeText, isFreeText = itemDef.isFreeText,
isStaticHint = itemDef.isStaticHint, isStaticHint = itemDef.isStaticHint,
freeTextValue = "", freeTextValue = "",
inputSpec = itemDef.inputSpec?.let(InputSpecCodec::encode) ?: "",
) )
} }
// Änderungswunsch: generische Freitext-Option jetzt NACH allen Katalog-Einträgen
// dieses Blocks (s. Methodenkommentar) statt davor.
val genericFreeTextOrder = (blockDef.items.maxOfOrNull { it.order } ?: 0) + 1
val genericFreeTextItem = CheckItemStateEntity( val genericFreeTextItem = CheckItemStateEntity(
id = UUID.randomUUID().toString(), id = UUID.randomUUID().toString(),
blockStateId = blockStateId, blockStateId = blockStateId,
checkItemDefinitionId = blockDef.freeTextDefinitionId, checkItemDefinitionId = blockDef.freeTextDefinitionId,
labelSnapshot = DocumentationCatalog.FREE_TEXT_LABEL, labelSnapshot = DocumentationCatalog.FREE_TEXT_LABEL,
itemOrder = 0, itemOrder = genericFreeTextOrder,
checked = false, checked = false,
isFreeText = true, isFreeText = true,
isStaticHint = false, isStaticHint = false,
freeTextValue = "", freeTextValue = "",
inputSpec = "",
) )
checkItemStateDao.insertAll(catalogItems + genericFreeTextItem) checkItemStateDao.insertAll(catalogItems + genericFreeTextItem)
} }
@@ -8,7 +8,9 @@ import de.medapp.wunddoku.data.repository.WoundCarerRepository
import de.medapp.wunddoku.media.InternalPhotoStorage import de.medapp.wunddoku.media.InternalPhotoStorage
import de.medapp.wunddoku.media.PhotoStorage import de.medapp.wunddoku.media.PhotoStorage
import de.medapp.wunddoku.pdf.AndroidPdfDocumentExporter import de.medapp.wunddoku.pdf.AndroidPdfDocumentExporter
import de.medapp.wunddoku.pdf.AndroidPdfToImagesConverter
import de.medapp.wunddoku.pdf.DocumentationPdfExporter import de.medapp.wunddoku.pdf.DocumentationPdfExporter
import de.medapp.wunddoku.pdf.PdfToImagesConverter
import de.medapp.wunddoku.signature.InternalSignatureStorage import de.medapp.wunddoku.signature.InternalSignatureStorage
import de.medapp.wunddoku.signature.SignatureStorage import de.medapp.wunddoku.signature.SignatureStorage
@@ -38,6 +40,7 @@ class AppContainer(context: Context) {
private val pdfExporter: DocumentationPdfExporter by lazy { private val pdfExporter: DocumentationPdfExporter by lazy {
AndroidPdfDocumentExporter(context, photoStorage, signatureStorage) AndroidPdfDocumentExporter(context, photoStorage, signatureStorage)
} }
private val pdfToImagesConverter: PdfToImagesConverter by lazy { AndroidPdfToImagesConverter(context) }
val patientRepository: PatientRepository by lazy { val patientRepository: PatientRepository by lazy {
PatientRepository(patientDao = database.patientDao()) PatientRepository(patientDao = database.patientDao())
@@ -62,6 +65,7 @@ class AppContainer(context: Context) {
signatureStorage = signatureStorage, signatureStorage = signatureStorage,
woundCarerRepository = woundCarerRepository, woundCarerRepository = woundCarerRepository,
pdfExporter = pdfExporter, pdfExporter = pdfExporter,
pdfToImagesConverter = pdfToImagesConverter,
db = database, db = database,
) )
} }
@@ -1,64 +1,103 @@
package de.medapp.wunddoku.domain.catalog package de.medapp.wunddoku.domain.catalog
import de.medapp.wunddoku.domain.model.InputFieldDef
import de.medapp.wunddoku.domain.model.InputSpec
/** /**
* Zentrale, statische Definition aller Dokumentationsblöcke und ihrer Checkboxen. * Zentrale, statische Definition aller Dokumentationsblöcke und ihrer Checkboxen.
* *
* Enthält die mit dem Kunden final abgestimmten Dokumentationsabschnitte. Basis ist die * Enthält die mit dem Kunden final abgestimmten Dokumentationsabschnitte. Basis ist die
* handschriftlich vorgegebene 22-Punkte-Reihenfolge, ergänzt/verfeinert nach Abgleich mit dem * handschriftlich vorgegebene 22-Punkte-Reihenfolge, zunächst ergänzt/verfeinert nach Abgleich
* ursprünglichen Papierformular ("Wunddokumentation" von Harald Klock) - dabei wurden: * mit dem ursprünglichen Papierformular, und in dieser Fassung ein weiteres Mal überarbeitet
* nach konkreten Änderungswünschen (u. a. neue Checkbox-Listen für Wundart/Vitalwerte/
* Auswirkungen aus Fotos, strukturierte Eingabefelder statt Freitext für Maße/Uhrzeiten/Tiefen,
* ein neuer Abschnitt "Wundheilung beeinflussende Faktoren").
* *
* - mehrere Abschnitte, die im Chat/der handschriftlichen Liste nur als Freitext geführt waren, * WICHTIGE GENERELLE REGEL (Änderungswunsch): Die automatisch an jeden Block angehängte
* auf dem Papierbogen aber eine differenzierte Checkbox-Struktur zeigen, entsprechend * generische Freitext-Option (s. [FREE_TEXT_LABEL]) erscheint jetzt als LETZTES Element eines
* erweitert (Wundgrund, Belag, Wundrand, Exsudat, Größe, Fistel/Tasche, Lokalisation, * Blocks (vorher: als erstes) - siehe [de.medapp.wunddoku.data.repository.DocumentationRepository.insertBlocksFromCatalog].
* Empfohlene Wundversorgung), * Vom Katalog selbst eigens benannte Freitext-Einträge (`isFreeText = true` an einem konkreten
* - zwei auf dem Papierbogen vorhandene, aber in der handschriftlichen Liste fehlende * [CheckItemDefinition]) behalten dagegen ihre hier festgelegte Position.
* Abschnitte wieder aufgenommen ("Weitere Überlegung" - vermutlich versehentlich in der
* handschriftlichen Liste ausgelassen; "Nächster Termin" - auf dem Bogen als eigenes Feld
* vorhanden),
* - "Verlauf" bewusst NICHT um die alten Papier-Checkboxen erweitert (explizit vom Kunden
* bestätigt: bleibt reiner Freitext, die alte Checkbox-Auswahl wurde bewusst verworfen),
* - "Wundumgebung" und "Auswirkungen auf das alltägliche Leben" um die Papier-Checkbox-Listen
* ergänzt, jeweils MIT vorangestelltem freiem Textfeld (die ohnehin an jedem Block
* automatisch vorhandene generische Freitext-Option deckt das ab, s. [FREE_TEXT_LABEL]),
* - "Wundart" um die Papier-Checkbox "nicht bekannt/diagnostiziert" ergänzt (zusätzlich zum
* Freitext - NICHT Teil der früher verworfenen Wundarten-Liste, sondern ein eigenständiges
* Status-Flag).
* *
* Frühere, im Kundenchat zwischenzeitlich diskutierte, aber später ausdrücklich verworfene * Frühere, im Kundenchat zwischenzeitlich diskutierte, aber später ausdrücklich verworfene
* Inhalte (Wundart-Typenliste, Verlauf-Checkboxen, "Schmerzempfinden 1-10") sind weiterhin * Inhalte (Wortlaut-Variante der alten Wundart-Checkboxen, "Schmerzempfinden 1-10") sind
* bewusst NICHT enthalten. * weiterhin bewusst NICHT enthalten.
* *
* Diese Definitionen sind bewusst reiner Kotlin-Code und keine Room-Tabellen: Sie ändern sich * Diese Definitionen sind bewusst reiner Kotlin-Code und keine Room-Tabellen: Sie ändern sich
* nur durch neue App-Versionen, nicht durch Nutzerinteraktion. Siehe Architektur-Dokument * nur durch neue App-Versionen, nicht durch Nutzerinteraktion. Siehe Architektur-Dokument
* Abschnitt 2.3b. * Abschnitt 2.3b.
* *
* [CATALOG_VERSION] wird beim Erstellen einer neuen Dokumentation als rein informatives * [CATALOG_VERSION] wird beim Erstellen einer neuen Dokumentation als rein informatives
* Metadatum in Documentation.catalogVersion übernommen. Erhöhe diesen Wert, wenn sich Blöcke * Metadatum in Documentation.catalogVersion übernommen. Auf 4 erhöht mit diesem Umbau.
* oder Checkboxen inhaltlich ändern (nicht bei rein redaktionellen Korrekturen wie
* Tippfehlern) – rein zu Nachvollziehbarkeitszwecken, nicht sicherheitsrelevant für die
* historische Korrektheit (das übernehmen die Label-/Titel-Snapshots). Auf 3 erhöht mit dem
* Abgleich gegen das ursprüngliche Papierformular.
*/ */
object DocumentationCatalog { object DocumentationCatalog {
const val CATALOG_VERSION: Int = 3 const val CATALOG_VERSION: Int = 4
/** /**
* Label der automatisch an jeden Block angehängten Freitext-Option (Änderungswunsch: * Label der automatisch an jeden Block angehängten Freitext-Option. Zentral hier definiert,
* "Freitext soll selbst wie eine weitere auswählbare Option behandelt werden"). Zentral * damit Repository (Erstellung/Fortsetzen) und PDF-Export dieselbe Bezeichnung verwenden.
* hier definiert, damit Repository (Erstellung/Fortsetzen) und PDF-Export dieselbe * Gilt weiterhin unverändert für ALLE Blöcke, auch für solche, die fachlich "nur
* Bezeichnung verwenden. Gilt weiterhin unverändert für ALLE Blöcke, auch für solche, die * Checkboxen" wären - ausdrücklich bestätigt und kein Fehler.
* fachlich "nur Checkboxen" wären - ausdrücklich bestätigt und kein Fehler.
*/ */
const val FREE_TEXT_LABEL: String = "Freitext" const val FREE_TEXT_LABEL: String = "Freitext"
// Wiederverwendete Auswahllisten für strukturierte Eingabefelder (s. InputFieldDef.Choice).
private val HOUR_OPTIONS: List<String> = (1..12).map { it.toString() }
private val WEEKDAY_COUNT_OPTIONS: List<String> = (1..7).map { it.toString() }
private val MINUTE_1_TO_15_OPTIONS: List<String> = (1..15).map { it.toString() }
private val DEPTH_HALF_CM_OPTIONS: List<String> = listOf(
"0,5 cm", "1 cm", "1,5 cm", "2 cm", "2,5 cm", "3 cm", "3,5 cm", "4 cm",
"4,5 cm", "5 cm", "5,5 cm", "6 cm", "6,5 cm", "7 cm", "7,5 cm", "8 cm", ">8 cm",
)
private val DECUBITUS_GRADE_OPTIONS: List<String> = listOf("I", "II", "III", "IV")
val blocks: List<BlockDefinition> = listOf( val blocks: List<BlockDefinition> = listOf(
BlockDefinition( BlockDefinition(
id = "wundart", id = "wundart",
title = "Wundart", title = "Wundart",
order = 1, order = 1,
items = listOf( items = listOf(
CheckItemDefinition(id = "wundart_nicht_bekannt_diagnostiziert", label = "nicht bekannt/diagnostiziert", order = 1), CheckItemDefinition(id = "wundart_nicht_bekannt_diagnostiziert", label = "Noch nicht diagnostiziert / nicht bekannt", order = 1),
CheckItemDefinition(id = "wundart_erscheint_wie", label = "Erscheint wie", order = 2, isFreeText = true),
CheckItemDefinition(
id = "wundart_decubitus",
label = "Decubitus",
order = 3,
inputSpec = InputSpec(
pdfTemplate = "Decubitus Grad {0}",
fields = listOf(InputFieldDef.Choice(label = "Grad", options = DECUBITUS_GRADE_OPTIONS)),
),
),
CheckItemDefinition(id = "wundart_ulcus_cruris_unbezeichnet", label = "Ulcus Cruris (nicht näher bezeichnet)", order = 4),
CheckItemDefinition(id = "wundart_ulcus_cruris_venosum", label = "Ulcus Cruris ven.", order = 5),
CheckItemDefinition(id = "wundart_ulcus_cruris_arteriosum", label = "Ulcus Cruris art.", order = 6),
CheckItemDefinition(id = "wundart_ulcus_cruris_venosum_arteriosum", label = "Ulcus Cruris ven.-art.", order = 7),
CheckItemDefinition(id = "wundart_diabetisches_fusssyndrom", label = "Diab. Fuß Syndrom", order = 8),
CheckItemDefinition(id = "wundart_post_operative_wundheilungsstoerung", label = "Post-operative Wundheilungsstörung / Dehiszenz", order = 9),
CheckItemDefinition(id = "wundart_schuerfwunde", label = "Schürfwunde", order = 10),
CheckItemDefinition(id = "wundart_intertrigo", label = "Intertrigo / Wunde nach Intertrigo", order = 11),
CheckItemDefinition(id = "wundart_iad", label = "IAD (Inkontinenz-assoziierte Dermatitis) / Wunde nach IAD", order = 12),
CheckItemDefinition(id = "wundart_amputationswunde", label = "Amputationswunde", order = 13),
CheckItemDefinition(id = "wundart_spalthautentnahmestelle", label = "Spalthautentnahmestelle", order = 14),
CheckItemDefinition(
id = "wundart_transplantatverlust",
label = "Wunde nach Transplantatverlust",
order = 15,
inputSpec = InputSpec(
pdfTemplate = "Wunde nach Transplantatverlust, Spalthautdeckung ca. {0}%",
fields = listOf(InputFieldDef.Number(label = "Spalthautdeckung ca. (%)", maxLength = 3)),
),
),
CheckItemDefinition(id = "wundart_sekundaer_heilende_wunde", label = "Sekundär heilende Wunde", order = 16),
CheckItemDefinition(id = "wundart_tumorwunde", label = "Tumorwunde", order = 17),
CheckItemDefinition(id = "wundart_wunde_nach_abszess", label = "Wunde nach Abszess", order = 18),
CheckItemDefinition(id = "wundart_verbrennung_verbruehung", label = "Verbrennung / Verbrühung", order = 19),
CheckItemDefinition(id = "wundart_platzwunde", label = "Platzwunde", order = 20),
CheckItemDefinition(id = "wundart_schnittwunde", label = "Schnittwunde", order = 21),
CheckItemDefinition(id = "wundart_bisswunde", label = "Bisswunde", order = 22),
CheckItemDefinition(id = "wundart_chemisch_veraetzung", label = "Chemisch hervorgerufene Wunde / Verätzung", order = 23),
CheckItemDefinition(id = "wundart_strahlenschaden", label = "Strahlenschaden", order = 24),
), ),
), ),
BlockDefinition( BlockDefinition(
@@ -74,18 +113,37 @@ object DocumentationCatalog {
title = "Größe", title = "Größe",
order = 3, order = 3,
items = listOf( items = listOf(
CheckItemDefinition(id = "groesse_multiple_wunden_max", label = "multiple Wunden mit max. (cm)", order = 1, isFreeText = true), CheckItemDefinition(id = "groesse_hinweis_masse", label = "Maße", order = 1, isStaticHint = true),
CheckItemDefinition(id = "groesse_wunde", label = "Wunde", order = 2), CheckItemDefinition(
CheckItemDefinition(id = "groesse_areal", label = "Areal", order = 3), id = "groesse_laenge_breite_tiefe",
CheckItemDefinition(id = "groesse_masse", label = "Maße (Länge x Breite, ca. cm)", order = 4, isFreeText = true), label = "Maße (Länge / Breite / Tiefe)",
CheckItemDefinition(id = "groesse_vertikale_tiefe", label = "Vertikale Tiefe (ca. cm)", order = 5, isFreeText = true), order = 2,
inputSpec = InputSpec(
pdfTemplate = "Länge {0} cm, Breite {1} cm, Tiefe {2} cm",
fields = listOf(
InputFieldDef.Number(label = "Länge (cm)", maxLength = 5, allowDecimal = true),
InputFieldDef.Number(label = "Breite (cm)", maxLength = 5, allowDecimal = true),
InputFieldDef.Number(label = "Tiefe (cm)", maxLength = 5, allowDecimal = true),
),
),
),
CheckItemDefinition(id = "groesse_freitext_masse", label = "Freitext für die Maße", order = 3, isFreeText = true),
CheckItemDefinition(id = "groesse_multiple_wunden_max", label = "Multiple Wunden mit max. (cm)", order = 4, isFreeText = true),
CheckItemDefinition(id = "groesse_hinweis_areal", label = "Zu versorgendes Areal", order = 5, isStaticHint = true),
CheckItemDefinition(id = "groesse_areal", label = "Zu versorgendes Areal", order = 6),
CheckItemDefinition(id = "groesse_freitext_areal", label = "Freitext für zu versorgendes Areal", order = 7, isFreeText = true),
), ),
), ),
BlockDefinition( BlockDefinition(
id = "verlauf", id = "verlauf",
title = "Verlauf", title = "Verlauf",
order = 4, order = 4,
items = emptyList(), items = listOf(
CheckItemDefinition(id = "verlauf_positiv", label = "Positiv", order = 1),
CheckItemDefinition(id = "verlauf_negativ", label = "Negativ", order = 2),
CheckItemDefinition(id = "verlauf_stagnierend", label = "Stagnierend", order = 3),
CheckItemDefinition(id = "verlauf_nicht_bekannt", label = "Nicht bekannt", order = 4),
),
), ),
BlockDefinition( BlockDefinition(
id = "exsudat", id = "exsudat",
@@ -114,21 +172,26 @@ object DocumentationCatalog {
title = "Wundgrund + Erscheinung", title = "Wundgrund + Erscheinung",
order = 6, order = 6,
items = listOf( items = listOf(
CheckItemDefinition(id = "wundgrund_eben", label = "Eben", order = 1), CheckItemDefinition(id = "wundgrund_wie_ausgestanzt", label = "Wie ausgestanzt", order = 1),
CheckItemDefinition(id = "wundgrund_unregelmaessig", label = "Unregelmäßig", order = 2), CheckItemDefinition(id = "wundgrund_kraterfoermig", label = "Kraterförmig", order = 2),
CheckItemDefinition(id = "wundgrund_knospig", label = "Knospig", order = 3), CheckItemDefinition(id = "wundgrund_unregelmaessig_landkartenartig", label = "Unregelmäßig (landkartenartig/amorph)", order = 3),
CheckItemDefinition(id = "wundgrund_auf_niveau", label = "Auf Niveau", order = 4), CheckItemDefinition(id = "wundgrund_linear_spaltfoermig", label = "Linear/spaltförmig", order = 4),
CheckItemDefinition(id = "wundgrund_ueber_niveau", label = "Über Niveau", order = 5), CheckItemDefinition(id = "wundgrund_rund", label = "Rund", order = 5),
CheckItemDefinition(id = "wundgrund_glaenzend", label = "Glänzend", order = 6), CheckItemDefinition(id = "wundgrund_oval_elliptisch", label = "Oval/elliptisch", order = 6),
CheckItemDefinition(id = "wundgrund_grob", label = "Grob", order = 7), CheckItemDefinition(id = "wundgrund_quadrangulaer", label = "Quadrangulär", order = 7),
CheckItemDefinition(id = "wundgrund_fein", label = "Fein", order = 8), CheckItemDefinition(id = "wundgrund_eben", label = "Eben", order = 8),
CheckItemDefinition(id = "wundgrund_glatt", label = "Glatt", order = 9), CheckItemDefinition(id = "wundgrund_auf_niveau", label = "Auf Niveau", order = 9),
CheckItemDefinition(id = "wundgrund_rot", label = "Rot", order = 10), CheckItemDefinition(id = "wundgrund_ueber_niveau", label = "Über Niveau", order = 10),
CheckItemDefinition(id = "wundgrund_blass", label = "Blass", order = 11), CheckItemDefinition(id = "wundgrund_glaenzend", label = "Glänzend", order = 11),
CheckItemDefinition(id = "wundgrund_knochen_sehnen", label = "Knochen/Sehnen", order = 12), CheckItemDefinition(id = "wundgrund_grob", label = "Grob", order = 12),
CheckItemDefinition(id = "wundgrund_haemorrhagie", label = "Hämorrhagie", order = 13), CheckItemDefinition(id = "wundgrund_fein", label = "Fein", order = 13),
CheckItemDefinition(id = "wundgrund_nicht_kaum_einsehbar", label = "Nicht/kaum einsehbar", order = 14), CheckItemDefinition(id = "wundgrund_glatt", label = "Glatt", order = 14),
CheckItemDefinition(id = "wundgrund_erscheinung_erscheinung", label = "Erscheinung", order = 15, isFreeText = true), CheckItemDefinition(id = "wundgrund_rot", label = "Rot", order = 15),
CheckItemDefinition(id = "wundgrund_blass", label = "Blass", order = 16),
CheckItemDefinition(id = "wundgrund_knochen_sehnen", label = "Knochen/Sehnen", order = 17),
CheckItemDefinition(id = "wundgrund_haemorrhagie", label = "Hämorrhagie", order = 18),
CheckItemDefinition(id = "wundgrund_nicht_kaum_einsehbar", label = "Nicht/kaum einsehbar", order = 19),
CheckItemDefinition(id = "wundgrund_erscheinung_erscheinung", label = "Erscheinung", order = 20, isFreeText = true),
), ),
), ),
BlockDefinition( BlockDefinition(
@@ -142,13 +205,14 @@ object DocumentationCatalog {
CheckItemDefinition(id = "belag_fremdkoerper", label = "Fremdkörper", order = 4), CheckItemDefinition(id = "belag_fremdkoerper", label = "Fremdkörper", order = 4),
CheckItemDefinition(id = "belag_nekrose", label = "Nekrose", order = 5), CheckItemDefinition(id = "belag_nekrose", label = "Nekrose", order = 5),
CheckItemDefinition(id = "belag_kruste", label = "Kruste", order = 6), CheckItemDefinition(id = "belag_kruste", label = "Kruste", order = 6),
CheckItemDefinition(id = "belag_mit_fibrinsaum", label = "Mit Fibrinsaum", order = 7), CheckItemDefinition(id = "belag_biofilm", label = "Biofilm", order = 7),
CheckItemDefinition(id = "belag_trocken", label = "Trocken", order = 8), CheckItemDefinition(id = "belag_mit_fibrinsaum", label = "Mit Fibrinsaum", order = 8),
CheckItemDefinition(id = "belag_feucht", label = "Feucht", order = 9), CheckItemDefinition(id = "belag_trocken", label = "Trocken", order = 9),
CheckItemDefinition(id = "belag_100_prozent", label = "100%", order = 10), CheckItemDefinition(id = "belag_feucht", label = "Feucht", order = 10),
CheckItemDefinition(id = "belag_75_prozent", label = "75%", order = 11), CheckItemDefinition(id = "belag_100_prozent", label = "100%", order = 11),
CheckItemDefinition(id = "belag_50_prozent", label = "50%", order = 12), CheckItemDefinition(id = "belag_75_prozent", label = "75%", order = 12),
CheckItemDefinition(id = "belag_25_prozent_plus", label = "25%+<", order = 13), CheckItemDefinition(id = "belag_50_prozent", label = "50%", order = 13),
CheckItemDefinition(id = "belag_25_prozent_plus", label = "25%+<", order = 14),
), ),
), ),
BlockDefinition( BlockDefinition(
@@ -190,10 +254,11 @@ object DocumentationCatalog {
CheckItemDefinition(id = "wundumgebung_blaeulich", label = "Bläulich", order = 8), CheckItemDefinition(id = "wundumgebung_blaeulich", label = "Bläulich", order = 8),
CheckItemDefinition(id = "wundumgebung_blass", label = "Blass", order = 9), CheckItemDefinition(id = "wundumgebung_blass", label = "Blass", order = 9),
CheckItemDefinition(id = "wundumgebung_hyperkeratose", label = "Hyperkeratose", order = 10), CheckItemDefinition(id = "wundumgebung_hyperkeratose", label = "Hyperkeratose", order = 10),
CheckItemDefinition(id = "wundumgebung_pergamenthaut", label = "Pergamenthaut", order = 11), CheckItemDefinition(id = "wundumgebung_sehr_trockene_haut", label = "Sehr trockene Haut", order = 11),
CheckItemDefinition(id = "wundumgebung_rissig_kratzspuren", label = "Rissig/Kratzspuren", order = 12), CheckItemDefinition(id = "wundumgebung_pergamenthaut", label = "Pergamenthaut", order = 12),
CheckItemDefinition(id = "wundumgebung_spannungsblasen", label = "Spannungsblasen", order = 13), CheckItemDefinition(id = "wundumgebung_rissig_kratzspuren", label = "Rissig/Kratzspuren", order = 13),
CheckItemDefinition(id = "wundumgebung_haematome", label = "Hämatome", order = 14), CheckItemDefinition(id = "wundumgebung_spannungsblasen", label = "Spannungsblasen", order = 14),
CheckItemDefinition(id = "wundumgebung_haematome", label = "Hämatome", order = 15),
), ),
), ),
BlockDefinition( BlockDefinition(
@@ -204,8 +269,27 @@ object DocumentationCatalog {
CheckItemDefinition(id = "fistel_tasche_nein", label = "Nein", order = 1), CheckItemDefinition(id = "fistel_tasche_nein", label = "Nein", order = 1),
CheckItemDefinition(id = "fistel_tasche_ja", label = "Ja", order = 2), CheckItemDefinition(id = "fistel_tasche_ja", label = "Ja", order = 2),
CheckItemDefinition(id = "fistel_tasche_zirkulaer", label = "Zirkulär", order = 3), CheckItemDefinition(id = "fistel_tasche_zirkulaer", label = "Zirkulär", order = 3),
CheckItemDefinition(id = "fistel_tasche_von_bis_uhr", label = "Von ... bis ... Uhr", order = 4, isFreeText = true), CheckItemDefinition(
CheckItemDefinition(id = "fistel_tasche_tiefe", label = "Tiefe (ca. cm)", order = 5, isFreeText = true), id = "fistel_tasche_von_bis_uhr",
label = "Von ... bis ... Uhr",
order = 4,
inputSpec = InputSpec(
pdfTemplate = "Von {0} bis {1} Uhr",
fields = listOf(
InputFieldDef.Choice(label = "Von", options = HOUR_OPTIONS),
InputFieldDef.Choice(label = "Bis", options = HOUR_OPTIONS),
),
),
),
CheckItemDefinition(
id = "fistel_tasche_tiefe",
label = "Tiefe",
order = 5,
inputSpec = InputSpec(
pdfTemplate = "Tiefe: {0}",
fields = listOf(InputFieldDef.Choice(label = "Tiefe", options = DEPTH_HALF_CM_OPTIONS)),
),
),
), ),
), ),
BlockDefinition( BlockDefinition(
@@ -264,28 +348,118 @@ object DocumentationCatalog {
id = "vitalwerte", id = "vitalwerte",
title = "Vitalwerte", title = "Vitalwerte",
order = 15, order = 15,
items = emptyList(), items = listOf(
CheckItemDefinition(id = "vitalwerte_arteria_tibialis_posterior_links", label = "Arteria tibialis posterior links", order = 1),
CheckItemDefinition(id = "vitalwerte_arteria_dorsalis_pedis_links", label = "Arteria dorsalis pedis links", order = 2),
CheckItemDefinition(id = "vitalwerte_arteria_tibialis_posterior_rechts", label = "Arteria tibialis posterior rechts", order = 3),
CheckItemDefinition(id = "vitalwerte_arteria_dorsalis_pedis_rechts", label = "Arteria dorsalis pedis rechts", order = 4),
CheckItemDefinition(id = "vitalwerte_per_doppler_taschendoppler", label = "per Doppler (Taschendoppler)", order = 5),
CheckItemDefinition(id = "vitalwerte_per_palpation", label = "per Palpation", order = 6),
CheckItemDefinition(id = "vitalwerte_darstellbar", label = "Darstellbar", order = 7),
CheckItemDefinition(id = "vitalwerte_abgeschwaecht_veraendert", label = "Abgeschwächt/verändert", order = 8),
CheckItemDefinition(id = "vitalwerte_nicht_darstellbar", label = "Nicht darstellbar", order = 9),
CheckItemDefinition(
id = "vitalwerte_rr",
label = "RR (systolisch/diastolisch)",
order = 10,
inputSpec = InputSpec(
pdfTemplate = "RR {0}/{1} mmHg",
fields = listOf(
InputFieldDef.Number(label = "RR systolisch", maxLength = 3),
InputFieldDef.Number(label = "RR diastolisch", maxLength = 3),
),
),
),
CheckItemDefinition(
id = "vitalwerte_puls",
label = "Puls (BPM)",
order = 11,
inputSpec = InputSpec(
pdfTemplate = "Puls {0} BPM",
fields = listOf(InputFieldDef.Number(label = "Puls (BPM)", maxLength = 3)),
),
),
CheckItemDefinition(
id = "vitalwerte_blutzucker",
label = "Blutzucker (mg/dl)",
order = 12,
inputSpec = InputSpec(
pdfTemplate = "Blutzucker {0} mg/dl",
fields = listOf(InputFieldDef.Number(label = "Blutzucker (mg/dl)", maxLength = 3)),
),
),
CheckItemDefinition(
id = "vitalwerte_spo2",
label = "SpO2 (%)",
order = 13,
inputSpec = InputSpec(
pdfTemplate = "SpO2 {0}%",
fields = listOf(InputFieldDef.Number(label = "SpO2 (%)", maxLength = 3)),
),
),
CheckItemDefinition(
id = "vitalwerte_temperatur",
label = "Temperatur (°C)",
order = 14,
inputSpec = InputSpec(
pdfTemplate = "Temp. {0} °C",
fields = listOf(InputFieldDef.Number(label = "Temperatur (°C)", maxLength = 4, allowDecimal = true)),
),
),
),
),
BlockDefinition(
id = "wundheilung_beeinflussende_faktoren",
title = "Wundheilung beeinflussende Faktoren",
order = 16,
items = listOf(
CheckItemDefinition(id = "wbf_infektion_keimbesiedelung", label = "Infektion/Keimbesiedelung", order = 1),
CheckItemDefinition(id = "wbf_alter", label = "Alter", order = 2),
CheckItemDefinition(id = "wbf_diabetes_mellitus", label = "Diabetes mellitus", order = 3),
CheckItemDefinition(id = "wbf_geschwaechtes_immunsystem", label = "Geschwächtes Immunsystem", order = 4),
CheckItemDefinition(id = "wbf_ernaehrungszustand_dehydration", label = "Ernährungszustand/Dehydration", order = 5),
CheckItemDefinition(id = "wbf_medikamente", label = "Medikamente", order = 6),
CheckItemDefinition(id = "wbf_rauchen", label = "Rauchen", order = 7),
CheckItemDefinition(id = "wbf_adipositas", label = "Adipositas", order = 8),
CheckItemDefinition(id = "wbf_psych_stress_schlafmangel", label = "Psych. Stress/Schlafmangel", order = 9),
CheckItemDefinition(id = "wbf_mobilitaet", label = "Mobilität", order = 10),
CheckItemDefinition(id = "wbf_lage_der_wunde", label = "Lage der Wunde", order = 11),
),
), ),
BlockDefinition( BlockDefinition(
id = "auswirkungen_alltag", id = "auswirkungen_alltag",
title = "Auswirkungen auf das alltägliche Leben", title = "Auswirkungen auf das alltägliche Leben",
order = 16, order = 17,
items = listOf( items = listOf(
CheckItemDefinition(id = "auswirkungen_alltag_ja", label = "Ja", order = 1), CheckItemDefinition(id = "auswirkungen_alltag_nein", label = "Nein", order = 1),
CheckItemDefinition(id = "auswirkungen_alltag_nein", label = "Nein", order = 2), CheckItemDefinition(id = "auswirkungen_alltag_schmerz", label = "Schmerz", order = 2),
CheckItemDefinition(id = "auswirkungen_alltag_nicht_ermittelbar", label = "Nicht ermittelbar", order = 3), CheckItemDefinition(id = "auswirkungen_alltag_eingeschraenkte_mobilitaet", label = "Eingeschränkte Mobilität", order = 3),
CheckItemDefinition(id = "auswirkungen_alltag_schlafstoerung", label = "Schlafstörung", order = 4),
CheckItemDefinition(id = "auswirkungen_alltag_koerperbildstoerung", label = "Körperbildstörung", order = 5),
CheckItemDefinition(id = "auswirkungen_alltag_geruch", label = "Geruch", order = 6),
CheckItemDefinition(id = "auswirkungen_alltag_sozialer_rueckzug", label = "Sozialer Rückzug", order = 7),
CheckItemDefinition(id = "auswirkungen_alltag_zeitaufwand", label = "Zeitaufwand", order = 8),
CheckItemDefinition(id = "auswirkungen_alltag_einschraenkung_kleidung_hygiene", label = "Einschränkung Kleidung/Hygiene", order = 9),
CheckItemDefinition(id = "auswirkungen_alltag_abhaengigkeit_von_dritten", label = "Abhängigkeit von Dritten (Pflegedienst/Ärzte/Angehörige)", order = 10),
CheckItemDefinition(id = "auswirkungen_alltag_angst", label = "Angst", order = 11),
CheckItemDefinition(id = "auswirkungen_alltag_hobbyverlust", label = "Hobbyverlust", order = 12),
CheckItemDefinition(id = "auswirkungen_alltag_berufliche_ausfaelle", label = "Berufliche Ausfälle", order = 13),
CheckItemDefinition(id = "auswirkungen_alltag_finanzielle_zusatzbelastung", label = "Finanzielle Zusatzbelastung", order = 14),
), ),
), ),
BlockDefinition( BlockDefinition(
id = "bekannte_allergie", id = "bekannte_allergie",
title = "Bekannte Allergie", title = "Bekannte Allergie",
order = 17, order = 18,
items = emptyList(), items = listOf(
CheckItemDefinition(id = "bekannte_allergie_ja", label = "Ja", order = 1),
CheckItemDefinition(id = "bekannte_allergie_nein", label = "Nein", order = 2),
),
), ),
BlockDefinition( BlockDefinition(
id = "beratung_erfolgte", id = "beratung_erfolgte",
title = "Beratung erfolgte", title = "Beratung erfolgte",
order = 18, order = 19,
items = listOf( items = listOf(
CheckItemDefinition(id = "beratung_verbandwechsel", label = "Verbandwechsel", order = 1), CheckItemDefinition(id = "beratung_verbandwechsel", label = "Verbandwechsel", order = 1),
CheckItemDefinition(id = "beratung_hygiene", label = "Hygiene", order = 2), CheckItemDefinition(id = "beratung_hygiene", label = "Hygiene", order = 2),
@@ -298,7 +472,7 @@ object DocumentationCatalog {
BlockDefinition( BlockDefinition(
id = "weitere_ueberlegung", id = "weitere_ueberlegung",
title = "Weitere Überlegung", title = "Weitere Überlegung",
order = 19, order = 20,
items = listOf( items = listOf(
CheckItemDefinition(id = "weitere_ueberlegung_hausarztvisite", label = "Hausarztvisite", order = 1), CheckItemDefinition(id = "weitere_ueberlegung_hausarztvisite", label = "Hausarztvisite", order = 1),
CheckItemDefinition(id = "weitere_ueberlegung_kompressionstherapie", label = "Kompressionstherapie", order = 2), CheckItemDefinition(id = "weitere_ueberlegung_kompressionstherapie", label = "Kompressionstherapie", order = 2),
@@ -321,20 +495,28 @@ object DocumentationCatalog {
BlockDefinition( BlockDefinition(
id = "sonstiges", id = "sonstiges",
title = "Sonstiges", title = "Sonstiges",
order = 20, order = 21,
items = emptyList(), items = emptyList(),
), ),
BlockDefinition( BlockDefinition(
id = "empfohlene_wundversorgung", id = "empfohlene_wundversorgung",
title = "Empfohlene Wundversorgung", title = "Empfohlene Wundversorgung",
order = 21, order = 22,
items = listOf( items = listOf(
CheckItemDefinition(id = "empfohlene_wundversorgung_hinweis_reinigen_mit", label = "Reinigen mit:", order = 1, isStaticHint = true), CheckItemDefinition(id = "empfohlene_wundversorgung_hinweis_reinigen_mit", label = "Reinigen mit:", order = 1, isStaticHint = true),
CheckItemDefinition(id = "empfohlene_wundversorgung_sterile_kompresse", label = "Sterile Kompresse", order = 2), CheckItemDefinition(id = "empfohlene_wundversorgung_sterile_kompresse", label = "Sterile Kompresse", order = 2),
CheckItemDefinition(id = "empfohlene_wundversorgung_nacl", label = "NaCl 0,9%", order = 3), CheckItemDefinition(id = "empfohlene_wundversorgung_nacl", label = "NaCl 0,9%", order = 3),
CheckItemDefinition(id = "empfohlene_wundversorgung_octenisept", label = "Octenisept", order = 4), CheckItemDefinition(id = "empfohlene_wundversorgung_octenisept", label = "Octenisept", order = 4),
CheckItemDefinition(id = "empfohlene_wundversorgung_granudacyn", label = "Granudacyn", order = 5), CheckItemDefinition(id = "empfohlene_wundversorgung_granudacyn", label = "Granudacyn", order = 5),
CheckItemDefinition(id = "empfohlene_wundversorgung_getraenkte_kompresse", label = "Getränkte Kompresse für ca. ... Min. auflegen", order = 6, isFreeText = true), CheckItemDefinition(
id = "empfohlene_wundversorgung_getraenkte_kompresse",
label = "Getränkte Kompresse",
order = 6,
inputSpec = InputSpec(
pdfTemplate = "Getränkte Kompresse für ca. {0} Min. auflegen",
fields = listOf(InputFieldDef.Choice(label = "Minuten", options = MINUTE_1_TO_15_OPTIONS)),
),
),
CheckItemDefinition(id = "empfohlene_wundversorgung_tupfend", label = "Tupfend", order = 7), CheckItemDefinition(id = "empfohlene_wundversorgung_tupfend", label = "Tupfend", order = 7),
CheckItemDefinition(id = "empfohlene_wundversorgung_wischend", label = "Wischend", order = 8), CheckItemDefinition(id = "empfohlene_wundversorgung_wischend", label = "Wischend", order = 8),
CheckItemDefinition(id = "empfohlene_wundversorgung_nachspuelen_nacl", label = "Nachspülen mit NaCl 0,9%", order = 9), CheckItemDefinition(id = "empfohlene_wundversorgung_nachspuelen_nacl", label = "Nachspülen mit NaCl 0,9%", order = 9),
@@ -343,24 +525,34 @@ object DocumentationCatalog {
CheckItemDefinition(id = "empfohlene_wundversorgung_ueber_wundrand_hinaus", label = "Über Wundrand hinaus", order = 12), CheckItemDefinition(id = "empfohlene_wundversorgung_ueber_wundrand_hinaus", label = "Über Wundrand hinaus", order = 12),
CheckItemDefinition(id = "empfohlene_wundversorgung_nicht_ueber_wundrand_mit", label = "Nicht über Wundrand mit", order = 13, isFreeText = true), CheckItemDefinition(id = "empfohlene_wundversorgung_nicht_ueber_wundrand_mit", label = "Nicht über Wundrand mit", order = 13, isFreeText = true),
CheckItemDefinition(id = "empfohlene_wundversorgung_hinweis_wundrandschutz_mit", label = "Wundrandschutz mit (bis 3x Wo.):", order = 14, isStaticHint = true), CheckItemDefinition(id = "empfohlene_wundversorgung_hinweis_wundrandschutz_mit", label = "Wundrandschutz mit (bis 3x Wo.):", order = 14, isStaticHint = true),
CheckItemDefinition(id = "empfohlene_wundversorgung_secura", label = "Secura", order = 15), CheckItemDefinition(id = "empfohlene_wundversorgung_wundrandschutz_ja", label = "Ja", order = 15),
CheckItemDefinition(id = "empfohlene_wundversorgung_cavilon", label = "Cavilon", order = 16), CheckItemDefinition(id = "empfohlene_wundversorgung_wundrandschutz_nein", label = "Nein", order = 16),
CheckItemDefinition(id = "empfohlene_wundversorgung_hinweis_sekundaere_wundauflage", label = "Sekundäre Wundauflage:", order = 17, isStaticHint = true), CheckItemDefinition(
CheckItemDefinition(id = "empfohlene_wundversorgung_sekundaer_sterile_kompresse", label = "Sterile Kompresse", order = 18), id = "empfohlene_wundversorgung_wundrandschutz_tage_pro_woche",
CheckItemDefinition(id = "empfohlene_wundversorgung_saugkompresse", label = "Saugkompresse", order = 19), label = "Wie oft pro Woche?",
CheckItemDefinition(id = "empfohlene_wundversorgung_hinweis_fixieren_mit", label = "Fixieren mit:", order = 20, isStaticHint = true), order = 17,
CheckItemDefinition(id = "empfohlene_wundversorgung_mullbinde", label = "Mullbinde", order = 21), inputSpec = InputSpec(
CheckItemDefinition(id = "empfohlene_wundversorgung_klebevlies", label = "Klebevlies", order = 22), pdfTemplate = "{0}x pro Woche",
CheckItemDefinition(id = "empfohlene_wundversorgung_folienverband", label = "Folienverband", order = 23), fields = listOf(InputFieldDef.Choice(label = "Tage pro Woche", options = WEEKDAY_COUNT_OPTIONS)),
CheckItemDefinition(id = "empfohlene_wundversorgung_schlauchverband", label = "Schlauchverband", order = 24), ),
CheckItemDefinition(id = "empfohlene_wundversorgung_kohaesive_fixierbinde", label = "Kohäsive Fixierbinde", order = 25), ),
CheckItemDefinition(id = "empfohlene_wundversorgung_kompressionsverband", label = "Kompressionsverband", order = 26), CheckItemDefinition(id = "empfohlene_wundversorgung_hinweis_sekundaere_wundauflage", label = "Sekundäre Wundauflage:", order = 18, isStaticHint = true),
CheckItemDefinition(id = "empfohlene_wundversorgung_wechselintervall", label = "Wechselintervall", order = 27, isFreeText = true), CheckItemDefinition(id = "empfohlene_wundversorgung_sekundaer_sterile_kompresse", label = "Sterile Kompresse", order = 19),
CheckItemDefinition(id = "empfohlene_wundversorgung_hinweis_bedarf", label = "+ bei Bedarf", order = 28, isStaticHint = true), CheckItemDefinition(id = "empfohlene_wundversorgung_saugkompresse", label = "Saugkompresse", order = 20),
CheckItemDefinition(id = "empfohlene_wundversorgung_sonstige_wundauflage", label = "Sonstiges (Wundauflage)", order = 21, isFreeText = true),
CheckItemDefinition(id = "empfohlene_wundversorgung_hinweis_fixieren_mit", label = "Fixieren mit:", order = 22, isStaticHint = true),
CheckItemDefinition(id = "empfohlene_wundversorgung_mullbinde", label = "Mullbinde", order = 23),
CheckItemDefinition(id = "empfohlene_wundversorgung_klebevlies", label = "Klebevlies", order = 24),
CheckItemDefinition(id = "empfohlene_wundversorgung_folienverband", label = "Folienverband", order = 25),
CheckItemDefinition(id = "empfohlene_wundversorgung_schlauchverband", label = "Schlauchverband", order = 26),
CheckItemDefinition(id = "empfohlene_wundversorgung_kohaesive_fixierbinde", label = "Kohäsive Fixierbinde", order = 27, isFreeText = true),
CheckItemDefinition(id = "empfohlene_wundversorgung_kompressionsverband_system", label = "Kompressionsverband / System", order = 28),
CheckItemDefinition(id = "empfohlene_wundversorgung_wechselintervall", label = "Wechselintervall", order = 29, isFreeText = true),
CheckItemDefinition(id = "empfohlene_wundversorgung_hinweis_bedarf", label = "+ bei Bedarf", order = 30, isStaticHint = true),
CheckItemDefinition( CheckItemDefinition(
id = "empfohlene_wundversorgung_hinweis_abklaerung", id = "empfohlene_wundversorgung_hinweis_abklaerung",
label = "Generell: bei akuter Verschlechterung sofort ärztliche Abklärung", label = "Generell: bei akuter Verschlechterung sofort ärztliche Abklärung",
order = 29, order = 31,
isStaticHint = true, isStaticHint = true,
), ),
), ),
@@ -368,7 +560,7 @@ object DocumentationCatalog {
BlockDefinition( BlockDefinition(
id = "ziel", id = "ziel",
title = "Ziel", title = "Ziel",
order = 22, order = 23,
items = listOf( items = listOf(
CheckItemDefinition(id = "ziel_sauberes_wundmilieu", label = "Sauberes Wundmilieu", order = 1), CheckItemDefinition(id = "ziel_sauberes_wundmilieu", label = "Sauberes Wundmilieu", order = 1),
CheckItemDefinition(id = "ziel_infektionsprophylaxe", label = "Infektionsprophylaxe", order = 2), CheckItemDefinition(id = "ziel_infektionsprophylaxe", label = "Infektionsprophylaxe", order = 2),
@@ -381,12 +573,13 @@ object DocumentationCatalog {
CheckItemDefinition(id = "ziel_einfache_durchfuehrung", label = "Einfache Durchführung", order = 9), CheckItemDefinition(id = "ziel_einfache_durchfuehrung", label = "Einfache Durchführung", order = 9),
CheckItemDefinition(id = "ziel_wundrandschutz", label = "Wundrandschutz", order = 10), CheckItemDefinition(id = "ziel_wundrandschutz", label = "Wundrandschutz", order = 10),
CheckItemDefinition(id = "ziel_aufbruch_vorhandenes_material", label = "Aufbruch des vorhandenen Materials", order = 11), CheckItemDefinition(id = "ziel_aufbruch_vorhandenes_material", label = "Aufbruch des vorhandenen Materials", order = 11),
CheckItemDefinition(id = "ziel_selbstversorgung_foerdern", label = "Selbstversorgung der Wunde durch Patient/Angehörige unterstützen/fördern", order = 12),
), ),
), ),
BlockDefinition( BlockDefinition(
id = "kopie_nachrichtlich_an", id = "kopie_nachrichtlich_an",
title = "Kopie nachrichtlich an", title = "Kopie nachrichtlich an",
order = 23, order = 24,
items = listOf( items = listOf(
CheckItemDefinition(id = "kopie_nachrichtlich_an_arzt", label = "Ärztin / Arzt", order = 1, isFreeText = true), CheckItemDefinition(id = "kopie_nachrichtlich_an_arzt", label = "Ärztin / Arzt", order = 1, isFreeText = true),
CheckItemDefinition(id = "kopie_nachrichtlich_an_pflege", label = "Pflege", order = 2, isFreeText = true), CheckItemDefinition(id = "kopie_nachrichtlich_an_pflege", label = "Pflege", order = 2, isFreeText = true),
@@ -397,7 +590,7 @@ object DocumentationCatalog {
BlockDefinition( BlockDefinition(
id = "naechster_termin", id = "naechster_termin",
title = "Nächster Termin", title = "Nächster Termin",
order = 24, order = 25,
items = emptyList(), items = emptyList(),
), ),
) )
@@ -425,7 +618,7 @@ data class BlockDefinition(
/** /**
* Ein einzelner Katalog-Eintrag innerhalb eines Blocks. * Ein einzelner Katalog-Eintrag innerhalb eines Blocks.
* *
* Normalfall: reine Checkbox ([isFreeText] = false, [isStaticHint] = false). * Normalfall: reine Checkbox ([isFreeText] = false, [isStaticHint] = false, [inputSpec] = null).
* *
* [isFreeText] = true: eine vom Katalog selbst definierte, fachlich eigenständig benannte * [isFreeText] = true: eine vom Katalog selbst definierte, fachlich eigenständig benannte
* Freitext-Checkbox (z. B. "Erscheinung", "Wechselintervall", oder ein Empfänger bei "Kopie * Freitext-Checkbox (z. B. "Erscheinung", "Wechselintervall", oder ein Empfänger bei "Kopie
@@ -437,10 +630,15 @@ data class BlockDefinition(
* optische Zwischenüberschrift wie "Reinigen mit:") - [label] ist hier direkt der anzuzeigende * optische Zwischenüberschrift wie "Reinigen mit:") - [label] ist hier direkt der anzuzeigende
* Text selbst, keine Checkbox-Beschriftung. * Text selbst, keine Checkbox-Beschriftung.
* *
* [isFreeText] und [isStaticHint] schließen sich fachlich gegenseitig aus (ein Eintrag ist * [inputSpec] != null: dieser Eintrag besitzt statt eines einfachen Häkchens ein oder mehrere
* entweder normale Checkbox, Freitext-Checkbox oder Hinweistext) - es gibt aber bewusst keine * strukturierte Eingabefelder (Zahl oder Auswahlliste, z. B. "Von [ ] bis [ ] Uhr" oder
* Laufzeitprüfung dafür, um die Katalogdefinition einfach zu halten; beide gleichzeitig auf * "Länge/Breite/Tiefe") - s. [de.medapp.wunddoku.domain.model.InputSpec].
* `true` zu setzen ergibt schlicht keinen sinnvollen Katalogeintrag. *
* [isFreeText], [isStaticHint] und [inputSpec] schließen sich fachlich gegenseitig aus (ein
* Eintrag ist entweder normale Checkbox, Freitext-Checkbox, Hinweistext oder strukturierte
* Eingabe) - es gibt aber bewusst keine Laufzeitprüfung dafür, um die Katalogdefinition einfach
* zu halten; mehrere davon gleichzeitig zu setzen ergibt schlicht keinen sinnvollen
* Katalogeintrag.
*/ */
data class CheckItemDefinition( data class CheckItemDefinition(
val id: String, val id: String,
@@ -448,4 +646,5 @@ data class CheckItemDefinition(
val order: Int, val order: Int,
val isFreeText: Boolean = false, val isFreeText: Boolean = false,
val isStaticHint: Boolean = false, val isStaticHint: Boolean = false,
val inputSpec: InputSpec? = null,
) )
@@ -20,6 +20,11 @@ package de.medapp.wunddoku.domain.model
* [isStaticHint]: reiner, nicht editierbarer Hinweistext (z. B. "+ bei Bedarf") - keine * [isStaticHint]: reiner, nicht editierbarer Hinweistext (z. B. "+ bei Bedarf") - keine
* Checkbox in der UI, [checked] ist bei solchen Zeilen technisch immer `true` (s. Entity- * Checkbox in der UI, [checked] ist bei solchen Zeilen technisch immer `true` (s. Entity-
* Kommentar), zählt aber NICHT als Bearbeitung (s. [de.medapp.wunddoku.domain.usecase.BlockEditedPolicy]). * Kommentar), zählt aber NICHT als Bearbeitung (s. [de.medapp.wunddoku.domain.usecase.BlockEditedPolicy]).
*
* [inputSpec]: nicht-leer, wenn dieser Eintrag statt eines einfachen Häkchens strukturierte
* Eingabefelder besitzt (Zahl/Auswahlliste) - roher, codierter String, s. [InputSpecCodec.decode].
* Die eingegebenen Werte liegen weiterhin in [freeTextValue] (mehrere Felder durch ein
* Trennzeichen kombiniert, s. [InputSpec.decodeValues]/[InputSpec.encodeValues]).
*/ */
data class CheckItem( data class CheckItem(
val checkItemStateId: String, val checkItemStateId: String,
@@ -30,4 +35,5 @@ data class CheckItem(
val isFreeText: Boolean = false, val isFreeText: Boolean = false,
val freeTextValue: String = "", val freeTextValue: String = "",
val isStaticHint: Boolean = false, val isStaticHint: Boolean = false,
val inputSpec: String = "",
) )
@@ -0,0 +1,155 @@
package de.medapp.wunddoku.domain.model
/**
* Ein einzelnes Eingabefeld innerhalb eines strukturierten Katalog-Eintrags (s. [InputSpec]).
*
* Bewusst nur zwei Feldarten - genau das, was die Kundenvorgaben bisher benötigen:
* - [Number]: Zahleneingabe mit maximaler Zeichenzahl (z. B. Länge/Breite/Tiefe, Blutdruck)
* - [Choice]: Auswahl aus einer festen Liste (z. B. Uhrzeit-Position 1-12, Tiefe in 0,5-cm-Schritten)
*/
sealed interface InputFieldDef {
val label: String
/**
* @param maxLength maximale Zeichenzahl (bei [allowDecimal] inklusive des Dezimaltrennzeichens)
* @param allowDecimal erlaubt genau ein Dezimalkomma (Punkt wird beim Tippen zu Komma)
*/
data class Number(
override val label: String,
val maxLength: Int,
val allowDecimal: Boolean = false,
) : InputFieldDef
data class Choice(
override val label: String,
val options: List<String>,
) : InputFieldDef
}
/**
* Beschreibt einen Katalog-Eintrag, der statt eines einfachen Häkchens (oder Freitexts) ein oder
* mehrere strukturierte Eingabefelder besitzt - z. B. "Von [3] bis [5] Uhr" oder
* "Länge [ ] Breite [ ] Tiefe [ ]".
*
* Der Eintrag bleibt trotzdem eine ganz normale Checkbox-Zeile (s. CheckItem.checked): Sobald in
* einem Feld etwas eingegeben/gewählt wird, wird das Häkchen automatisch gesetzt (s. UI). Dadurch
* greifen BlockEditedPolicy und die "nur angehakte Elemente"-Regel im PDF-Export unverändert -
* es gibt keine zweite "bearbeitet"-Logik für diesen Eintragstyp.
*
* [pdfTemplate] bestimmt, wie der Eintrag im PDF ausgegeben wird; `{0}`, `{1}` ... werden durch die
* Werte der Felder (in Reihenfolge von [fields]) ersetzt, z. B. `"Von {0} bis {1} Uhr"`.
*
* Die Beschreibung wird beim Anlegen einer Dokumentation zusammen mit dem Eintrag gespeichert
* (Snapshot, s. [InputSpecCodec]) - spätere Katalogänderungen (andere Auswahlwerte, andere
* Textvorlage) verändern bestehende Dokumentationen dadurch nicht rückwirkend.
*/
data class InputSpec(
val pdfTemplate: String,
val fields: List<InputFieldDef>,
) {
/** Zerlegt den gespeicherten Gesamtwert (s. [encodeValues]) in genau [fields].size Einzelwerte. */
fun decodeValues(stored: String): List<String> {
val parts = if (stored.isEmpty()) emptyList() else stored.split(VALUE_SEPARATOR)
return List(fields.size) { index -> parts.getOrElse(index) { "" } }
}
/** Fasst die Einzelwerte der Felder zu dem einen String zusammen, der in der Datenbank steht. */
fun encodeValues(values: List<String>): String = values.joinToString(VALUE_SEPARATOR.toString())
/** true, sobald mindestens ein Feld einen Wert enthält. */
fun hasAnyValue(stored: String): Boolean = decodeValues(stored).any { it.isNotBlank() }
/**
* Text für den PDF-Export: [pdfTemplate] mit den eingesetzten Feldwerten. Ein noch leeres
* Feld erscheint als "–", damit erkennbar bleibt, dass dort bewusst nichts gewählt wurde.
*/
fun formatForPdf(stored: String): String {
var result = pdfTemplate
decodeValues(stored).forEachIndexed { index, value ->
result = result.replace("{$index}", value.ifBlank { EMPTY_VALUE_PLACEHOLDER })
}
return result
}
companion object {
/** Trennzeichen zwischen den Werten mehrerer Felder (Steuerzeichen, kommt in Zahlen/Auswahlwerten nie vor). */
const val VALUE_SEPARATOR: Char = '\u001F'
private const val EMPTY_VALUE_PLACEHOLDER = "–"
}
}
/**
* (De-)Serialisierung von [InputSpec] in einen einzelnen String für die Datenbankspalte
* `check_item_states.inputSpec`. Bewusst ein schlichtes eigenes Format mit Steuerzeichen als
* Trennern statt JSON: reines Kotlin ohne Android-Klassen (jederzeit in normalen JUnit-Tests
* prüfbar) und ohne zusätzliche Abhängigkeit. Die verwendeten Trennzeichen kommen in Labels,
* Textvorlagen und Auswahlwerten nicht vor.
*
* Aufbau: `pdfTemplate ␞ feld1 ␞ feld2 ...`, jedes Feld `N␟label␟maxLength␟decimal` bzw.
* `C␟label␟option␝option␝...`.
*/
object InputSpecCodec {
private const val RECORD_SEPARATOR = '\u001E'
private const val UNIT_SEPARATOR = '\u001F'
private const val GROUP_SEPARATOR = '\u001D'
fun encode(spec: InputSpec): String {
val records = mutableListOf(spec.pdfTemplate)
for (field in spec.fields) {
records += when (field) {
is InputFieldDef.Number -> listOf(
"N",
field.label,
field.maxLength.toString(),
if (field.allowDecimal) "1" else "0",
).joinToString(UNIT_SEPARATOR.toString())
is InputFieldDef.Choice -> listOf(
"C",
field.label,
field.options.joinToString(GROUP_SEPARATOR.toString()),
).joinToString(UNIT_SEPARATOR.toString())
}
}
return records.joinToString(RECORD_SEPARATOR.toString())
}
/** Liefert `null` für einen leeren String (Eintrag ohne strukturierte Felder) oder bei nicht lesbaren Daten. */
fun decode(encoded: String): InputSpec? {
if (encoded.isEmpty()) return null
val records = encoded.split(RECORD_SEPARATOR)
if (records.size < 2) return null
val fields = records.drop(1).mapNotNull { record ->
val attributes = record.split(UNIT_SEPARATOR)
when (attributes.firstOrNull()) {
"N" -> {
val maxLength = attributes.getOrNull(2)?.toIntOrNull()
if (attributes.size >= 4 && maxLength != null) {
InputFieldDef.Number(
label = attributes[1],
maxLength = maxLength,
allowDecimal = attributes[3] == "1",
)
} else {
null
}
}
"C" -> if (attributes.size >= 3) {
InputFieldDef.Choice(
label = attributes[1],
options = attributes[2].split(GROUP_SEPARATOR),
)
} else {
null
}
else -> null
}
}
if (fields.isEmpty()) return null
return InputSpec(pdfTemplate = records.first(), fields = fields)
}
}
@@ -6,6 +6,7 @@ import android.graphics.Color
import android.graphics.Paint import android.graphics.Paint
import android.graphics.pdf.PdfDocument import android.graphics.pdf.PdfDocument
import android.text.TextPaint import android.text.TextPaint
import de.medapp.wunddoku.domain.model.InputSpecCodec
import de.medapp.wunddoku.media.PhotoStorage import de.medapp.wunddoku.media.PhotoStorage
import de.medapp.wunddoku.signature.SignatureStorage import de.medapp.wunddoku.signature.SignatureStorage
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -281,6 +282,22 @@ class AndroidPdfDocumentExporter(
* angehakte Optionen enthält (s. Mapper), wäre ein "immer angekreuztes" Kästchen ohnehin * angehakte Optionen enthält (s. Mapper), wäre ein "immer angekreuztes" Kästchen ohnehin
* redundant - stattdessen ein einfacher, leicht eingerückter Label-Text je Option. */ * redundant - stattdessen ein einfacher, leicht eingerückter Label-Text je Option. */
private fun drawItem(cursor: PdfPageCursor, item: PdfExportItem, paints: ExportPaints) { private fun drawItem(cursor: PdfPageCursor, item: PdfExportItem, paints: ExportPaints) {
// Änderungswunsch: strukturierte Eingabefelder (Zahl/Auswahlliste, z. B. "Von ... bis
// ... Uhr") werden als EIN fertig formatierter Satz ausgegeben (s.
// InputSpec.formatForPdf) - kein Label+Doppelpunkt+separate Zusatzzeile wie bei
// Freitext, da die Textvorlage im Katalog bereits den vollständigen, lesbaren Satz
// vorgibt.
val decodedInputSpec = if (item.inputSpec.isNotEmpty()) InputSpecCodec.decode(item.inputSpec) else null
if (decodedInputSpec != null) {
cursor.drawText(
text = decodedInputSpec.formatForPdf(item.freeTextValue),
paint = paints.item,
indent = ITEM_INDENT,
spacingAfter = 3f,
)
return
}
// Die Freitext-Option bekommt einen abschließenden Doppelpunkt, normale Optionen nicht. // Die Freitext-Option bekommt einen abschließenden Doppelpunkt, normale Optionen nicht.
val labelText = if (item.isFreeText) "${item.label}:" else item.label val labelText = if (item.isFreeText) "${item.label}:" else item.label
@@ -40,6 +40,7 @@ object DocumentationToPdfExportModelMapper {
label = item.label, label = item.label,
isFreeText = item.isFreeText, isFreeText = item.isFreeText,
freeTextValue = item.freeTextValue, freeTextValue = item.freeTextValue,
inputSpec = item.inputSpec,
) )
}, },
) )
@@ -34,11 +34,17 @@ data class PdfExportBlock(
) )
/** Kein `checked`-Feld mehr: Da [PdfExportBlock.items] bereits ausschließlich angehakte /** Kein `checked`-Feld mehr: Da [PdfExportBlock.items] bereits ausschließlich angehakte
* Optionen enthält, wäre es immer `true` und damit bedeutungslos - s. Mapper-Filter. */ * Optionen enthält, wäre es immer `true` und damit bedeutungslos - s. Mapper-Filter.
*
* [inputSpec]: nicht-leer, wenn dieser Eintrag strukturierte Eingabefelder besitzt (s.
* [de.medapp.wunddoku.domain.model.InputSpec]) - der Exporter formatiert in diesem Fall
* [freeTextValue] über [de.medapp.wunddoku.domain.model.InputSpec.formatForPdf] statt es als
* einfachen Zusatztext unter [label] auszugeben. */
data class PdfExportItem( data class PdfExportItem(
val label: String, val label: String,
val isFreeText: Boolean, val isFreeText: Boolean,
val freeTextValue: String, val freeTextValue: String,
val inputSpec: String = "",
) )
/** /**
@@ -0,0 +1,99 @@
package de.medapp.wunddoku.pdf
import android.content.ActivityNotFoundException
import android.content.ClipData
import android.content.Context
import android.content.Intent
import androidx.core.content.FileProvider
import java.io.File
/**
* Übergibt alle als JPEG gerenderten PDF-Seiten (s. [PdfToImagesConverter]) GEMEINSAM an den
* Android-"Senden an"-Dialog (`ACTION_SEND_MULTIPLE`) - Änderungswunsch: "alle generierten
* Bilder über 'Senden an' direkt verschicken können", nicht Seite für Seite einzeln.
*
* Bewusst getrennt von [PdfToImagesConverter] (reine Datei-Logik): das Starten einer Activity
* ist eine UI-/Plattform-Aktion, keine Repository-Aufgabe - exakt dieselbe Trennung wie bei
* [DocumentationPdfExporter] (Erzeugung) vs. [PdfOpener] (Anzeige).
*
* VulneraDoc implementiert ausdrücklich KEINEN eigenen Versand-Mechanismus; welche konkrete App
* (Mail, Messenger, Cloud-Speicher, ...) die Bilder entgegennimmt, entscheidet ausschließlich
* Android/der Nutzer über den System-Dialog.
*/
interface PdfImageSharer {
fun share(imageFiles: List<File>): PdfImageShareResult
}
sealed interface PdfImageShareResult {
data object Success : PdfImageShareResult
sealed interface Failure : PdfImageShareResult {
/** Es wurden keine Bilddateien übergeben - nichts zum Teilen vorhanden. */
data object NoImages : Failure
/** FileProvider konnte für mindestens eine Datei keine content://-Uri erzeugen. */
data class UriResolutionFailed(val message: String) : Failure
/** Keine installierte App kann Bilder (image/jpeg) entgegennehmen. */
data object NoAppAvailable : Failure
data class Unexpected(val message: String) : Failure
}
}
fun PdfImageShareResult.Failure.toUserMessage(): String = when (this) {
PdfImageShareResult.Failure.NoImages ->
"Es sind keine Bilder zum Versenden vorhanden."
is PdfImageShareResult.Failure.UriResolutionFailed ->
"Die Bilder konnten nicht zum Versenden freigegeben werden."
PdfImageShareResult.Failure.NoAppAvailable ->
"Auf diesem Gerät ist keine App zum Versenden von Bildern installiert."
is PdfImageShareResult.Failure.Unexpected ->
"Die Bilder konnten nicht versendet werden."
}
class AndroidPdfImageSharer(private val context: Context) : PdfImageSharer {
override fun share(imageFiles: List<File>): PdfImageShareResult {
if (imageFiles.isEmpty()) return PdfImageShareResult.Failure.NoImages
val authority = "${context.packageName}.fileprovider"
val uris = try {
imageFiles.map { file -> FileProvider.getUriForFile(context, authority, file) }
} catch (e: IllegalArgumentException) {
// Wird von FileProvider geworfen, wenn eine Datei außerhalb der in file_paths.xml
// freigegebenen Verzeichnisse liegt oder die Authority nicht passt.
return PdfImageShareResult.Failure.UriResolutionFailed(e.message ?: "Uri konnte nicht erzeugt werden.")
}
val intent = Intent(Intent.ACTION_SEND_MULTIPLE).apply {
type = "image/jpeg"
putParcelableArrayListExtra(Intent.EXTRA_STREAM, ArrayList(uris))
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
// Zusätzlich zum Flag oben wird die Leseberechtigung explizit über ClipData an JEDE
// einzelne Uri gehängt - bei ACTION_SEND_MULTIPLE mit mehreren content://-Uris ist
// das der zuverlässige Weg, damit wirklich ALLE empfangenden Apps Zugriff auf ALLE
// Bilder bekommen (nicht nur auf die im Intent-Datenfeld primär referenzierte).
clipData = ClipData.newUri(context.contentResolver, "Wunddokumentation", uris.first()).apply {
for (uri in uris.drop(1)) {
addItem(ClipData.Item(uri))
}
}
}
val chooser = Intent.createChooser(intent, "Bilder senden an")
return try {
context.startActivity(chooser)
PdfImageShareResult.Success
} catch (e: ActivityNotFoundException) {
// Bewusst KEIN vorheriges intent.resolveActivity(...) - s. Begründung in
// AndroidPdfOpener.open() (Package-Visibility-Einschränkungen ab Android 11).
PdfImageShareResult.Failure.NoAppAvailable
} catch (e: SecurityException) {
PdfImageShareResult.Failure.Unexpected(e.message ?: "Zugriff auf die Bilddateien wurde verweigert.")
} catch (e: Exception) {
PdfImageShareResult.Failure.Unexpected(e.message ?: "Unbekannter Fehler beim Versenden der Bilder.")
}
}
}
@@ -0,0 +1,72 @@
package de.medapp.wunddoku.pdf
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import androidx.core.content.FileProvider
import java.io.File
/**
* Übergibt die fertige PDF-Datei selbst (statt in JPEG-Seiten umgewandelt, s.
* [PdfToImagesConverter]/[PdfImageSharer]) an den Android-"Senden an"-Dialog
* (`ACTION_SEND`, `application/pdf`) - Änderungswunsch: Nutzer soll beim Antippen des
* Teilen-Buttons wählen können, ob als JPG ODER als PDF geteilt wird.
*
* Bewusst getrennt von [PdfOpener] (öffnet dieselbe Datei stattdessen per `ACTION_VIEW` in
* einem PDF-Reader) - unterschiedliche Nutzerabsicht (ansehen vs. weitergeben), auch wenn
* beide dieselbe Datei über denselben FileProvider-Pfad freigeben.
*/
interface PdfSharer {
fun share(pdfFile: File): PdfShareResult
}
sealed interface PdfShareResult {
data object Success : PdfShareResult
sealed interface Failure : PdfShareResult {
data class UriResolutionFailed(val message: String) : Failure
data object NoAppAvailable : Failure
data class Unexpected(val message: String) : Failure
}
}
fun PdfShareResult.Failure.toUserMessage(): String = when (this) {
is PdfShareResult.Failure.UriResolutionFailed ->
"Die PDF-Datei konnte nicht zum Versenden freigegeben werden."
PdfShareResult.Failure.NoAppAvailable ->
"Auf diesem Gerät ist keine App zum Versenden von PDFs installiert."
is PdfShareResult.Failure.Unexpected ->
"Die PDF-Datei konnte nicht versendet werden."
}
class AndroidPdfSharer(private val context: Context) : PdfSharer {
override fun share(pdfFile: File): PdfShareResult {
val authority = "${context.packageName}.fileprovider"
val uri = try {
FileProvider.getUriForFile(context, authority, pdfFile)
} catch (e: IllegalArgumentException) {
return PdfShareResult.Failure.UriResolutionFailed(e.message ?: "Uri konnte nicht erzeugt werden.")
}
val intent = Intent(Intent.ACTION_SEND).apply {
type = "application/pdf"
putExtra(Intent.EXTRA_STREAM, uri)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
val chooser = Intent.createChooser(intent, "PDF senden an")
return try {
context.startActivity(chooser)
PdfShareResult.Success
} catch (e: ActivityNotFoundException) {
// Bewusst KEIN vorheriges intent.resolveActivity(...) - s. Begründung in
// AndroidPdfOpener.open() (Package-Visibility-Einschränkungen ab Android 11).
PdfShareResult.Failure.NoAppAvailable
} catch (e: SecurityException) {
PdfShareResult.Failure.Unexpected(e.message ?: "Zugriff auf die PDF-Datei wurde verweigert.")
} catch (e: Exception) {
PdfShareResult.Failure.Unexpected(e.message ?: "Unbekannter Fehler beim Versenden der PDF-Datei.")
}
}
}
@@ -0,0 +1,117 @@
package de.medapp.wunddoku.pdf
import android.content.Context
import android.graphics.Bitmap
import android.graphics.Color
import android.graphics.pdf.PdfRenderer
import android.os.ParcelFileDescriptor
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File
import java.io.FileOutputStream
import kotlin.math.roundToInt
/**
* Rendert eine bereits fertig erzeugte PDF-Datei (s. [DocumentationPdfExporter]) seitenweise in
* einzelne JPEG-Dateien - Änderungswunsch: "PDF zusätzlich als JPEG speichern/senden können".
*
* Bewusst als eigener Schritt NACH dem PDF-Export statt als Alternative dazu: Es gibt weiterhin
* genau eine Quelle der Wahrheit für das Seitenlayout (der bestehende [PdfPageCursor]-basierte
* Export) - dieser Konverter rastert lediglich das bereits fertige Ergebnis, statt eine zweite,
* parallele Layout-Logik zu pflegen.
*
* Verwendet [android.graphics.pdf.PdfRenderer] (Android-Bordmittel seit API 21, unproblematisch
* bei minSdk 26) - keine zusätzliche Bibliothek nötig. Eine mehrseitige Wunddokumentation (Kopf/
* Blöcke, Signaturen, ggf. 30+ Fotoseiten) ergibt dabei bewusst EIN JPEG PRO SEITE, kein
* einzelnes Sammelbild - beim späteren Teilen (s. [PdfImageSharer]) werden alle Seiten
* gemeinsam als Bilder-Mehrfachauswahl übergeben.
*/
interface PdfToImagesConverter {
suspend fun convert(documentationId: String, pdfFile: File): PdfToImagesResult
}
sealed interface PdfToImagesResult {
data class Success(val imageFiles: List<File>) : PdfToImagesResult
sealed interface Failure : PdfToImagesResult {
/** Die übergebene PDF-Datei existiert nicht (mehr). */
data object FileNotFound : Failure
/** PdfRenderer meldet 0 Seiten - sollte bei einem selbst erzeugten PDF nie vorkommen. */
data object EmptyDocument : Failure
data class Unexpected(val message: String) : Failure
}
}
fun PdfToImagesResult.Failure.toUserMessage(): String = when (this) {
PdfToImagesResult.Failure.FileNotFound ->
"Die PDF-Datei wurde nicht gefunden. Bitte zuerst die Dokumentation erneut exportieren."
PdfToImagesResult.Failure.EmptyDocument ->
"Die PDF-Datei enthält keine Seiten."
is PdfToImagesResult.Failure.Unexpected ->
"Die PDF-Datei konnte nicht in Bilder umgewandelt werden."
}
class AndroidPdfToImagesConverter(private val context: Context) : PdfToImagesConverter {
override suspend fun convert(documentationId: String, pdfFile: File): PdfToImagesResult =
withContext(Dispatchers.IO) {
if (!pdfFile.exists()) return@withContext PdfToImagesResult.Failure.FileNotFound
val outputDir = File(File(context.filesDir, "pdf_images"), documentationId)
// Bilder eines vorherigen Exports zuerst entfernen, damit bei einer inzwischen
// kürzeren PDF (z. B. Fotos gelöscht) keine veralteten, zu vielen Seiten-Dateien
// liegen bleiben.
outputDir.deleteRecursively()
outputDir.mkdirs()
try {
ParcelFileDescriptor.open(pdfFile, ParcelFileDescriptor.MODE_READ_ONLY).use { fd ->
PdfRenderer(fd).use { renderer ->
if (renderer.pageCount == 0) {
return@withContext PdfToImagesResult.Failure.EmptyDocument
}
val imageFiles = mutableListOf<File>()
for (pageIndex in 0 until renderer.pageCount) {
renderer.openPage(pageIndex).use { page ->
val scale = RENDER_DPI / POINTS_PER_INCH
val width = (page.width * scale).roundToInt().coerceAtLeast(1)
val height = (page.height * scale).roundToInt().coerceAtLeast(1)
val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
// WICHTIG: PdfRenderer zeichnet nur die tatsächlich im PDF
// vorhandenen Inhalte - unbedeckte Flächen bleiben transparent
// (Alpha 0). JPEG kennt keine Transparenz; ohne dieses
// Vorab-Weißfüllen würde jede unbedeckte Fläche beim
// Komprimieren als SCHWARZ statt als weißer Seitenhintergrund
// enden (bekannte PdfRenderer-Falle, s. offizielle Android-
// Beispiele).
bitmap.eraseColor(Color.WHITE)
page.render(bitmap, null, null, PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY)
val imageFile = File(outputDir, "seite_%02d.jpg".format(pageIndex + 1))
FileOutputStream(imageFile).use { out ->
bitmap.compress(Bitmap.CompressFormat.JPEG, JPEG_QUALITY, out)
}
bitmap.recycle()
imageFiles.add(imageFile)
}
}
PdfToImagesResult.Success(imageFiles)
}
}
} catch (e: Exception) {
PdfToImagesResult.Failure.Unexpected(e.message ?: "Unbekannter Fehler bei der Bildkonvertierung.")
}
}
companion object {
/** PDF-Koordinaten sind in Punkten (1/72 Zoll) angegeben - diese Ziel-Auflösung ergibt
* ein für Ausdruck/Weitergabe ausreichend scharfes, aber nicht unnötig großes Bild. */
private const val RENDER_DPI = 200f
private const val POINTS_PER_INCH = 72f
private const val JPEG_QUALITY = 90
}
}
@@ -10,16 +10,19 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
@@ -30,6 +33,7 @@ import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.ExpandLess import androidx.compose.material.icons.filled.ExpandLess
import androidx.compose.material.icons.filled.ExpandMore import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material.icons.filled.PictureAsPdf import androidx.compose.material.icons.filled.PictureAsPdf
import androidx.compose.material.icons.filled.Share
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.Checkbox import androidx.compose.material3.Checkbox
@@ -39,12 +43,15 @@ import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult import androidx.compose.material3.SnackbarResult
import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
@@ -64,7 +71,9 @@ import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.LocalLifecycleOwner
@@ -72,11 +81,17 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import de.medapp.wunddoku.R import de.medapp.wunddoku.R
import de.medapp.wunddoku.domain.model.CheckItem import de.medapp.wunddoku.domain.model.CheckItem
import de.medapp.wunddoku.domain.model.InputFieldDef
import de.medapp.wunddoku.domain.model.InputSpecCodec
import de.medapp.wunddoku.domain.model.DocumentationBlock import de.medapp.wunddoku.domain.model.DocumentationBlock
import de.medapp.wunddoku.domain.model.Photo import de.medapp.wunddoku.domain.model.Photo
import de.medapp.wunddoku.domain.model.Signature import de.medapp.wunddoku.domain.model.Signature
import de.medapp.wunddoku.pdf.AndroidPdfImageSharer
import de.medapp.wunddoku.pdf.AndroidPdfOpener import de.medapp.wunddoku.pdf.AndroidPdfOpener
import de.medapp.wunddoku.pdf.AndroidPdfSharer
import de.medapp.wunddoku.pdf.PdfImageShareResult
import de.medapp.wunddoku.pdf.PdfOpenResult import de.medapp.wunddoku.pdf.PdfOpenResult
import de.medapp.wunddoku.pdf.PdfShareResult
import de.medapp.wunddoku.pdf.toUserMessage import de.medapp.wunddoku.pdf.toUserMessage
import de.medapp.wunddoku.ui.documentation.edit.signature.AddSignatureDialog import de.medapp.wunddoku.ui.documentation.edit.signature.AddSignatureDialog
@@ -137,6 +152,8 @@ fun DocumentationEditScreen(
// Starten einer externen Activity ist eine UI-/Plattform-Aktion, keine Repository-Aufgabe. // Starten einer externen Activity ist eine UI-/Plattform-Aktion, keine Repository-Aufgabe.
val context = LocalContext.current val context = LocalContext.current
val pdfOpener = remember(context) { AndroidPdfOpener(context) } val pdfOpener = remember(context) { AndroidPdfOpener(context) }
val pdfImageSharer = remember(context) { AndroidPdfImageSharer(context) }
val pdfSharer = remember(context) { AndroidPdfSharer(context) }
val snackbarHostState = remember { SnackbarHostState() } val snackbarHostState = remember { SnackbarHostState() }
LaunchedEffect(uiState.pdfExportMessage) { LaunchedEffect(uiState.pdfExportMessage) {
val message = uiState.pdfExportMessage ?: return@LaunchedEffect val message = uiState.pdfExportMessage ?: return@LaunchedEffect
@@ -157,6 +174,34 @@ fun DocumentationEditScreen(
viewModel.dismissPdfExportMessage() viewModel.dismissPdfExportMessage()
} }
// Änderungswunsch: erzeugte Bilder direkt über den Android-"Senden an"-Dialog verschicken
// können. Sobald das ViewModel frische JPEG-Seiten bereitstellt (s.
// DocumentationEditViewModel.shareAsImages), öffnet dieser Effekt den System-
// Share-Dialog - Intent/Activity-Start ist bewusst UI-Aufgabe, nicht Sache des ViewModels.
LaunchedEffect(uiState.imagesToShare) {
val images = uiState.imagesToShare ?: return@LaunchedEffect
when (val shareResult = pdfImageSharer.share(images)) {
PdfImageShareResult.Success -> Unit
is PdfImageShareResult.Failure -> snackbarHostState.showSnackbar(shareResult.toUserMessage())
}
viewModel.onImageShareHandled()
}
// Analog zum Bilderversand oben, nur für die Format-Auswahl "PDF" (s.
// DocumentationEditViewModel.shareAsPdf).
LaunchedEffect(uiState.pdfToShare) {
val pdfFile = uiState.pdfToShare ?: return@LaunchedEffect
when (val shareResult = pdfSharer.share(pdfFile)) {
PdfShareResult.Success -> Unit
is PdfShareResult.Failure -> snackbarHostState.showSnackbar(shareResult.toUserMessage())
}
viewModel.onPdfShareHandled()
}
LaunchedEffect(uiState.shareMessage) {
val message = uiState.shareMessage ?: return@LaunchedEffect
snackbarHostState.showSnackbar(message)
viewModel.dismissShareMessage()
}
Scaffold( Scaffold(
snackbarHost = { SnackbarHost(snackbarHostState) }, snackbarHost = { SnackbarHost(snackbarHostState) },
topBar = { topBar = {
@@ -175,9 +220,8 @@ fun DocumentationEditScreen(
} }
}, },
actions = { actions = {
// PDF-005 (Öffnen)/PDF-006 (Teilen) folgen später - dieser Button erzeugt // PDF-005 (Öffnen) ist umgesetzt (s. LaunchedEffect oben); dieser Button
// ausschließlich die Datei im internen Speicher (PDF-001/002), als // erzeugt die Datei im internen Speicher (PDF-001/002).
// minimaler Trigger zum manuellen Testen der Export-Funktionalität.
if (uiState.pdfExportInProgress) { if (uiState.pdfExportInProgress) {
CircularProgressIndicator(modifier = Modifier.size(24.dp), strokeWidth = 2.dp) CircularProgressIndicator(modifier = Modifier.size(24.dp), strokeWidth = 2.dp)
} else { } else {
@@ -185,6 +229,16 @@ fun DocumentationEditScreen(
Icon(Icons.Filled.PictureAsPdf, contentDescription = "PDF erzeugen") Icon(Icons.Filled.PictureAsPdf, contentDescription = "PDF erzeugen")
} }
} }
// Änderungswunsch: PDF zusätzlich als JPEG speichern/versenden, wahlweise
// auch als PDF selbst - Format wird per Dialog abgefragt (s. weiter unten),
// BEVOR irgendetwas exportiert wird.
if (uiState.shareInProgress) {
CircularProgressIndicator(modifier = Modifier.size(24.dp), strokeWidth = 2.dp)
} else {
IconButton(onClick = viewModel::requestShareFormatChoice) {
Icon(Icons.Filled.Share, contentDescription = "Teilen")
}
}
// "Fertig" beendet die Bearbeitung bereits jetzt (Inhalte sind ohnehin per // "Fertig" beendet die Bearbeitung bereits jetzt (Inhalte sind ohnehin per
// Autosave gesichert, kein expliziter Speichern-Button nötig). // Autosave gesichert, kein expliziter Speichern-Button nötig).
Button(onClick = onFinished) { Text("Fertig") } Button(onClick = onFinished) { Text("Fertig") }
@@ -315,6 +369,52 @@ fun DocumentationEditScreen(
onDismiss = { viewedPhoto = null }, onDismiss = { viewedPhoto = null },
) )
} }
// Änderungswunsch: Format-Auswahl beim Antippen des Teilen-Buttons.
if (uiState.showShareFormatDialog) {
ShareFormatDialog(
onDismiss = viewModel::dismissShareFormatDialog,
onChooseImages = viewModel::shareAsImages,
onChoosePdf = viewModel::shareAsPdf,
)
}
}
/**
* Fragt beim Antippen des Teilen-Buttons ab, ob als JPG (Bilder, eine Datei pro Seite) oder als
* PDF (die Datei selbst) geteilt werden soll (Änderungswunsch). Erst NACH dieser Auswahl wird
* überhaupt exportiert (s. [DocumentationEditViewModel.shareAsImages]/[DocumentationEditViewModel.shareAsPdf]) -
* kein unnötiger Export, falls der Nutzer den Dialog stattdessen abbricht.
*/
@Composable
private fun ShareFormatDialog(
onDismiss: () -> Unit,
onChooseImages: () -> Unit,
onChoosePdf: () -> Unit,
) {
Dialog(onDismissRequest = onDismiss) {
Surface(shape = RoundedCornerShape(16.dp), color = MaterialTheme.colorScheme.surface) {
Column(modifier = Modifier.fillMaxWidth().padding(20.dp)) {
Text("Teilen als", style = MaterialTheme.typography.titleMedium)
Spacer(Modifier.height(8.dp))
Text(
"Als Bilder (JPG) oder als PDF teilen?",
style = MaterialTheme.typography.bodyMedium,
)
Spacer(Modifier.height(16.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
) {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.action_cancel)) }
Spacer(Modifier.width(8.dp))
OutlinedButton(onClick = onChooseImages) { Text("JPG") }
Spacer(Modifier.width(8.dp))
Button(onClick = onChoosePdf) { Text("PDF") }
}
}
}
}
} }
/** /**
@@ -347,8 +447,28 @@ private fun DocumentationEditContent(
onDeleteSignatureRequested: (signatureId: String) -> Unit, onDeleteSignatureRequested: (signatureId: String) -> Unit,
loadSignatureBitmap: suspend (documentationId: String, fileName: String) -> Bitmap?, loadSignatureBitmap: suspend (documentationId: String, fileName: String) -> Bitmap?,
) { ) {
val listState = rememberLazyListState()
// Änderungswunsch: Beim Aufklappen eines Blocks dorthin scrollen, damit sein erster Eintrag
// sichtbar wird - wichtig bei langen Formularen, wenn ein weiter unten liegender Block
// aufgeklappt wird und sein Inhalt sonst unterhalb des sichtbaren Bereichs läge. Reagiert
// NUR auf tatsächliches Aufklappen (expandedBlockId wechselt zu einer konkreten Block-ID),
// nicht auf das Zuklappen (expandedBlockId wird null) - dort gibt es nichts, wohin gescrollt
// werden müsste. Da jeder Block GENAU EIN LazyColumn-Element ist (Kopfzeile + bei Bedarf
// aufgeklappter Inhalt gemeinsam, s. [BlockAccordionItem]) und die Block-Liste die ERSTEN
// Elemente der LazyColumn sind, landet der Block-Index 1:1 auf dem passenden
// LazyColumn-Index - der Block erscheint dadurch ganz oben im sichtbaren Bereich, sein
// erster Eintrag direkt darunter.
LaunchedEffect(expandedBlockId) {
val targetIndex = blocks.indexOfFirst { it.blockStateId == expandedBlockId }
if (targetIndex >= 0) {
listState.animateScrollToItem(targetIndex)
}
}
LazyColumn( LazyColumn(
modifier = modifier, modifier = modifier,
state = listState,
contentPadding = PaddingValues(16.dp), contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp),
) { ) {
@@ -678,6 +798,11 @@ private fun BlockAccordionItem(
for (item in block.items) { for (item in block.items) {
when { when {
item.isStaticHint -> StaticHintRow(item = item) item.isStaticHint -> StaticHintRow(item = item)
item.inputSpec.isNotEmpty() -> StructuredInputRow(
item = item,
onCheckedChanged = onCheckedChanged,
onFreeTextChanged = onFreeTextChanged,
)
item.isFreeText -> FreeTextCheckItemRow( item.isFreeText -> FreeTextCheckItemRow(
item = item, item = item,
onCheckedChanged = onCheckedChanged, onCheckedChanged = onCheckedChanged,
@@ -751,3 +876,100 @@ private fun StaticHintRow(item: CheckItem) {
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
} }
/**
* Ein Eintrag mit strukturierten Eingabefeldern statt eines einfachen Häkchens (Änderungswunsch,
* z. B. "Von [ ] bis [ ] Uhr" oder "Länge/Breite/Tiefe") - s. [de.medapp.wunddoku.domain.model.InputSpec].
* Anders als bei [FreeTextCheckItemRow] gibt es hier KEINE eigene Checkbox zum Freischalten: die
* Felder sind immer direkt ausfüllbar, `checked` wird automatisch gesetzt, sobald mindestens ein
* Feld einen Wert hat - dieselbe Checkbox-Semantik wie überall sonst (BlockEditedPolicy, PDF-
* Filter), nur ohne zusätzlichen Tap zum "Freischalten" der Felder.
*/
@Composable
private fun StructuredInputRow(
item: CheckItem,
onCheckedChanged: (checkItemStateId: String, checked: Boolean) -> Unit,
onFreeTextChanged: (checkItemStateId: String, text: String) -> Unit,
) {
val spec = remember(item.inputSpec) { InputSpecCodec.decode(item.inputSpec) } ?: return
val values = remember(item.freeTextValue, spec) { spec.decodeValues(item.freeTextValue) }
fun updateValue(fieldIndex: Int, newValue: String) {
val newValues = values.toMutableList().apply { this[fieldIndex] = newValue }
val encoded = spec.encodeValues(newValues)
onFreeTextChanged(item.checkItemStateId, encoded)
onCheckedChanged(item.checkItemStateId, spec.hasAnyValue(encoded))
}
Column(modifier = Modifier.fillMaxWidth()) {
Text(item.label, style = MaterialTheme.typography.bodyMedium)
Spacer(Modifier.height(4.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
spec.fields.forEachIndexed { index, field ->
Box(modifier = Modifier.weight(1f)) {
when (field) {
is InputFieldDef.Number -> OutlinedTextField(
value = values.getOrElse(index) { "" },
onValueChange = { raw ->
var seenComma = false
val filtered = buildString {
for (c in raw) {
when {
c.isDigit() -> append(c)
field.allowDecimal && (c == ',' || c == '.') && !seenComma -> {
append(',')
seenComma = true
}
}
}
}.take(field.maxLength)
updateValue(index, filtered)
},
label = { Text(field.label) },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.fillMaxWidth(),
)
is InputFieldDef.Choice -> ChoiceField(
field = field,
selected = values.getOrElse(index) { "" },
onSelected = { updateValue(index, it) },
)
}
}
}
}
}
}
/** Einfache Auswahlliste (Tippen öffnet ein Dropdown) für [InputFieldDef.Choice]-Felder - bewusst
* ein schlichter Button + [DropdownMenu] statt `ExposedDropdownMenuBox`, um ohne zusätzliche
* experimentelle Material3-APIs auszukommen. */
@Composable
private fun ChoiceField(
field: InputFieldDef.Choice,
selected: String,
onSelected: (String) -> Unit,
) {
var expanded by remember { mutableStateOf(false) }
Box(modifier = Modifier.fillMaxWidth()) {
OutlinedButton(onClick = { expanded = true }, modifier = Modifier.fillMaxWidth()) {
Text(selected.ifBlank { field.label }, maxLines = 1)
}
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
field.options.forEach { option ->
DropdownMenuItem(
text = { Text(option) },
onClick = {
onSelected(option)
expanded = false
},
)
}
}
}
}
@@ -15,6 +15,8 @@ import de.medapp.wunddoku.domain.model.Documentation
import de.medapp.wunddoku.domain.model.WoundCarer import de.medapp.wunddoku.domain.model.WoundCarer
import de.medapp.wunddoku.pdf.PdfExportError import de.medapp.wunddoku.pdf.PdfExportError
import de.medapp.wunddoku.pdf.PdfExportResult import de.medapp.wunddoku.pdf.PdfExportResult
import de.medapp.wunddoku.pdf.PdfToImagesResult
import de.medapp.wunddoku.pdf.toUserMessage
import de.medapp.wunddoku.ui.navigation.Screen import de.medapp.wunddoku.ui.navigation.Screen
import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
@@ -63,6 +65,28 @@ data class DocumentationEditUiState(
* referenziert werden kann, während ein neuer Export läuft. * referenziert werden kann, während ein neuer Export läuft.
*/ */
val lastExportedPdfFile: File? = null, val lastExportedPdfFile: File? = null,
/** true, während PDF-Export + Konvertierung für den Versand laufen - gemeinsamer
* Ladezustand für BEIDE Formate (JPG über [DocumentationEditViewModel.shareAsImages],
* PDF über [DocumentationEditViewModel.shareAsPdf]), da immer zuerst frisch exportiert
* wird. */
val shareInProgress: Boolean = false,
/** Fehlermeldung, falls Export oder Konvertierung fehlgeschlagen sind - ein erfolgreicher
* Export löst dagegen KEINE Meldung aus, sondern direkt [imagesToShare] bzw. [pdfToShare]. */
val shareMessage: String? = null,
/** true, solange die Auswahl "Als JPG oder PDF teilen?" angezeigt wird (Änderungswunsch:
* Format-Auswahl beim Antippen des Teilen-Buttons, bevor irgendetwas exportiert wird). */
val showShareFormatDialog: Boolean = false,
/**
* Frisch erzeugte JPEG-Seiten, bereit zum Teilen (Änderungswunsch: "alle generierten Bilder
* über 'Senden an' direkt verschicken können"). Wird vom Screen per LaunchedEffect
* beobachtet, der daraufhin den System-"Senden an"-Dialog öffnet und anschließend
* [DocumentationEditViewModel.onImageShareHandled] aufruft - das Teilen selbst (Intent/
* Activity-Start) ist bewusst UI-Aufgabe, nicht Sache des ViewModels.
*/
val imagesToShare: List<File>? = null,
/** Analog [imagesToShare], nur für die Variante "als PDF teilen" - Gegenstück
* [DocumentationEditViewModel.onPdfShareHandled]. */
val pdfToShare: File? = null,
) { ) {
val blockCount: Int get() = documentation?.blocks?.size ?: 0 val blockCount: Int get() = documentation?.blocks?.size ?: 0
@@ -357,6 +381,120 @@ class DocumentationEditViewModel(
_uiState.update { it.copy(pdfExportMessage = null) } _uiState.update { it.copy(pdfExportMessage = null) }
} }
/**
* Öffnet die Auswahl "Als JPG oder PDF teilen?" (Änderungswunsch: Format-Auswahl beim
* Antippen des Teilen-Buttons, BEVOR irgendetwas exportiert wird - vorher wurde beim
* Antippen sofort und ausschließlich als JPG geteilt).
*/
fun requestShareFormatChoice() {
_uiState.update { it.copy(showShareFormatDialog = true) }
}
fun dismissShareFormatDialog() {
_uiState.update { it.copy(showShareFormatDialog = false) }
}
/** Nutzer hat sich für "Als JPG teilen" entschieden. */
fun shareAsImages() {
_uiState.update { it.copy(showShareFormatDialog = false) }
exportAndShareAsImages()
}
/** Nutzer hat sich für "Als PDF teilen" entschieden. */
fun shareAsPdf() {
_uiState.update { it.copy(showShareFormatDialog = false) }
exportAndSharePdf()
}
/**
* Exportiert die Dokumentation frisch als PDF und wandelt das Ergebnis anschließend
* seitenweise in JPEG-Bilder um (Änderungswunsch: "PDF zusätzlich als JPEG speichern/senden
* können"). Bewusst ein eigener, vollständiger Export-Lauf statt Wiederverwendung von
* [uiState.lastExportedPdfFile]: so spiegeln die Bilder IMMER den aktuellen Stand wider,
* unabhängig davon, ob/wann zuvor schon einmal "PDF erzeugen" angetippt wurde.
*
* Das eigentliche Teilen (Android-"Senden an"-Dialog) übernimmt NICHT diese Methode, sondern
* der Screen anhand von [DocumentationEditUiState.imagesToShare] - Intent/Activity-Start ist
* UI-Aufgabe (s. [de.medapp.wunddoku.pdf.PdfImageSharer]).
*/
private fun exportAndShareAsImages() {
viewModelScope.launch {
_uiState.update {
it.copy(shareInProgress = true, shareMessage = null, imagesToShare = null)
}
val exportResult = repository.exportPdf(documentationId)
val pdfFile = when (exportResult) {
is PdfExportResult.Success -> exportResult.file
is PdfExportResult.Failure -> {
_uiState.update {
it.copy(
shareInProgress = false,
shareMessage = "PDF-Erzeugung fehlgeschlagen: ${exportResult.error.toUserMessage()}",
)
}
return@launch
}
}
when (val imagesResult = repository.convertPdfToImages(documentationId, pdfFile)) {
is PdfToImagesResult.Success -> _uiState.update {
it.copy(shareInProgress = false, imagesToShare = imagesResult.imageFiles)
}
is PdfToImagesResult.Failure -> _uiState.update {
it.copy(
shareInProgress = false,
shareMessage = "Bildexport fehlgeschlagen: ${imagesResult.toUserMessage()}",
)
}
}
}
}
/**
* Exportiert die Dokumentation frisch als PDF und stellt sie zum Teilen bereit
* (Änderungswunsch: Format-Auswahl "PDF" statt "JPG") - ohne die JPEG-Konvertierung, die
* geteilte Datei ist die PDF selbst. Das eigentliche Teilen übernimmt der Screen anhand von
* [DocumentationEditUiState.pdfToShare] (s. [de.medapp.wunddoku.pdf.PdfSharer]).
*/
private fun exportAndSharePdf() {
viewModelScope.launch {
_uiState.update {
it.copy(shareInProgress = true, shareMessage = null, pdfToShare = null)
}
when (val exportResult = repository.exportPdf(documentationId)) {
is PdfExportResult.Success -> _uiState.update {
it.copy(shareInProgress = false, pdfToShare = exportResult.file)
}
is PdfExportResult.Failure -> _uiState.update {
it.copy(
shareInProgress = false,
shareMessage = "PDF-Erzeugung fehlgeschlagen: ${exportResult.error.toUserMessage()}",
)
}
}
}
}
/** Wird vom Screen aufgerufen, nachdem der "Senden an"-Dialog für [DocumentationEditUiState.imagesToShare]
* geöffnet wurde (erfolgreich oder nicht) - verhindert ein erneutes Öffnen bei der nächsten
* Recomposition, solange keine neuen Bilder erzeugt wurden. */
fun onImageShareHandled() {
_uiState.update { it.copy(imagesToShare = null) }
}
/** Analog [onImageShareHandled], nur für [DocumentationEditUiState.pdfToShare]. */
fun onPdfShareHandled() {
_uiState.update { it.copy(pdfToShare = null) }
}
fun dismissShareMessage() {
_uiState.update { it.copy(shareMessage = null) }
}
private fun PdfExportError.toUserMessage(): String = when (this) { private fun PdfExportError.toUserMessage(): String = when (this) {
PdfExportError.DocumentationNotFound -> "Die Dokumentation wurde nicht gefunden." PdfExportError.DocumentationNotFound -> "Die Dokumentation wurde nicht gefunden."
is PdfExportError.DirectoryCreationFailed -> "Zielverzeichnis konnte nicht angelegt werden." is PdfExportError.DirectoryCreationFailed -> "Zielverzeichnis konnte nicht angelegt werden."
+1
View File
@@ -7,6 +7,7 @@
--> -->
<paths xmlns:android="http://schemas.android.com/apk/res/android"> <paths xmlns:android="http://schemas.android.com/apk/res/android">
<files-path name="pdf_exports" path="pdf/" /> <files-path name="pdf_exports" path="pdf/" />
<files-path name="pdf_images" path="pdf_images/" />
<files-path name="photos" path="photos/" /> <files-path name="photos" path="photos/" />
<files-path name="signatures" path="signatures/" /> <files-path name="signatures" path="signatures/" />
</paths> </paths>
@@ -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("???", "***"))
}
}