feat(export): CSV- und PDF-Export mit Share-Intent

- CsvExporter: UTF-8 BOM, Semikolon-Separator, DE-Locale, aufgelöste
  Kategorie-/Lagerort-Namen, CSV-Escaping für Sonderzeichen
- PdfExporter: Android PdfDocument API, A4-Format, nach Kategorie
  gruppiert, Tabellenheader, Gesamtwert, automatischer Seitenumbruch
- ImportExportRepository: +exportToCsv(), +exportToPdf(File)
- SettingsViewModel: +exportCsv(), +exportPdf() mit FileProvider-URI
- ShareContent: +Csv(fileUri), +Pdf(fileUri) Varianten
- SettingsScreen: CSV- und PDF-Buttons, Share-Intent für text/csv
  und application/pdf
- 15 neue Tests: CsvExporterTest (7), CSV-Repository-Tests (5),
  ViewModel-Tests für CSV/PDF (4 = 2 success + 2 failure)
- Alle 282 Tests grün

Closes #81
This commit is contained in:
Jens Reinemann 2026-05-17 04:13:14 +02:00
parent 61ef56425d
commit d81acfbb4f
11 changed files with 675 additions and 0 deletions

View file

@ -0,0 +1,69 @@
package de.krisenvorrat.app.data.export
import de.krisenvorrat.app.data.db.entity.CategoryEntity
import de.krisenvorrat.app.data.db.entity.ItemEntity
import de.krisenvorrat.app.data.db.entity.LocationEntity
import java.time.format.DateTimeFormatter
import java.util.Locale
internal object CsvExporter {
private const val BOM = "\uFEFF"
private const val SEPARATOR = ";"
private val DATE_FORMATTER = DateTimeFormatter.ofPattern("dd.MM.yyyy", Locale.GERMAN)
private val HEADER = listOf(
"Name", "Kategorie", "Menge", "Einheit", "Stückpreis",
"kcal/kg", "MHD", "Lagerort", "Notizen"
)
fun export(
items: List<ItemEntity>,
categories: List<CategoryEntity>,
locations: List<LocationEntity>
): String {
val categoryMap = categories.associate { it.id to it.name }
val locationMap = locations.associate { it.id to it.name }
val sb = StringBuilder()
sb.append(BOM)
sb.appendLine(HEADER.joinToString(SEPARATOR))
for (item in items) {
val row = listOf(
escapeCsv(item.name),
escapeCsv(categoryMap[item.categoryId] ?: ""),
formatQuantity(item.quantity),
escapeCsv(item.unit),
formatPrice(item.unitPrice),
item.kcalPerKg?.toString() ?: "",
item.expiryDate?.format(DATE_FORMATTER) ?: "",
escapeCsv(locationMap[item.locationId] ?: ""),
escapeCsv(item.notes)
)
sb.appendLine(row.joinToString(SEPARATOR))
}
return sb.toString()
}
private fun escapeCsv(value: String): String {
return if (value.contains(SEPARATOR) || value.contains("\"") || value.contains("\n")) {
"\"${value.replace("\"", "\"\"")}\""
} else {
value
}
}
private fun formatQuantity(value: Double): String {
return if (value == value.toLong().toDouble()) {
value.toLong().toString()
} else {
String.format(Locale.GERMAN, "%.1f", value)
}
}
private fun formatPrice(value: Double): String {
return String.format(Locale.GERMAN, "%.2f", value)
}
}

View file

@ -21,6 +21,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import java.io.File
import java.time.LocalDate
import java.time.format.DateTimeFormatter
import java.util.Locale
@ -126,6 +127,20 @@ internal class ImportExportRepositoryImpl @Inject constructor(
}
}
override suspend fun exportToCsv(): String = withContext(Dispatchers.IO) {
val categories = categoryDao.getAll().first()
val locations = locationDao.getAll().first()
val items = itemDao.getAll().first()
CsvExporter.export(items, categories, locations)
}
override suspend fun exportToPdf(file: File): Unit = withContext(Dispatchers.IO) {
val categories = categoryDao.getAll().first()
val locations = locationDao.getAll().first()
val items = itemDao.getAll().first()
PdfExporter.export(file, items, categories, locations)
}
override suspend fun exportToMarkdown(): String = withContext(Dispatchers.IO) {
val categories = categoryDao.getAll().first()
val locations = locationDao.getAll().first()

View file

@ -0,0 +1,175 @@
package de.krisenvorrat.app.data.export
import android.graphics.Paint
import android.graphics.Typeface
import android.graphics.pdf.PdfDocument
import de.krisenvorrat.app.data.db.entity.CategoryEntity
import de.krisenvorrat.app.data.db.entity.ItemEntity
import de.krisenvorrat.app.data.db.entity.LocationEntity
import java.io.File
import java.io.FileOutputStream
import java.time.LocalDate
import java.time.format.DateTimeFormatter
import java.util.Locale
internal object PdfExporter {
private const val PAGE_WIDTH = 595 // A4
private const val PAGE_HEIGHT = 842 // A4
private const val MARGIN_LEFT = 40f
private const val MARGIN_TOP = 50f
private const val MARGIN_BOTTOM = 40f
private const val LINE_HEIGHT = 18f
private const val HEADER_HEIGHT = 28f
private val DATE_FORMATTER = DateTimeFormatter.ofPattern("dd.MM.yyyy", Locale.GERMAN)
fun export(
file: File,
items: List<ItemEntity>,
categories: List<CategoryEntity>,
locations: List<LocationEntity>
) {
val locationMap = locations.associate { it.id to it.name }
val document = PdfDocument()
var pageNumber = 1
var pageInfo = PdfDocument.PageInfo.Builder(PAGE_WIDTH, PAGE_HEIGHT, pageNumber).create()
var page = document.startPage(pageInfo)
var canvas = page.canvas
var yPos = MARGIN_TOP
val titlePaint = Paint().apply {
textSize = 18f
typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD)
isAntiAlias = true
}
val categoryPaint = Paint().apply {
textSize = 14f
typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD)
isAntiAlias = true
}
val headerPaint = Paint().apply {
textSize = 10f
typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD)
isAntiAlias = true
}
val textPaint = Paint().apply {
textSize = 10f
isAntiAlias = true
}
val smallPaint = Paint().apply {
textSize = 8f
color = 0xFF666666.toInt()
isAntiAlias = true
}
// Title
canvas.drawText("Krisenvorrat Inventar", MARGIN_LEFT, yPos, titlePaint)
yPos += 8f
canvas.drawText(
"Erstellt am ${LocalDate.now().format(DATE_FORMATTER)}",
MARGIN_LEFT, yPos + LINE_HEIGHT, smallPaint
)
yPos += HEADER_HEIGHT + LINE_HEIGHT
// Column positions
val colName = MARGIN_LEFT
val colQty = 250f
val colUnit = 300f
val colExpiry = 350f
val colLocation = 420f
val colPrice = 500f
var totalValue = 0.0
for (category in categories) {
val categoryItems = items.filter { it.categoryId == category.id }
if (categoryItems.isEmpty()) continue
// Check if we need a new page for category header + at least one item
if (yPos + HEADER_HEIGHT + LINE_HEIGHT * 2 > PAGE_HEIGHT - MARGIN_BOTTOM) {
document.finishPage(page)
pageNumber++
pageInfo = PdfDocument.PageInfo.Builder(PAGE_WIDTH, PAGE_HEIGHT, pageNumber).create()
page = document.startPage(pageInfo)
canvas = page.canvas
yPos = MARGIN_TOP
}
// Category header
yPos += 6f
canvas.drawText(category.name, MARGIN_LEFT, yPos, categoryPaint)
yPos += HEADER_HEIGHT
// Table header
canvas.drawText("Name", colName, yPos, headerPaint)
canvas.drawText("Menge", colQty, yPos, headerPaint)
canvas.drawText("Einheit", colUnit, yPos, headerPaint)
canvas.drawText("MHD", colExpiry, yPos, headerPaint)
canvas.drawText("Lagerort", colLocation, yPos, headerPaint)
canvas.drawText("Preis", colPrice, yPos, headerPaint)
yPos += 4f
canvas.drawLine(MARGIN_LEFT, yPos, PAGE_WIDTH - MARGIN_LEFT, yPos, textPaint)
yPos += LINE_HEIGHT
for (item in categoryItems) {
if (yPos + LINE_HEIGHT > PAGE_HEIGHT - MARGIN_BOTTOM) {
document.finishPage(page)
pageNumber++
pageInfo = PdfDocument.PageInfo.Builder(PAGE_WIDTH, PAGE_HEIGHT, pageNumber).create()
page = document.startPage(pageInfo)
canvas = page.canvas
yPos = MARGIN_TOP
}
val itemValue = item.quantity * item.unitPrice
totalValue += itemValue
canvas.drawText(truncate(item.name, 30), colName, yPos, textPaint)
canvas.drawText(formatQuantity(item.quantity), colQty, yPos, textPaint)
canvas.drawText(item.unit, colUnit, yPos, textPaint)
canvas.drawText(item.expiryDate?.format(DATE_FORMATTER) ?: "", colExpiry, yPos, textPaint)
canvas.drawText(truncate(locationMap[item.locationId] ?: "", 15), colLocation, yPos, textPaint)
canvas.drawText(formatPrice(itemValue), colPrice, yPos, textPaint)
yPos += LINE_HEIGHT
}
}
// Total value
yPos += LINE_HEIGHT
if (yPos + LINE_HEIGHT * 2 > PAGE_HEIGHT - MARGIN_BOTTOM) {
document.finishPage(page)
pageNumber++
pageInfo = PdfDocument.PageInfo.Builder(PAGE_WIDTH, PAGE_HEIGHT, pageNumber).create()
page = document.startPage(pageInfo)
canvas = page.canvas
yPos = MARGIN_TOP
}
canvas.drawLine(MARGIN_LEFT, yPos, PAGE_WIDTH - MARGIN_LEFT, yPos, textPaint)
yPos += LINE_HEIGHT
canvas.drawText("Gesamtwert: ${formatPrice(totalValue)}", MARGIN_LEFT, yPos, categoryPaint)
document.finishPage(page)
FileOutputStream(file).use { out ->
document.writeTo(out)
}
document.close()
}
private fun truncate(text: String, maxLength: Int): String {
return if (text.length > maxLength) text.take(maxLength - 1) + "" else text
}
private fun formatQuantity(value: Double): String {
return if (value == value.toLong().toDouble()) {
value.toLong().toString()
} else {
String.format(Locale.GERMAN, "%.1f", value)
}
}
private fun formatPrice(value: Double): String {
return String.format(Locale.GERMAN, "%.2f", value)
}
}

