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:
parent
61ef56425d
commit
d81acfbb4f
11 changed files with 675 additions and 0 deletions
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
175
app/src/main/java/de/krisenvorrat/app/data/export/PdfExporter.kt
Normal file
175
app/src/main/java/de/krisenvorrat/app/data/export/PdfExporter.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
}
|
||||
}
|
||||
|
|
@ -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"))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue