Fehlerbehandlung

Robuste Behandlung aller bekannten Fehlerfälle.
- Speichermangel behandelt
- Bildfehler behandelt
- PDF-Fehler behandelt
- Datenverlust verhindert
This commit is contained in:
NOrtmann1
2026-10-14 17:14:05 +02:00
parent e9403801d6
commit 489c4fa070
22 changed files with 2634 additions and 83 deletions
@@ -0,0 +1,91 @@
package de.medapp.wunddoku.domain.error
import kotlinx.coroutines.CancellationException
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Assert.fail
import org.junit.Test
import java.io.FileNotFoundException
import java.io.IOException
/**
* Reine JVM-Tests für die Fehlereinordnung (Speichermangel erkennen) und
* [runCatchingCancellable].
*/
class FailureCategoryTest {
// --- Arbeitsspeicher -----------------------------------------------------------------
@Test
fun out_of_memory_error_wird_erkannt() {
assertEquals(FailureCategory.OUT_OF_MEMORY, FailureCategory.of(OutOfMemoryError("Failed to allocate")))
}
@Test
fun out_of_memory_error_wird_auch_als_ursache_erkannt() {
val wrapped = RuntimeException("Export fehlgeschlagen", OutOfMemoryError())
assertEquals(FailureCategory.OUT_OF_MEMORY, FailureCategory.of(wrapped))
}
// --- Speicherplatz -------------------------------------------------------------------
@Test
fun enospc_ioexception_wird_als_speicher_voll_erkannt() {
val e = IOException("write failed: ENOSPC (No space left on device)")
assertEquals(FailureCategory.OUT_OF_STORAGE, FailureCategory.of(e))
}
@Test
fun sqlite_disk_full_meldung_wird_als_speicher_voll_erkannt() {
val e = RuntimeException("database or disk is full (code 13 SQLITE_FULL)")
assertEquals(FailureCategory.OUT_OF_STORAGE, FailureCategory.of(e))
}
@Test
fun erkennung_ist_unabhaengig_von_gross_und_kleinschreibung() {
assertEquals(FailureCategory.OUT_OF_STORAGE, FailureCategory.of(IOException("NO SPACE LEFT ON DEVICE")))
}
@Test
fun speicher_voll_in_tiefer_ursachenkette_wird_erkannt() {
val root = IOException("No space left on device")
val chain = IllegalStateException("a", RuntimeException("b", RuntimeException("c", root)))
assertEquals(FailureCategory.OUT_OF_STORAGE, FailureCategory.of(chain))
}
// --- Sonstige Fehler -----------------------------------------------------------------
@Test
fun gewoehnliche_fehler_sind_other() {
assertEquals(FailureCategory.OTHER, FailureCategory.of(FileNotFoundException("weg")))
assertEquals(FailureCategory.OTHER, FailureCategory.of(IllegalStateException()))
assertEquals(FailureCategory.OTHER, FailureCategory.of(RuntimeException(null as String?)))
}
@Test
fun zyklische_ursachenkette_fuehrt_nicht_zur_endlosschleife() {
val a = RuntimeException("a")
val b = RuntimeException("b", a)
a.initCause(b)
assertEquals(FailureCategory.OTHER, FailureCategory.of(a))
}
// --- runCatchingCancellable ----------------------------------------------------------
@Test
fun runCatchingCancellable_liefert_erfolg_und_fehler_als_result() {
assertEquals(42, runCatchingCancellable { 42 }.getOrNull())
val failure = runCatchingCancellable<Int> { throw IOException("x") }
assertTrue(failure.exceptionOrNull() is IOException)
}
@Test
fun runCatchingCancellable_reicht_coroutine_abbruch_weiter() {
try {
runCatchingCancellable<Unit> { throw CancellationException("abgebrochen") }
fail("CancellationException darf nicht verschluckt werden")
} catch (expected: CancellationException) {
assertEquals("abgebrochen", expected.message)
}
}
}
@@ -0,0 +1,80 @@
package de.medapp.wunddoku.media
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Reine JVM-Tests für [ImageDownsampler.computeInSampleSize] - der Kern der speicherschonenden
* Bilddekodierung (Schutz vor OutOfMemoryError bei großen Kamerafotos) und der Robustheit
* gegenüber ungültigen Abmessungen defekter Bilddateien.
*/
class ImageDownsamplerTest {
private val target = ImageDownsampler.MAX_STORED_DIMENSION_PX
// --- ungültige / defekte Abmessungen -------------------------------------------------
@Test
fun ungueltige_abmessungen_defekter_bilder_liefern_1_statt_zu_werfen() {
assertEquals(1, ImageDownsampler.computeInSampleSize(0, 0, target))
assertEquals(1, ImageDownsampler.computeInSampleSize(-1, -1, target))
assertEquals(1, ImageDownsampler.computeInSampleSize(4000, 0, target))
assertEquals(1, ImageDownsampler.computeInSampleSize(0, 4000, target))
assertEquals(1, ImageDownsampler.computeInSampleSize(4000, 3000, 0))
assertEquals(1, ImageDownsampler.computeInSampleSize(4000, 3000, -5))
}
// --- Bilder, die nicht verkleinert werden müssen -------------------------------------
@Test
fun kleine_bilder_werden_nicht_heruntergerechnet() {
assertEquals(1, ImageDownsampler.computeInSampleSize(100, 100, target))
assertEquals(1, ImageDownsampler.computeInSampleSize(target, target, target))
assertEquals(1, ImageDownsampler.computeInSampleSize(1, 1, target))
}
// --- Große Bilder (Speichermangel vermeiden) -----------------------------------------
@Test
fun grosse_kamerafotos_werden_deutlich_heruntergerechnet() {
// 48 MP ≈ 8000 x 6000
val sample = ImageDownsampler.computeInSampleSize(8000, 6000, target)
assertTrue("erwartet >= 2, war $sample", sample >= 2)
}
@Test
fun ergebnis_ist_immer_eine_zweierpotenz_und_haelt_die_zielgroesse_ein() {
val sizes = listOf(1, 2, 99, 100, 1999, 2000, 2001, 3999, 4000, 4001, 6000, 8000, 12000, 20000, 65535)
for (width in sizes) {
for (height in sizes) {
val sample = ImageDownsampler.computeInSampleSize(width, height, target)
val largest = maxOf(width, height)
assertTrue("Zweierpotenz erwartet bei ${width}x$height, war $sample", sample >= 1 && sample and (sample - 1) == 0)
if (largest > target) {
// Nie unter die Zielgröße dekodieren ...
assertTrue("${width}x$height: dekodiert wäre ${largest / sample}", largest / sample >= target)
// ... aber auch nie mehr als doppelt so groß wie nötig (kein Speicher-Verschwenden).
assertTrue("${width}x$height: dekodiert wäre ${largest / sample}", largest / sample < 2 * target)
} else {
assertEquals(1, sample)
}
}
}
}
@Test
fun extrem_grosse_werte_laufen_nicht_ueber() {
val sample = ImageDownsampler.computeInSampleSize(Int.MAX_VALUE, Int.MAX_VALUE, target)
assertTrue(sample >= 1)
assertTrue(sample and (sample - 1) == 0)
}
@Test
fun thumbnail_ziel_ist_kleiner_als_speicherziel() {
val full = ImageDownsampler.computeInSampleSize(2000, 1500, target)
val thumb = ImageDownsampler.computeInSampleSize(2000, 1500, ImageDownsampler.THUMBNAIL_MAX_DIMENSION_PX)
assertTrue(thumb > full)
}
}
@@ -0,0 +1,106 @@
package de.medapp.wunddoku.pdf
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Reine JVM-Tests für die Fehlermeldungen des PDF-Bereichs: jede Fehlerart hat eine
* verständliche deutsche Meldung, und technische Details (Pfade, Exception-Texte - können
* Patientennamen enthalten) gelangen nie in die Anzeige.
*/
class ErrorMessagesTest {
private val sensitive = "/data/user/0/de.medapp.wunddoku/files/pdf/2026-08-19_Max_Mustermann_1980-03-14.pdf"
private val exportErrors: List<PdfExportError> = listOf(
PdfExportError.DocumentationNotFound,
PdfExportError.DirectoryCreationFailed(sensitive),
PdfExportError.WriteFailed(sensitive),
PdfExportError.Unexpected(sensitive),
PdfExportError.OutOfMemory,
PdfExportError.InsufficientStorage,
)
private val openFailures: List<PdfOpenResult.Failure> = listOf(
PdfOpenResult.Failure.FileNotFound,
PdfOpenResult.Failure.UriResolutionFailed(sensitive),
PdfOpenResult.Failure.NoViewerAvailable,
PdfOpenResult.Failure.Unexpected(sensitive),
)
private val shareFailures: List<PdfShareResult.Failure> = listOf(
PdfShareResult.Failure.UriResolutionFailed(sensitive),
PdfShareResult.Failure.NoAppAvailable,
PdfShareResult.Failure.Unexpected(sensitive),
)
private val imageShareFailures: List<PdfImageShareResult.Failure> = listOf(
PdfImageShareResult.Failure.NoImages,
PdfImageShareResult.Failure.UriResolutionFailed(sensitive),
PdfImageShareResult.Failure.NoAppAvailable,
PdfImageShareResult.Failure.Unexpected(sensitive),
)
private val imageConversionFailures: List<PdfToImagesResult.Failure> = listOf(
PdfToImagesResult.Failure.FileNotFound,
PdfToImagesResult.Failure.EmptyDocument,
PdfToImagesResult.Failure.Unexpected(sensitive),
PdfToImagesResult.Failure.OutOfMemory,
PdfToImagesResult.Failure.InsufficientStorage,
)
private fun assertSafe(message: String) {
assertTrue("Meldung darf nicht leer sein", message.isNotBlank())
assertFalse("Meldung enthält Pfad/Patientendaten: $message", message.contains("Mustermann"))
assertFalse("Meldung enthält Pfad: $message", message.contains("/data/"))
assertFalse("Meldung enthält Dateiendung/Pfad: $message", message.contains(".pdf"))
}
@Test
fun alle_pdf_export_fehler_haben_eine_sichere_meldung() {
exportErrors.forEach { assertSafe(it.toUserMessage()) }
}
@Test
fun alle_oeffnen_fehler_haben_eine_sichere_meldung() {
openFailures.forEach { assertSafe(it.toUserMessage()) }
}
@Test
fun alle_pdf_teilen_fehler_haben_eine_sichere_meldung() {
shareFailures.forEach { assertSafe(it.toUserMessage()) }
}
@Test
fun alle_bilder_teilen_fehler_haben_eine_sichere_meldung() {
imageShareFailures.forEach { assertSafe(it.toUserMessage()) }
}
@Test
fun alle_bildkonvertierungs_fehler_haben_eine_sichere_meldung() {
imageConversionFailures.forEach { assertSafe(it.toUserMessage()) }
}
@Test
fun speichermangel_hat_eine_eigene_aussagekraeftige_meldung() {
assertNotEquals(PdfExportError.OutOfMemory.toUserMessage(), PdfExportError.InsufficientStorage.toUserMessage())
assertNotEquals(
PdfExportError.OutOfMemory.toUserMessage(),
PdfExportError.Unexpected("x").toUserMessage(),
)
assertNotEquals(
PdfToImagesResult.Failure.OutOfMemory.toUserMessage(),
PdfToImagesResult.Failure.InsufficientStorage.toUserMessage(),
)
assertTrue(PdfExportError.InsufficientStorage.toUserMessage().contains("Speicher"))
assertTrue(PdfExportError.OutOfMemory.toUserMessage().contains("Arbeitsspeicher"))
}
@Test
fun unterschiedliche_fehlerarten_erhalten_unterschiedliche_meldungen() {
val messages = exportErrors.map { it.toUserMessage() }
assertTrue(messages.toSet().size == messages.size)
}
}