Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
package com.devil.phoenixproject.util

import android.content.Context
import android.net.Uri
import androidx.test.core.app.ApplicationProvider
import com.devil.phoenixproject.testutil.FakeWorkoutRepository
import java.io.IOException
import java.io.InputStream
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertTrue
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.Shadows.shadowOf
import org.robolectric.annotation.Config

/**
* [CancellationException] extends [Exception], so [AndroidCsvImporter] must rethrow it
* and stop writing later rows. A real read or save failure still comes back as
* [CsvImportResult].
*/
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [28])
class AndroidCsvImporterCancellationTest {
private val context: Context = ApplicationProvider.getApplicationContext()

@Test
fun importFromCsv_rethrowsCancellationAndDoesNotWriteLaterRows() = runTest {
val cancellation = CancellationException("import scope cancelled")
val repository = FakeWorkoutRepository().apply {
beforeSaveSession = { session ->
if (session.exerciseName == "Squat") throw cancellation
}
}
register(HISTORY_URI, historyCsv())

val thrown = assertFailsWith<CancellationException> {
importer(repository).importFromCsv(HISTORY_URI, PROFILE_ID)
}

assertEquals(cancellation.message, thrown.message)
assertEquals(listOf("Bench Press"), savedNames(repository))
assertEquals(listOf("Bench Press", "Squat"), attemptedNames(repository))
assertEquals(PROFILE_ID, repository.allSessions().single().profileId)
}

@Test
fun importFromCsv_rethrowsCancellationFromTheFileRead() = runTest {
val cancellation = CancellationException("left screen")
val repository = FakeWorkoutRepository()
register(HISTORY_URI, ThrowingStream(cancellation))

val thrown = assertFailsWith<CancellationException> {
importer(repository).importFromCsv(HISTORY_URI, PROFILE_ID)
}

assertEquals(cancellation.message, thrown.message)
assertTrue(repository.saveSessionAttempts.isEmpty())
}

@Test
fun importFromCsv_reportsSaveFailureAndContinues() = runTest {
val repository = FakeWorkoutRepository().apply {
beforeSaveSession = { session ->
if (session.exerciseName == "Squat") throw IOException("disk full")
}
}
register(HISTORY_URI, historyCsv())

val result = importer(repository).importFromCsv(HISTORY_URI, PROFILE_ID)

assertEquals(2, result.imported)
assertEquals(0, result.skipped)
assertEquals(1, result.failed)
assertEquals(listOf("Failed to save session (Squat): disk full"), result.errors)
assertEquals(listOf("Bench Press", "Row"), savedNames(repository))
}

@Test
fun importFromCsv_reportsUnreadableFile() = runTest {
val repository = FakeWorkoutRepository()
register(HISTORY_URI, ThrowingStream(IOException("denied")))

val result = importer(repository).importFromCsv(HISTORY_URI, PROFILE_ID)

assertEquals(
CsvImportResult(imported = 0, skipped = 0, failed = 0, errors = listOf("Import failed: denied")),
result,
)
assertTrue(repository.saveSessionAttempts.isEmpty())
}

private fun importer(repository: FakeWorkoutRepository) = AndroidCsvImporter(context, repository)

private fun register(uri: String, stream: InputStream) {
shadowOf(context.contentResolver).registerInputStream(Uri.parse(uri), stream)
}

private fun savedNames(repository: FakeWorkoutRepository): List<String> =
repository.allSessions().map { it.exerciseName.orEmpty() }

private fun attemptedNames(repository: FakeWorkoutRepository): List<String> =
repository.saveSessionAttempts.map { it.exerciseName.orEmpty() }

private fun historyCsv(): InputStream = """
Date,Time,Exercise,Mode,Target Reps,Warmup Reps,Working Reps,Total Reps,Weight,Progression,Duration (s),Just Lift,Eccentric Load

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SUGGESTION: The history header is hardcoded although PhoenixCsvCodec.HISTORY_HEADER already exists

PhoenixCsvCodec is in the same package, so the fixture can use ${PhoenixCsvCodec.HISTORY_HEADER} instead of the duplicated literal and stay in step with the codec. Optional - the literal currently matches.

2026-03-10,09:00:00,Bench Press,OldSchool,10,0,10,10,80.0,0,45,No,100
2026-03-11,09:00:00,Squat,OldSchool,8,0,8,8,100.0,0,60,No,100
2026-03-12,09:00:00,Row,OldSchool,8,0,8,8,40.0,0,30,No,100
""".trimIndent().byteInputStream()

private class ThrowingStream(private val error: Exception) : InputStream() {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SUGGESTION: ThrowingStream is a verbatim copy of the one in UriContentReaderCancellationTest.kt:132

Both are the same 4-line private helper. Move it to shared/src/androidHostTest/kotlin/com/devil/phoenixproject/testutil/ (which already holds the Android-only test helpers) and use it from both tests. Optional cleanup - the duplication is harmless.

override fun read(): Int = throw error

override fun read(b: ByteArray, off: Int, len: Int): Int = throw error
}

private companion object {
const val HISTORY_URI = "content://files/history.csv"
const val PROFILE_ID = "profile-a"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,9 @@ class AndroidCsvImporter(private val context: Context, private val workoutReposi
existingKeys.add(key) // Track intra-file duplicates
imported++
} catch (e: Exception) {
// CancellationException extends Exception. Rethrow it so a cancelled
// import stops instead of recording a failed row and writing the rest.
e.rethrowIfCancellation()
Logger.w("CsvImporter") { "Failed to save row: ${e.message}" }
if (e.message?.contains("UNIQUE", ignoreCase = true) == true) {
skipped++
Expand All @@ -89,6 +92,7 @@ class AndroidCsvImporter(private val context: Context, private val workoutReposi
errors = importErrors,
)
} catch (e: Exception) {
e.rethrowIfCancellation()
Logger.e("CsvImporter", e) { "Import failed" }
CsvImportResult(
imported = 0,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import com.devil.phoenixproject.util.CsvImportResult
import com.devil.phoenixproject.util.CsvImporter
import com.devil.phoenixproject.util.KmpUtils
import com.devil.phoenixproject.util.rememberFilePicker
import com.devil.phoenixproject.util.rethrowIfCancellation
import kotlin.coroutines.cancellation.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
Expand Down Expand Up @@ -812,6 +813,7 @@ fun AnalyticsScreen(
importResult = result
showImportResultDialog = true
} catch (e: Exception) {
e.rethrowIfCancellation()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: This rethrow never fires on iOS - CsvImporter.ios.kt still swallows the cancellation

AnalyticsScreen lives in phoneMain, so it is the caller on both platforms, but the iOS importer keeps the two catch (e: Exception) blocks this PR is fixing on Android:

  • shared/src/iosMain/kotlin/com/devil/phoenixproject/util/CsvImporter.ios.kt:72 records the cancelled row as a Failed to save session (...) error and keeps writing the remaining rows.
  • shared/src/iosMain/kotlin/com/devil/phoenixproject/util/CsvImporter.ios.kt:93 turns a cancelled read into a CsvImportResult whose only error is Import failed: ....

Because the iOS importer returns a result instead of throwing, this line never sees a CancellationException on iOS, so a cancelled iOS import still opens the import-failed dialog. Add e.rethrowIfCancellation() to both iOS catches (identical to CsvImporter.android.kt:73 and :95) so the two platforms behave the same.

importResult = CsvImportResult(
imported = 0,
skipped = 0,
Expand Down
Loading