View file

@ -1,11 +1,14 @@
package de.krisenvorrat.app.domain.repository
import de.krisenvorrat.shared.model.InventoryDto
import java.io.File
internal interface ImportExportRepository {
suspend fun exportToJson(): String
suspend fun importFromJson(json: String): Result<Unit>
suspend fun exportToMarkdown(): String
suspend fun exportToCsv(): String
suspend fun exportToPdf(file: File)
suspend fun exportToInventoryDto(): InventoryDto
suspend fun importFromInventoryDto(dto: InventoryDto): Result<Unit>
}

View file

@ -66,6 +66,20 @@ internal fun SettingsScreen(
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
}
is ShareContent.Csv -> {
Intent(Intent.ACTION_SEND).apply {
type = "text/csv"
putExtra(Intent.EXTRA_STREAM, content.fileUri)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
}
is ShareContent.Pdf -> {
Intent(Intent.ACTION_SEND).apply {
type = "application/pdf"
putExtra(Intent.EXTRA_STREAM, content.fileUri)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
}
is ShareContent.Markdown -> {
Intent(Intent.ACTION_SEND).apply {
type = "text/plain"
@ -188,6 +202,26 @@ internal fun SettingsScreen(
Spacer(modifier = Modifier.height(8.dp))
OutlinedButton(
onClick = viewModel::exportCsv,
enabled = !uiState.isExporting,
modifier = Modifier.fillMaxWidth()
) {
Text("Daten exportieren (CSV)")
}
Spacer(modifier = Modifier.height(8.dp))
OutlinedButton(
onClick = viewModel::exportPdf,
enabled = !uiState.isExporting,
modifier = Modifier.fillMaxWidth()
) {
Text("Inventarliste (PDF)")
}
Spacer(modifier = Modifier.height(8.dp))
OutlinedButton(
onClick = viewModel::exportMarkdown,
enabled = !uiState.isExporting,

View file

@ -259,6 +259,47 @@ internal class SettingsViewModel @Inject constructor(
}
}
fun exportCsv() {
viewModelScope.launch {
_uiState.update { it.copy(isExporting = true, exportError = null) }
try {
val csv = importExportRepository.exportToCsv()
val cacheDir = File(context.cacheDir, "exports")
cacheDir.mkdirs()
val file = File(cacheDir, "krisenvorrat_export.csv")
file.writeText(csv)
val uri = FileProvider.getUriForFile(
context,
"${context.packageName}.fileprovider",
file
)
_uiState.update { it.copy(isExporting = false, shareContent = ShareContent.Csv(uri)) }
} catch (e: Exception) {
_uiState.update { it.copy(isExporting = false, exportError = "Export fehlgeschlagen") }
}
}
}
fun exportPdf() {
viewModelScope.launch {
_uiState.update { it.copy(isExporting = true, exportError = null) }
try {
val cacheDir = File(context.cacheDir, "exports")
cacheDir.mkdirs()
val file = File(cacheDir, "krisenvorrat_inventar.pdf")
importExportRepository.exportToPdf(file)
val uri = FileProvider.getUriForFile(
context,
"${context.packageName}.fileprovider",
file
)
_uiState.update { it.copy(isExporting = false, shareContent = ShareContent.Pdf(uri)) }
} catch (e: Exception) {
_uiState.update { it.copy(isExporting = false, exportError = "Export fehlgeschlagen") }
}
}
}
fun onShareHandled() {
_uiState.update { it.copy(shareContent = null) }
}

View file

@ -4,5 +4,7 @@ import android.net.Uri
internal sealed interface ShareContent {
data class Json(val fileUri: Uri) : ShareContent
data class Csv(val fileUri: Uri) : ShareContent
data class Pdf(val fileUri: Uri) : ShareContent
data class Markdown(val text: String) : ShareContent
}

View file

@ -0,0 +1,136 @@
package de.krisenvorrat.app.data.export
import de.krisenvorrat.app.data.db.entity.CategoryEntity
import de.krisenvorrat.app.data.db.entity.LocationEntity
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import java.time.LocalDate
class CsvExporterTest {
@Test
fun test_export_withItems_producesCorrectCsv() {
// Given
val categories = listOf(CategoryEntity(id = 1, name = "Lebensmittel"))
val locations = listOf(LocationEntity(id = 1, name = "Keller"))
val items = listOf(
buildItemEntity("item1").copy(
name = "Konserve",
quantity = 5.0,
unit = "Stk",
unitPrice = 1.50,
kcalPerKg = 800,
expiryDate = LocalDate.of(2027, 3, 15),
locationId = 1
)
)
// When
val csv = CsvExporter.export(items, categories, locations)
// Then
assertTrue(csv.startsWith("\uFEFF"))
val lines = csv.lines().filter { it.isNotBlank() }
assertEquals(2, lines.size)
assertEquals("\uFEFFName;Kategorie;Menge;Einheit;Stückpreis;kcal/kg;MHD;Lagerort;Notizen", lines[0])
assertEquals("Konserve;Lebensmittel;5;Stk;1,50;800;15.03.2027;Keller;", lines[1])
}
@Test
fun test_export_withSemicolonInField_escapesCorrectly() {
// Given
val categories = listOf(CategoryEntity(id = 1, name = "Test"))
val locations = listOf(LocationEntity(id = 1, name = "Keller"))
val items = listOf(
buildItemEntity("item1").copy(name = "Reis; Langkorn")
)
// When
val csv = CsvExporter.export(items, categories, locations)
// Then
assertTrue(csv.contains("\"Reis; Langkorn\""))
}
@Test
fun test_export_withQuoteInField_doublesQuotes() {
// Given
val categories = listOf(CategoryEntity(id = 1, name = "Test"))
val locations = listOf(LocationEntity(id = 1, name = "Keller"))
val items = listOf(
buildItemEntity("item1").copy(notes = "Marke \"Premium\"")
)
// When
val csv = CsvExporter.export(items, categories, locations)
// Then
assertTrue(csv.contains("\"Marke \"\"Premium\"\"\""))
}
@Test
fun test_export_withNoExpiryDate_emptyField() {
// Given
val categories = listOf(CategoryEntity(id = 1, name = "Test"))
val locations = listOf(LocationEntity(id = 1, name = "Keller"))
val items = listOf(
buildItemEntity("item1").copy(expiryDate = null)
)
// When
val csv = CsvExporter.export(items, categories, locations)
// Then
val dataLine = csv.lines()[1]
val fields = dataLine.split(";")
// MHD is the 7th field (index 6)
assertEquals("", fields[6])
}
@Test
fun test_export_withNoItems_headerOnly() {
// Given
val csv = CsvExporter.export(emptyList(), emptyList(), emptyList())
// Then
assertTrue(csv.startsWith("\uFEFF"))
val lines = csv.lines().filter { it.isNotBlank() }
assertEquals(1, lines.size)
}
@Test
fun test_export_withUnknownCategory_emptyName() {
// Given
val categories = listOf(CategoryEntity(id = 1, name = "Lebensmittel"))
val locations = listOf(LocationEntity(id = 1, name = "Keller"))
val items = listOf(
buildItemEntity("item1").copy(categoryId = 99)
)
// When
val csv = CsvExporter.export(items, categories, locations)
// Then
val dataLine = csv.lines()[1]
val fields = dataLine.split(";")
// Category is the 2nd field (index 1)
assertEquals("", fields[1])
}
@Test
fun test_export_withFractionalQuantity_usesGermanComma() {
// Given
val categories = listOf(CategoryEntity(id = 1, name = "Test"))
val locations = listOf(LocationEntity(id = 1, name = "Keller"))
val items = listOf(
buildItemEntity("item1").copy(quantity = 2.5)
)
// When
val csv = CsvExporter.export(items, categories, locations)
// Then
assertTrue(csv.contains("2,5"))
}
}

View file

@ -272,4 +272,110 @@ class ImportExportRepositoryImplTest {
// Then Server-Name wird übernommen, da Server-Timestamp neuer ist
assertEquals("VomServer", itemDao.getItems().first { it.id == "item2" }.name)
}
// --- CSV Export Tests ---
@Test
fun test_exportToCsv_withItems_producesValidCsv() = runBlocking {
// Given
val categoryDao = FakeCategoryDao()
val locationDao = FakeLocationDao()
val itemDao = FakeItemDao()
categoryDao.upsertAll(listOf(CategoryEntity(id = 1, name = "Lebensmittel")))
locationDao.upsertAll(listOf(LocationEntity(id = 1, name = "Keller")))
itemDao.upsertAll(listOf(
buildItemEntity("item1").copy(
name = "Konserve",
quantity = 5.0,
unit = "Stk",
unitPrice = 1.50,
expiryDate = LocalDate.of(2027, 3, 15),
locationId = 1
)
))
val repository = buildRepository(categoryDao, locationDao, itemDao)
// When
val csv = repository.exportToCsv()
// Then
assertTrue(csv.startsWith("\uFEFF"))
assertTrue(csv.contains("Name;Kategorie;Menge;Einheit;Stückpreis;kcal/kg;MHD;Lagerort;Notizen"))
assertTrue(csv.contains("Konserve;Lebensmittel;5;Stk;1,50;;15.03.2027;Keller;"))
}
@Test
fun test_exportToCsv_withSemicolonInName_escapesWithQuotes() = runBlocking {
// Given
val categoryDao = FakeCategoryDao()
val locationDao = FakeLocationDao()
val itemDao = FakeItemDao()
categoryDao.upsertAll(listOf(CategoryEntity(id = 1, name = "Lebensmittel")))
locationDao.upsertAll(listOf(LocationEntity(id = 1, name = "Keller")))
itemDao.upsertAll(listOf(
buildItemEntity("item1").copy(name = "Reis; Langkorn")
))
val repository = buildRepository(categoryDao, locationDao, itemDao)
// When
val csv = repository.exportToCsv()
// Then
assertTrue(csv.contains("\"Reis; Langkorn\""))
}
@Test
fun test_exportToCsv_withFractionalQuantity_usesGermanLocale() = runBlocking {
// Given
val categoryDao = FakeCategoryDao()
val locationDao = FakeLocationDao()
val itemDao = FakeItemDao()
categoryDao.upsertAll(listOf(CategoryEntity(id = 1, name = "Lebensmittel")))
locationDao.upsertAll(listOf(LocationEntity(id = 1, name = "Keller")))
itemDao.upsertAll(listOf(
buildItemEntity("item1").copy(name = "Reis", quantity = 2.5, unit = "kg")
))
val repository = buildRepository(categoryDao, locationDao, itemDao)
// When
val csv = repository.exportToCsv()
// Then
assertTrue(csv.contains("2,5"))
}
@Test
fun test_exportToCsv_empty_producesHeaderOnly() = runBlocking {
// Given
val repository = buildRepository()
// When
val csv = repository.exportToCsv()
// Then
assertTrue(csv.startsWith("\uFEFF"))
val lines = csv.trim().lines()
assertEquals(1, lines.size)
assertTrue(lines[0].contains("Name;Kategorie"))
}
@Test
fun test_exportToCsv_withKcalPerKg_includesValue() = runBlocking {
// Given
val categoryDao = FakeCategoryDao()
val locationDao = FakeLocationDao()
val itemDao = FakeItemDao()
categoryDao.upsertAll(listOf(CategoryEntity(id = 1, name = "Lebensmittel")))
locationDao.upsertAll(listOf(LocationEntity(id = 1, name = "Keller")))
itemDao.upsertAll(listOf(
buildItemEntity("item1").copy(name = "Reis", kcalPerKg = 3500)
))
val repository = buildRepository(categoryDao, locationDao, itemDao)
// When
val csv = repository.exportToCsv()
// Then
assertTrue(csv.contains("3500"))
}
}

View file

@ -260,6 +260,8 @@ private class FakeImportExportRepository : ImportExportRepository {
override suspend fun exportToJson(): String = "{}"
override suspend fun importFromJson(json: String): Result<Unit> = Result.success(Unit)
override suspend fun exportToMarkdown(): String = ""
override suspend fun exportToCsv(): String = ""
override suspend fun exportToPdf(file: java.io.File) {}
override suspend fun exportToInventoryDto(): InventoryDto =
InventoryDto(categories = emptyList(), locations = emptyList(), items = emptyList(), settings = emptyList())
override suspend fun importFromInventoryDto(dto: InventoryDto): Result<Unit> = Result.success(Unit)

View file

@ -312,6 +312,87 @@ class SettingsViewModelTest {
assertEquals("Export fehlgeschlagen", state.exportError)
}
@Test
fun test_exportCsv_success_emitsShareContentWithUri() = runTest(testDispatcher) {
// Given
fakeImportExportRepository.csvResult = "\uFEFFName;Kategorie\nReis;Lebensmittel\n"
mockkStatic(FileProvider::class)
val mockUri = mockk<android.net.Uri>()
every { FileProvider.getUriForFile(any(), any(), any()) } returns mockUri
viewModel = createViewModel()
advanceUntilIdle()
// When
viewModel.exportCsv()
advanceUntilIdle()
// Then
val state = viewModel.uiState.value
assertFalse(state.isExporting)
assertNotNull(state.shareContent)
assertTrue(state.shareContent is ShareContent.Csv)
assertEquals(mockUri, (state.shareContent as ShareContent.Csv).fileUri)
unmockkStatic(FileProvider::class)
}
@Test
fun test_exportCsv_failure_setsExportError() = runTest(testDispatcher) {
// Given
fakeImportExportRepository.shouldThrow = true
viewModel = createViewModel()
advanceUntilIdle()
// When
viewModel.exportCsv()
advanceUntilIdle()
// Then
val state = viewModel.uiState.value
assertFalse(state.isExporting)
assertNull(state.shareContent)
assertEquals("Export fehlgeschlagen", state.exportError)
}
@Test
fun test_exportPdf_success_emitsShareContentWithUri() = runTest(testDispatcher) {
// Given
mockkStatic(FileProvider::class)
val mockUri = mockk<android.net.Uri>()
every { FileProvider.getUriForFile(any(), any(), any()) } returns mockUri
viewModel = createViewModel()
advanceUntilIdle()
// When
viewModel.exportPdf()
advanceUntilIdle()
// Then
val state = viewModel.uiState.value
assertFalse(state.isExporting)
assertNotNull(state.shareContent)
assertTrue(state.shareContent is ShareContent.Pdf)
assertEquals(mockUri, (state.shareContent as ShareContent.Pdf).fileUri)
unmockkStatic(FileProvider::class)
}
@Test
fun test_exportPdf_failure_setsExportError() = runTest(testDispatcher) {
// Given
fakeImportExportRepository.shouldThrow = true
viewModel = createViewModel()
advanceUntilIdle()
// When
viewModel.exportPdf()
advanceUntilIdle()
// Then
val state = viewModel.uiState.value
assertFalse(state.isExporting)
assertNull(state.shareContent)
assertEquals("Export fehlgeschlagen", state.exportError)
}
@Test
fun test_onShareHandled_clearsShareContent() = runTest(testDispatcher) {
// Given
@ -716,6 +797,7 @@ private class FakeSettingsRepository : SettingsRepository {
private class FakeImportExportRepository : ImportExportRepository {
var jsonResult = """{"version":1,"categories":[],"locations":[],"items":[],"settings":[]}"""
var markdownResult = "# Krisenvorrat Inventar\n"
var csvResult = "\uFEFFName;Kategorie;Menge;Einheit;Stückpreis;kcal/kg;MHD;Lagerort;Notizen\n"
var shouldThrow = false
var importShouldFail = false
var inventoryDto = InventoryDto(
@ -740,6 +822,16 @@ private class FakeImportExportRepository : ImportExportRepository {
return markdownResult
}
override suspend fun exportToCsv(): String {
if (shouldThrow) throw RuntimeException("Test error")
return csvResult
}
override suspend fun exportToPdf(file: File) {
if (shouldThrow) throw RuntimeException("Test error")
file.writeText("fake-pdf")
}
override suspend fun exportToInventoryDto(): InventoryDto {
if (shouldThrow) throw RuntimeException("Test error")
return inventoryDto