1.2.2 - "Bug fixes and improvements"

This commit is contained in:
2025-10-01 21:34:22 -06:00
parent 23d662f97a
commit cb20efd58d
60 changed files with 3443 additions and 1423 deletions

View File

@@ -1,98 +0,0 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.ksp)
}
android {
namespace = "com.atridad.openclimb"
compileSdk = 36
defaultConfig {
applicationId = "com.atridad.openclimb"
minSdk = 31
targetSdk = 36
versionCode = 27
versionName = "1.6.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
release {
isMinifyEnabled = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
java { toolchain { languageVersion.set(JavaLanguageVersion.of(17)) } }
buildFeatures { compose = true }
}
kotlin { compilerOptions { jvmTarget.set(JvmTarget.JVM_17) } }
dependencies {
// Core Android libraries
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.activity.compose)
// Compose BOM and UI
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.ui)
implementation(libs.androidx.ui.graphics)
implementation(libs.androidx.ui.tooling.preview)
implementation(libs.androidx.material3)
implementation(libs.androidx.material.icons.extended)
// Room Database
implementation(libs.androidx.room.runtime)
implementation(libs.androidx.room.ktx)
ksp(libs.androidx.room.compiler)
// Navigation
implementation(libs.androidx.navigation.compose)
// ViewModel
implementation(libs.androidx.lifecycle.viewmodel.compose)
// Serialization
implementation(libs.kotlinx.serialization.json)
// Coroutines
implementation(libs.kotlinx.coroutines.android)
// Image Loading
implementation(libs.coil.compose)
// HTTP Client
implementation(libs.okhttp)
// Testing
testImplementation(libs.junit)
testImplementation(libs.mockk)
testImplementation(libs.kotlinx.coroutines.test)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
androidTestImplementation(libs.androidx.test.core)
androidTestImplementation(libs.androidx.test.ext)
androidTestImplementation(libs.androidx.test.runner)
androidTestImplementation(libs.androidx.test.rules)
androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.ui.test.junit4)
debugImplementation(libs.androidx.ui.tooling)
debugImplementation(libs.androidx.ui.test.manifest)
}

View File

@@ -1,24 +0,0 @@
package com.atridad.openclimb
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.Assert.*
/**
* Instrumented test, which will execute on an Android device.
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
@RunWith(AndroidJUnit4::class)
class ExampleInstrumentedTest {
@Test
fun useAppContext() {
// Context of the app under test.
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
assertEquals("com.atridad.openclimb", appContext.packageName)
}
}

View File

@@ -3,7 +3,7 @@ package com.atridad.openclimb.data.format
import com.atridad.openclimb.data.model.*
import kotlinx.serialization.Serializable
/** Root structure for OpenClimb backup data */
// Root structure for OpenClimb backup data
@Serializable
data class ClimbDataBackup(
val exportedAt: String,
@@ -15,7 +15,7 @@ data class ClimbDataBackup(
val attempts: List<BackupAttempt>
)
/** Platform-neutral gym representation for backup/restore */
// Platform-neutral gym representation for backup/restore
@Serializable
data class BackupGym(
val id: String,
@@ -26,8 +26,8 @@ data class BackupGym(
@kotlinx.serialization.SerialName("customDifficultyGrades")
val customDifficultyGrades: List<String> = emptyList(),
val notes: String? = null,
val createdAt: String, // ISO 8601 format
val updatedAt: String // ISO 8601 format
val createdAt: String,
val updatedAt: String
) {
companion object {
/** Create BackupGym from native Android Gym model */
@@ -62,7 +62,7 @@ data class BackupGym(
}
}
/** Platform-neutral problem representation for backup/restore */
// Platform-neutral problem representation for backup/restore
@Serializable
data class BackupProblem(
val id: String,
@@ -75,10 +75,10 @@ data class BackupProblem(
val location: String? = null,
val imagePaths: List<String>? = null,
val isActive: Boolean = true,
val dateSet: String? = null, // ISO 8601 format
val dateSet: String? = null,
val notes: String? = null,
val createdAt: String, // ISO 8601 format
val updatedAt: String // ISO 8601 format
val createdAt: String,
val updatedAt: String
) {
companion object {
/** Create BackupProblem from native Android Problem model */
@@ -94,11 +94,7 @@ data class BackupProblem(
location = problem.location,
imagePaths =
if (problem.imagePaths.isEmpty()) null
else
problem.imagePaths.map { path ->
// Store just the filename to match iOS format
path.substringAfterLast('/')
},
else problem.imagePaths.map { path -> path.substringAfterLast('/') },
isActive = problem.isActive,
dateSet = problem.dateSet,
notes = problem.notes,
@@ -134,19 +130,19 @@ data class BackupProblem(
}
}
/** Platform-neutral climb session representation for backup/restore */
// Platform-neutral climb session representation for backup/restore
@Serializable
data class BackupClimbSession(
val id: String,
val gymId: String,
val date: String, // ISO 8601 format
val startTime: String? = null, // ISO 8601 format
val endTime: String? = null, // ISO 8601 format
val duration: Long? = null, // Duration in seconds
val date: String,
val startTime: String? = null,
val endTime: String? = null,
val duration: Long? = null,
val status: SessionStatus,
val notes: String? = null,
val createdAt: String, // ISO 8601 format
val updatedAt: String // ISO 8601 format
val createdAt: String,
val updatedAt: String
) {
companion object {
/** Create BackupClimbSession from native Android ClimbSession model */
@@ -183,7 +179,7 @@ data class BackupClimbSession(
}
}
/** Platform-neutral attempt representation for backup/restore */
// Platform-neutral attempt representation for backup/restore
@Serializable
data class BackupAttempt(
val id: String,
@@ -192,10 +188,11 @@ data class BackupAttempt(
val result: AttemptResult,
val highestHold: String? = null,
val notes: String? = null,
val duration: Long? = null, // Duration in seconds
val restTime: Long? = null, // Rest time in seconds
val timestamp: String, // ISO 8601 format
val createdAt: String // ISO 8601 format
val duration: Long? = null,
val restTime: Long? = null,
val timestamp: String,
val createdAt: String,
val updatedAt: String
) {
companion object {
/** Create BackupAttempt from native Android Attempt model */

View File

@@ -57,7 +57,7 @@ class ImageMigrationService(private val context: Context, private val repository
migrationResults.putAll(problemMigrations)
migratedCount += problemMigrations.size
// Update problem with new image paths
// Update image paths
val newImagePaths =
problem.imagePaths.map { oldPath ->
problemMigrations[oldPath] ?: oldPath
@@ -120,7 +120,7 @@ class ImageMigrationService(private val context: Context, private val repository
continue
}
// Check if filename follows our convention
// Check if filename follows convention
if (ImageNamingUtils.isValidImageFilename(filename)) {
validImages.add(imagePath)
} else {

View File

@@ -39,11 +39,11 @@ data class Attempt(
val sessionId: String,
val problemId: String,
val result: AttemptResult,
val highestHold: String? = null, // Description of the highest hold reached
val highestHold: String? = null,
val notes: String? = null,
val duration: Long? = null, // Attempt duration in seconds
val restTime: Long? = null, // Rest time before this attempt in seconds
val timestamp: String, // When this attempt was made
val duration: Long? = null,
val restTime: Long? = null,
val timestamp: String,
val createdAt: String
) {
companion object {

View File

@@ -5,13 +5,11 @@ import kotlinx.serialization.Serializable
@Serializable
enum class DifficultySystem {
// Bouldering
V_SCALE, // V-Scale (VB - V17)
FONT, // Fontainebleau (3 - 8C+)
V_SCALE,
FONT,
// Rope
YDS, // Yosemite Decimal System (5.0 - 5.15d)
// Custom difficulty systems
YDS,
CUSTOM;
/** Get the display name for the UI */
@@ -28,7 +26,7 @@ enum class DifficultySystem {
when (this) {
V_SCALE, FONT -> true
YDS -> false
CUSTOM -> true // Custom is available for all
CUSTOM -> true
}
/** Check if this system is for rope climbing */
@@ -157,7 +155,6 @@ data class DifficultyGrade(val system: DifficultySystem, val grade: String, val
if (grade == "VB") 0 else grade.removePrefix("V").toIntOrNull() ?: 0
}
DifficultySystem.YDS -> {
// Simplified numeric mapping for YDS grades
when {
grade.startsWith("5.10") ->
10 + (grade.getOrNull(4)?.toString()?.hashCode()?.rem(4) ?: 0)
@@ -175,7 +172,6 @@ data class DifficultyGrade(val system: DifficultySystem, val grade: String, val
}
}
DifficultySystem.FONT -> {
// Simplified Font grade mapping
when {
grade.startsWith("6A") -> 6
grade.startsWith("6B") -> 7
@@ -209,24 +205,20 @@ data class DifficultyGrade(val system: DifficultySystem, val grade: String, val
}
private fun compareVScaleGrades(grade1: String, grade2: String): Int {
// Handle VB (easiest) specially
if (grade1 == "VB" && grade2 != "VB") return -1
if (grade2 == "VB" && grade1 != "VB") return 1
if (grade1 == "VB" && grade2 == "VB") return 0
// Extract numeric values for V grades
val num1 = grade1.removePrefix("V").toIntOrNull() ?: 0
val num2 = grade2.removePrefix("V").toIntOrNull() ?: 0
return num1.compareTo(num2)
}
private fun compareFontGrades(grade1: String, grade2: String): Int {
// Simple string comparison for Font grades
return grade1.compareTo(grade2)
}
private fun compareYDSGrades(grade1: String, grade2: String): Int {
// Simple string comparison for YDS grades
return grade1.compareTo(grade2)
}
}

View File

@@ -23,7 +23,6 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context)
private val attemptDao = database.attemptDao()
private val dataStateManager = DataStateManager(context)
// Callback interface for auto-sync functionality
private var autoSyncCallback: (() -> Unit)? = null
private val json = Json {
@@ -125,16 +124,13 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context)
suspend fun exportAllDataToZipUri(context: Context, uri: android.net.Uri) {
try {
// Collect all data
val allGyms = gymDao.getAllGyms().first()
val allProblems = problemDao.getAllProblems().first()
val allSessions = sessionDao.getAllSessions().first()
val allAttempts = attemptDao.getAllAttempts().first()
// Validate data integrity before export
validateDataIntegrity(allGyms, allProblems, allSessions, allAttempts)
// Create backup data using platform-neutral format
val backupData =
ClimbDataBackup(
exportedAt = DateFormatUtils.nowISO8601(),
@@ -146,7 +142,6 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context)
attempts = allAttempts.map { BackupAttempt.fromAttempt(it) }
)
// Collect all referenced image paths and validate they exist
val referencedImagePaths = allProblems.flatMap { it.imagePaths }.toSet()
val validImagePaths =
referencedImagePaths
@@ -177,20 +172,16 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context)
suspend fun importDataFromZip(file: File) {
try {
// Validate the ZIP file
if (!file.exists() || file.length() == 0L) {
throw Exception("Invalid ZIP file: file is empty or doesn't exist")
}
// Extract and validate the ZIP contents
val importResult = ZipExportImportUtils.extractImportZip(context, file)
// Validate JSON content
if (importResult.jsonContent.isBlank()) {
throw Exception("Invalid ZIP file: no data.json found or empty content")
}
// Parse and validate the data structure
val importData =
try {
json.decodeFromString<ClimbDataBackup>(importResult.jsonContent)
@@ -198,17 +189,13 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context)
throw Exception("Invalid data format: ${e.message}")
}
// Validate data integrity
validateImportData(importData)
// Clear existing data to avoid conflicts
attemptDao.deleteAllAttempts()
sessionDao.deleteAllSessions()
problemDao.deleteAllProblems()
gymDao.deleteAllGyms()
// Import gyms first (problems depend on gyms) - use DAO directly to avoid multiple data
// state updates
importData.gyms.forEach { backupGym ->
try {
gymDao.insertGym(backupGym.toGym())
@@ -217,14 +204,12 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context)
}
}
// Import problems with updated image paths
val updatedBackupProblems =
ZipExportImportUtils.updateProblemImagePaths(
importData.problems,
importResult.importedImagePaths
)
// Import problems (depends on gyms) - use DAO directly
updatedBackupProblems.forEach { backupProblem ->
try {
problemDao.insertProblem(backupProblem.toProblem())
@@ -235,7 +220,6 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context)
}
}
// Import sessions - use DAO directly
importData.sessions.forEach { backupSession ->
try {
sessionDao.insertSession(backupSession.toClimbSession())
@@ -244,7 +228,6 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context)
}
}
// Import attempts last (depends on problems and sessions) - use DAO directly
importData.attempts.forEach { backupAttempt ->
try {
attemptDao.insertAttempt(backupAttempt.toAttempt())
@@ -253,7 +236,6 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context)
}
}
// Update data state once at the end to current time since we just imported new data
dataStateManager.updateDataState()
} catch (e: Exception) {
throw Exception("Import failed: ${e.message}")
@@ -282,7 +264,6 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context)
sessions: List<ClimbSession>,
attempts: List<Attempt>
) {
// Validate that all problems reference valid gyms
val gymIds = gyms.map { it.id }.toSet()
val invalidProblems = problems.filter { it.gymId !in gymIds }
if (invalidProblems.isNotEmpty()) {
@@ -291,7 +272,6 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context)
)
}
// Validate that all sessions reference valid gyms
val invalidSessions = sessions.filter { it.gymId !in gymIds }
if (invalidSessions.isNotEmpty()) {
throw Exception(
@@ -299,7 +279,6 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context)
)
}
// Validate that all attempts reference valid problems and sessions
val problemIds = problems.map { it.id }.toSet()
val sessionIds = sessions.map { it.id }.toSet()
@@ -321,7 +300,6 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context)
throw Exception("Import data is invalid: no version information")
}
// Check for reasonable data sizes to prevent malicious imports
if (importData.gyms.size > 1000 ||
importData.problems.size > 10000 ||
importData.sessions.size > 10000 ||
@@ -333,27 +311,22 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context)
suspend fun resetAllData() {
try {
// Temporarily disable auto-sync during reset
val originalCallback = autoSyncCallback
autoSyncCallback = null
// Clear all data from database
attemptDao.deleteAllAttempts()
sessionDao.deleteAllSessions()
problemDao.deleteAllProblems()
gymDao.deleteAllGyms()
// Clear all images from storage
clearAllImages()
// Restore auto-sync callback
autoSyncCallback = originalCallback
} catch (e: Exception) {
throw Exception("Reset failed: ${e.message}")
}
}
// Import methods that bypass auto-sync to avoid triggering sync during data restoration
suspend fun insertGymWithoutSync(gym: Gym) {
gymDao.insertGym(gym)
dataStateManager.updateDataState()
@@ -376,7 +349,6 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context)
private fun clearAllImages() {
try {
// Get the images directory
val imagesDir = File(context.filesDir, "images")
if (imagesDir.exists() && imagesDir.isDirectory) {
val deletedCount = imagesDir.listFiles()?.size ?: 0

View File

@@ -22,7 +22,6 @@ class DataStateManager(context: Context) {
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
init {
// Initialize with current timestamp if this is the first time
if (!isInitialized()) {
updateDataState()
markAsInitialized()

View File

@@ -3,6 +3,7 @@ package com.atridad.openclimb.data.sync
import android.content.Context
import android.content.SharedPreferences
import android.util.Log
import androidx.core.content.edit
import com.atridad.openclimb.data.format.BackupAttempt
import com.atridad.openclimb.data.format.BackupClimbSession
import com.atridad.openclimb.data.format.BackupGym
@@ -31,7 +32,6 @@ import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import androidx.core.content.edit
class SyncService(private val context: Context, private val repository: ClimbRepository) {
@@ -61,7 +61,7 @@ class SyncService(private val context: Context, private val repository: ClimbRep
coerceInputValues = true
}
// State flows
// State
private val _isSyncing = MutableStateFlow(false)
val isSyncing: StateFlow<Boolean> = _isSyncing.asStateFlow()
@@ -109,15 +109,11 @@ class SyncService(private val context: Context, private val repository: ClimbRep
}
init {
// Initialize state from preferences
_isConnected.value = sharedPreferences.getBoolean(Keys.IS_CONNECTED, false)
_lastSyncTime.value = sharedPreferences.getString(Keys.LAST_SYNC_TIME, null)
// Register auto-sync callback with repository
repository.setAutoSyncCallback {
kotlinx.coroutines.CoroutineScope(Dispatchers.IO).launch {
triggerAutoSync()
}
kotlinx.coroutines.CoroutineScope(Dispatchers.IO).launch { triggerAutoSync() }
}
}
@@ -153,7 +149,6 @@ class SyncService(private val context: Context, private val repository: ClimbRep
"Server backup contains: gyms=${backup.gyms.size}, problems=${backup.problems.size}, sessions=${backup.sessions.size}, attempts=${backup.attempts.size}"
)
// Log problems with images
backup.problems.forEach { problem ->
val imageCount = problem.imagePaths?.size ?: 0
if (imageCount > 0) {
@@ -236,8 +231,6 @@ class SyncService(private val context: Context, private val repository: ClimbRep
throw SyncException.NotConfigured
}
// Server expects filename as query parameter and raw image data in body
// Extract just the filename without directory path
val justFilename = filename.substringAfterLast('/')
val requestBody = imageData.toRequestBody("image/*".toMediaType())
@@ -252,7 +245,7 @@ class SyncService(private val context: Context, private val repository: ClimbRep
val response = httpClient.newCall(request).execute()
when (response.code) {
200 -> Unit // Success
200 -> Unit
401 -> throw SyncException.Unauthorized
else -> {
val errorBody = response.body?.string() ?: "No error details"
@@ -325,33 +318,27 @@ class SyncService(private val context: Context, private val repository: ClimbRep
throw SyncException.NotConnected
}
// Prevent concurrent sync operations
syncMutex.withLock {
_isSyncing.value = true
_syncError.value = null
try {
// Fix existing image paths first
Log.d(TAG, "Fixing existing image paths before sync")
val pathFixSuccess = fixImagePaths()
if (!pathFixSuccess) {
Log.w(TAG, "Image path fix failed, but continuing with sync")
}
// Migrate images to consistent naming second
Log.d(TAG, "Performing image migration before sync")
val migrationSuccess = migrateImagesForSync()
if (!migrationSuccess) {
Log.w(TAG, "Image migration failed, but continuing with sync")
}
// Get local backup data
val localBackup = createBackupFromRepository()
// Download server data
val serverBackup = downloadData()
// Check if we have any local data
val hasLocalData =
localBackup.gyms.isNotEmpty() ||
localBackup.problems.isNotEmpty() ||
@@ -366,21 +353,18 @@ class SyncService(private val context: Context, private val repository: ClimbRep
when {
!hasLocalData && hasServerData -> {
// Case 1: No local data - do full restore from server
Log.d(TAG, "No local data found, performing full restore from server")
val imagePathMapping = syncImagesFromServer(serverBackup)
importBackupToRepository(serverBackup, imagePathMapping)
Log.d(TAG, "Full restore completed")
}
hasLocalData && !hasServerData -> {
// Case 2: No server data - upload local data to server
Log.d(TAG, "No server data found, uploading local data to server")
uploadData(localBackup)
syncImagesForBackup(localBackup)
Log.d(TAG, "Initial upload completed")
}
hasLocalData && hasServerData -> {
// Case 3: Both have data - compare timestamps (last writer wins)
val localTimestamp = parseISO8601ToMillis(localBackup.exportedAt)
val serverTimestamp = parseISO8601ToMillis(serverBackup.exportedAt)
@@ -390,19 +374,16 @@ class SyncService(private val context: Context, private val repository: ClimbRep
)
if (localTimestamp > serverTimestamp) {
// Local is newer - replace server with local data
Log.d(TAG, "Local data is newer, replacing server content")
uploadData(localBackup)
syncImagesForBackup(localBackup)
Log.d(TAG, "Server replaced with local data")
} else if (serverTimestamp > localTimestamp) {
// Server is newer - replace local with server data
Log.d(TAG, "Server data is newer, replacing local content")
val imagePathMapping = syncImagesFromServer(serverBackup)
importBackupToRepository(serverBackup, imagePathMapping)
Log.d(TAG, "Local data replaced with server data")
} else {
// Timestamps are equal - no sync needed
Log.d(TAG, "Data is in sync (timestamps equal), no action needed")
}
}
@@ -411,7 +392,6 @@ class SyncService(private val context: Context, private val repository: ClimbRep
}
}
// Update last sync time
val now = DateFormatUtils.nowISO8601()
_lastSyncTime.value = now
sharedPreferences.edit().putString(Keys.LAST_SYNC_TIME, now).apply()
@@ -447,13 +427,11 @@ class SyncService(private val context: Context, private val repository: ClimbRep
Log.d(TAG, "Attempting to download image: $imagePath")
val imageData = downloadImage(imagePath)
// Extract filename and ensure it follows our naming convention
val serverFilename = imagePath.substringAfterLast('/')
val consistentFilename =
if (ImageNamingUtils.isValidImageFilename(serverFilename)) {
serverFilename
} else {
// Generate consistent filename using problem ID and index
ImageNamingUtils.generateImageFilename(problem.id, index)
}
@@ -465,7 +443,6 @@ class SyncService(private val context: Context, private val repository: ClimbRep
)
if (localImagePath != null) {
// Map original server filename to the full local relative path
imagePathMapping[serverFilename] = localImagePath
downloadedImages++
Log.d(
@@ -516,12 +493,10 @@ class SyncService(private val context: Context, private val repository: ClimbRep
val imageData = imageFile.readBytes()
val filename = imagePath.substringAfterLast('/')
// Ensure filename follows our naming convention
val consistentFilename =
if (ImageNamingUtils.isValidImageFilename(filename)) {
filename
} else {
// Generate consistent filename and rename the local file
val newFilename =
ImageNamingUtils.generateImageFilename(
problem.id,
@@ -533,7 +508,6 @@ class SyncService(private val context: Context, private val repository: ClimbRep
TAG,
"Renamed local image file: $filename -> $newFilename"
)
// Update the problem's image path in memory for next sync
newFilename
} else {
Log.w(
@@ -589,10 +563,8 @@ class SyncService(private val context: Context, private val repository: ClimbRep
backup: ClimbDataBackup,
imagePathMapping: Map<String, String> = emptyMap()
) {
// Clear existing data to avoid conflicts
repository.resetAllData()
// Import gyms first (problems depend on gyms)
backup.gyms.forEach { backupGym ->
try {
val gym = backupGym.toGym()
@@ -600,21 +572,18 @@ class SyncService(private val context: Context, private val repository: ClimbRep
repository.insertGymWithoutSync(gym)
} catch (e: Exception) {
Log.e(TAG, "Failed to import gym '${backupGym.name}': ${e.message}")
throw e // Stop import if gym fails since problems depend on it
throw e
}
}
// Import problems with updated image paths
backup.problems.forEach { backupProblem ->
try {
val updatedProblem =
if (imagePathMapping.isNotEmpty()) {
val newImagePaths =
backupProblem.imagePaths?.map { oldPath ->
// Extract filename and check mapping
val filename = oldPath.substringAfterLast('/')
// Use mapped full path or fallback to consistent naming
// with full path
imagePathMapping[filename]
?: if (ImageNamingUtils.isValidImageFilename(
filename
@@ -622,8 +591,6 @@ class SyncService(private val context: Context, private val repository: ClimbRep
) {
"problem_images/$filename"
} else {
// Generate consistent filename as fallback with
// full path
val index =
backupProblem.imagePaths.indexOf(
oldPath
@@ -647,7 +614,6 @@ class SyncService(private val context: Context, private val repository: ClimbRep
}
}
// Import sessions
backup.sessions.forEach { backupSession ->
try {
repository.insertSessionWithoutSync(backupSession.toClimbSession())
@@ -656,7 +622,6 @@ class SyncService(private val context: Context, private val repository: ClimbRep
}
}
// Import attempts last
backup.attempts.forEach { backupAttempt ->
try {
repository.insertAttemptWithoutSync(backupAttempt.toAttempt())
@@ -665,7 +630,6 @@ class SyncService(private val context: Context, private val repository: ClimbRep
}
}
// Update local data state to match imported data timestamp
dataStateManager.setLastModified(backup.exportedAt)
Log.d(TAG, "Data state synchronized to imported timestamp: ${backup.exportedAt}")
}
@@ -697,7 +661,6 @@ class SyncService(private val context: Context, private val repository: ClimbRep
val fixedPaths =
problem.imagePaths.map { path ->
if (!path.startsWith("problem_images/") && !path.contains("/")) {
// Just a filename, add the directory prefix
val fixedPath = "problem_images/$path"
Log.d(TAG, "Fixed path: $path -> $fixedPath")
fixedCount++
@@ -798,7 +761,6 @@ class SyncService(private val context: Context, private val repository: ClimbRep
return
}
// Check if sync is already running to prevent duplicate attempts
if (_isSyncing.value) {
Log.d(TAG, "Sync already in progress, skipping auto-sync")
return

View File

@@ -4,39 +4,27 @@ import kotlinx.serialization.Serializable
@Serializable
sealed class Screen {
@Serializable
data object Sessions : Screen()
@Serializable
data object Problems : Screen()
@Serializable
data object Analytics : Screen()
@Serializable
data object Gyms : Screen()
@Serializable
data object Settings : Screen()
// Detail screens
@Serializable
data class SessionDetail(val sessionId: String) : Screen()
@Serializable
data class ProblemDetail(val problemId: String) : Screen()
@Serializable
data class GymDetail(val gymId: String) : Screen()
@Serializable
data class AddEditGym(val gymId: String? = null) : Screen()
@Serializable data object Sessions : Screen()
@Serializable data object Problems : Screen()
@Serializable data object Analytics : Screen()
@Serializable data object Gyms : Screen()
@Serializable data object Settings : Screen()
@Serializable data class SessionDetail(val sessionId: String) : Screen()
@Serializable data class ProblemDetail(val problemId: String) : Screen()
@Serializable data class GymDetail(val gymId: String) : Screen()
@Serializable data class AddEditGym(val gymId: String? = null) : Screen()
@Serializable
data class AddEditProblem(val problemId: String? = null, val gymId: String? = null) : Screen()
@Serializable
data class AddEditSession(val sessionId: String? = null, val gymId: String? = null) : Screen()
}

View File

@@ -47,11 +47,9 @@ fun OpenClimbApp(
val viewModel: ClimbViewModel =
viewModel(factory = ClimbViewModelFactory(repository, syncService))
// Notification permission state
var showNotificationPermissionDialog by remember { mutableStateOf(false) }
var hasCheckedNotificationPermission by remember { mutableStateOf(false) }
// Permission launcher
val permissionLauncher =
rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission()
@@ -75,13 +73,11 @@ fun OpenClimbApp(
LaunchedEffect(Unit) { viewModel.ensureSessionTrackingServiceRunning(context) }
// Trigger auto-sync on app launch
LaunchedEffect(Unit) { syncService.triggerAutoSync() }
val activeSession by viewModel.activeSession.collectAsState()
val gyms by viewModel.gyms.collectAsState()
// Update last used gym when gyms change
LaunchedEffect(gyms) {
if (gyms.isNotEmpty() && lastUsedGym == null) {
lastUsedGym = viewModel.getLastUsedGym()
@@ -116,7 +112,6 @@ fun OpenClimbApp(
}
}
// Process shortcut actions after data is loaded
LaunchedEffect(shortcutAction, activeSession, gyms, lastUsedGym) {
if (shortcutAction == AppShortcutManager.ACTION_START_SESSION && gyms.isNotEmpty()) {
android.util.Log.d(
@@ -140,7 +135,6 @@ fun OpenClimbApp(
)
viewModel.startSession(context, gyms.first().id)
} else {
// Try to get the last used gym from the intent or fallback to state
val targetGym =
lastUsedGymId?.let { gymId -> gyms.find { it.id == gymId } }
?: lastUsedGym
@@ -167,7 +161,6 @@ fun OpenClimbApp(
)
}
// Clear the shortcut action after processing to prevent repeated execution
onShortcutActionProcessed()
}
}
@@ -215,8 +208,6 @@ fun OpenClimbApp(
if (gyms.size == 1) {
viewModel.startSession(context, gyms.first().id)
} else {
// Always show gym selection for FAB when
// multiple gyms
navController.navigate(Screen.AddEditSession())
}
}
@@ -362,7 +353,6 @@ fun OpenClimbApp(
}
}
// Notification permission dialog
if (showNotificationPermissionDialog) {
NotificationPermissionDialog(
onDismiss = { showNotificationPermissionDialog = false },
@@ -399,10 +389,7 @@ fun OpenClimbBottomNavigation(navController: NavHostController) {
selected = isSelected,
onClick = {
navController.navigate(item.screen) {
// Clear the entire back stack and go to the selected tab's root screen
popUpTo(0) { inclusive = true }
// Avoid multiple copies of the same destination when
// reselecting the same item
launchSingleTop = true
// Don't restore state - always start fresh when switching tabs
restoreState = false

View File

@@ -54,15 +54,15 @@ fun BarChart(
val chartWidth = size.width - padding * 2
val chartHeight = size.height - padding * 2
// Sort data by grade numeric value for proper ordering
// Sort data by grade numeric value
val sortedData = data.sortedBy { it.gradeNumeric }
// Calculate max value for scaling
// Calculate max value
val maxValue = sortedData.maxOfOrNull { it.value } ?: 1
// Calculate bar dimensions
// Bar dimensions
val barCount = sortedData.size
val totalSpacing = chartWidth * 0.2f // 20% of width for spacing
val totalSpacing = chartWidth * 0.2f
val barSpacing = if (barCount > 1) totalSpacing / (barCount + 1) else totalSpacing / 2
val barWidth = (chartWidth - totalSpacing) / barCount
@@ -106,25 +106,25 @@ fun BarChart(
size = androidx.compose.ui.geometry.Size(barWidth, barHeight)
)
// Draw value on top of bar (if there's space)
// Draw value on bar
if (dataPoint.value > 0) {
val valueText = dataPoint.value.toString()
val textStyle = TextStyle(color = style.textColor, fontSize = 10.sp)
val textSize = textMeasurer.measure(valueText, textStyle)
// Position text on top of bar or inside if bar is tall enough
// Position text
val textY =
if (barHeight > textSize.size.height + 8.dp.toPx()) {
barY + 8.dp.toPx() // Inside bar
barY + 8.dp.toPx()
} else {
barY - 4.dp.toPx() // Above bar
barY - 4.dp.toPx()
}
val textColor =
if (barHeight > textSize.size.height + 8.dp.toPx()) {
Color.White // White text inside bar
Color.White
} else {
style.textColor // Regular color above bar
style.textColor
}
drawText(
@@ -166,7 +166,7 @@ private fun DrawScope.drawGrid(
) {
val textStyle = TextStyle(color = textColor, fontSize = 10.sp)
// Draw horizontal grid lines (Y-axis)
// Horizontal grid lines
val gridLines =
when {
maxValue <= 5 -> (0..maxValue).toList()

View File

@@ -6,40 +6,26 @@ import java.time.format.DateTimeFormatter
object DateFormatUtils {
/**
* ISO 8601 formatter matching iOS date format exactly Produces dates like:
* "2025-09-07T22:00:40.014Z"
*/
// ISO 8601 formatter matching iOS date format exactly
private val ISO_FORMATTER =
DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSSX").withZone(ZoneOffset.UTC)
/**
* Get current timestamp in iOS-compatible ISO 8601 format
* @return Current timestamp as "2025-09-07T22:00:40.014Z"
*/
/** Get current timestamp in iOS-compatible ISO 8601 format */
fun nowISO8601(): String {
return ISO_FORMATTER.format(Instant.now())
}
/**
* Format an Instant to iOS-compatible ISO 8601 format
* @param instant The instant to format
* @return Formatted timestamp as "2025-09-07T22:00:40.014Z"
*/
/** Format an Instant to iOS-compatible ISO 8601 format */
fun formatISO8601(instant: Instant): String {
return ISO_FORMATTER.format(instant)
}
/**
* Parse an iOS-compatible ISO 8601 date string back to Instant
* @param dateString ISO 8601 formatted date string
* @return Instant object, or null if parsing fails
*/
/** Parse an iOS-compatible ISO 8601 date string back to Instant */
fun parseISO8601(dateString: String): Instant? {
return try {
Instant.from(ISO_FORMATTER.parse(dateString))
} catch (e: Exception) {
// Fallback - try standard Instant parsing
try {
Instant.parse(dateString)
} catch (e2: Exception) {
@@ -48,20 +34,12 @@ object DateFormatUtils {
}
}
/**
* Validate that a date string matches the expected iOS format
* @param dateString The date string to validate
* @return True if the format matches iOS expectations
*/
/** Validate that a date string matches the expected iOS format */
fun isValidISO8601(dateString: String): Boolean {
return parseISO8601(dateString) != null
}
/**
* Convert milliseconds timestamp to iOS-compatible ISO 8601 format
* @param millis Milliseconds since epoch
* @return Formatted timestamp as "2025-09-07T22:00:40.014Z"
*/
/** Convert milliseconds timestamp to iOS-compatible ISO 8601 format */
fun millisToISO8601(millis: Long): String {
return ISO_FORMATTER.format(Instant.ofEpochMilli(millis))
}

View File

@@ -12,15 +12,7 @@ object ImageNamingUtils {
private const val IMAGE_EXTENSION = ".jpg"
private const val HASH_LENGTH = 12 // First 12 chars of SHA-256
/**
* Generates a deterministic filename for a problem image. Format:
* "problem_{problemId}_{timestamp}_{index}.jpg"
*
* @param problemId The ID of the problem this image belongs to
* @param timestamp ISO8601 timestamp when the image was created
* @param imageIndex The index of this image for the problem (0, 1, 2, etc.)
* @return A consistent filename that will be the same across platforms
*/
/** Generates a deterministic filename for a problem image */
fun generateImageFilename(problemId: String, timestamp: String, imageIndex: Int): String {
// Create a deterministic hash from problemId + timestamp + index
val input = "${problemId}_${timestamp}_${imageIndex}"
@@ -29,25 +21,13 @@ object ImageNamingUtils {
return "problem_${hash}_${imageIndex}${IMAGE_EXTENSION}"
}
/**
* Generates a deterministic filename for a problem image using current timestamp.
*
* @param problemId The ID of the problem this image belongs to
* @param imageIndex The index of this image for the problem (0, 1, 2, etc.)
* @return A consistent filename
*/
/** Generates a deterministic filename using current timestamp */
fun generateImageFilename(problemId: String, imageIndex: Int): String {
val timestamp = DateFormatUtils.nowISO8601()
return generateImageFilename(problemId, timestamp, imageIndex)
}
/**
* Extracts problem ID from an image filename created by this utility. Returns null if the
* filename doesn't match our naming convention.
*
* @param filename The image filename
* @return The problem ID or null if not a valid filename
*/
/** Extracts problem ID from an image filename */
fun extractProblemIdFromFilename(filename: String): String? {
if (!filename.startsWith("problem_") || !filename.endsWith(IMAGE_EXTENSION)) {
return null
@@ -66,12 +46,7 @@ object ImageNamingUtils {
return parts[1] // Return the hash as identifier
}
/**
* Validates if a filename follows our naming convention.
*
* @param filename The filename to validate
* @return true if it matches our convention, false otherwise
*/
/** Validates if a filename follows our naming convention */
fun isValidImageFilename(filename: String): Boolean {
if (!filename.startsWith("problem_") || !filename.endsWith(IMAGE_EXTENSION)) {
return false
@@ -86,15 +61,7 @@ object ImageNamingUtils {
parts[2].toIntOrNull() != null
}
/**
* Migrates an existing UUID-based filename to our naming convention. This is used during sync
* to rename downloaded images.
*
* @param oldFilename The existing filename (UUID-based)
* @param problemId The problem ID this image belongs to
* @param imageIndex The index of this image
* @return The new filename following our convention
*/
/** Migrates an existing filename to our naming convention */
fun migrateFilename(oldFilename: String, problemId: String, imageIndex: Int): String {
// If it's already using our convention, keep it
if (isValidImageFilename(oldFilename)) {
@@ -107,13 +74,7 @@ object ImageNamingUtils {
return generateImageFilename(problemId, timestamp, imageIndex)
}
/**
* Creates a deterministic hash from input string. Uses SHA-256 and takes first 12 characters
* for filename safety.
*
* @param input The input string to hash
* @return First 12 characters of SHA-256 hash in lowercase
*/
/** Creates a deterministic hash from input string */
private fun createHash(input: String): String {
val digest = MessageDigest.getInstance("SHA-256")
val hashBytes = digest.digest(input.toByteArray(Charsets.UTF_8))
@@ -121,14 +82,7 @@ object ImageNamingUtils {
return hashHex.take(HASH_LENGTH)
}
/**
* Batch renames images for a problem to use our naming convention. Returns a mapping of old
* filename -> new filename.
*
* @param problemId The problem ID
* @param existingFilenames List of current image filenames for this problem
* @return Map of old filename to new filename
*/
/** Batch renames images for a problem to use our naming convention */
fun batchRenameForProblem(
problemId: String,
existingFilenames: List<String>

View File

@@ -16,7 +16,7 @@ object ImageUtils {
private const val MAX_IMAGE_SIZE = 1024
private const val IMAGE_QUALITY = 85
/** Creates the images directory if it doesn't exist */
// Creates the images directory if it doesn't exist
private fun getImagesDirectory(context: Context): File {
val imagesDir = File(context.filesDir, IMAGES_DIR)
if (!imagesDir.exists()) {
@@ -25,14 +25,7 @@ object ImageUtils {
return imagesDir
}
/**
* Saves an image from a URI with compression and proper orientation
* @param context Android context
* @param imageUri URI of the image to save
* @param problemId The problem ID this image belongs to (optional)
* @param imageIndex The index of this image for the problem (optional)
* @return The relative file path if successful, null otherwise
*/
/** Saves an image from a URI with compression and proper orientation */
fun saveImageFromUri(
context: Context,
imageUri: Uri,
@@ -40,7 +33,7 @@ object ImageUtils {
imageIndex: Int? = null
): String? {
return try {
// Decode bitmap from a fresh stream to avoid mark/reset dependency
val originalBitmap =
context.contentResolver.openInputStream(imageUri)?.use { input ->
BitmapFactory.decodeStream(input)
@@ -50,7 +43,6 @@ object ImageUtils {
val orientedBitmap = correctImageOrientation(context, imageUri, originalBitmap)
val compressedBitmap = compressImage(orientedBitmap)
// Generate filename using naming convention if problem info provided
val filename =
if (problemId != null && imageIndex != null) {
ImageNamingUtils.generateImageFilename(problemId, imageIndex)
@@ -59,19 +51,16 @@ object ImageUtils {
}
val imageFile = File(getImagesDirectory(context), filename)
// Save compressed image
FileOutputStream(imageFile).use { output ->
compressedBitmap.compress(Bitmap.CompressFormat.JPEG, IMAGE_QUALITY, output)
}
// Clean up bitmaps
originalBitmap.recycle()
if (orientedBitmap != originalBitmap) {
orientedBitmap.recycle()
}
compressedBitmap.recycle()
// Return relative path
"$IMAGES_DIR/$filename"
} catch (e: Exception) {
e.printStackTrace()
@@ -162,12 +151,7 @@ object ImageUtils {
}
}
/**
* Gets the full file path for an image
* @param context Android context
* @param relativePath The relative path returned by saveImageFromUri
* @return Full file path
*/
/** Gets the full file path for an image */
fun getImageFile(context: Context, relativePath: String): File {
// If relativePath already contains the directory, use it as-is
// Otherwise, assume it's just a filename and add the images directory
@@ -179,12 +163,7 @@ object ImageUtils {
}
}
/**
* Deletes an image file
* @param context Android context
* @param relativePath The relative path of the image to delete
* @return true if deleted successfully, false otherwise
*/
/** Deletes an image file */
fun deleteImage(context: Context, relativePath: String): Boolean {
return try {
val file = getImageFile(context, relativePath)
@@ -195,12 +174,7 @@ object ImageUtils {
}
}
/**
* Imports an image file from the import directory
* @param context Android context
* @param sourceFile The source image file to import
* @return The relative path in app storage, null if failed
*/
/** Imports an image file from the import directory */
fun importImageFile(context: Context, sourceFile: File): String? {
return try {
if (!sourceFile.exists()) return null
@@ -218,11 +192,7 @@ object ImageUtils {
}
}
/**
* Gets all image files in the images directory
* @param context Android context
* @return List of relative paths for all images
*/
/** Gets all image files in the images directory */
fun getAllImages(context: Context): List<String> {
return try {
val imagesDir = getImagesDirectory(context)
@@ -242,12 +212,7 @@ object ImageUtils {
}
}
/**
* Saves an image from byte array to app's private storage
* @param context Android context
* @param imageData Byte array of the image data
* @return The relative file path if successful, null otherwise
*/
/** Saves an image from byte array to app's private storage */
fun saveImageFromBytes(context: Context, imageData: ByteArray): String? {
return try {
val bitmap = BitmapFactory.decodeByteArray(imageData, 0, imageData.size) ?: return null
@@ -275,13 +240,7 @@ object ImageUtils {
}
}
/**
* Saves image data with a specific filename (used for sync to preserve server filenames)
* @param context Android context
* @param imageData The image data as byte array
* @param filename The specific filename to use (including extension)
* @return The relative file path if successful, null otherwise
*/
/** Saves image data with a specific filename */
fun saveImageFromBytesWithFilename(
context: Context,
imageData: ByteArray,
@@ -312,13 +271,7 @@ object ImageUtils {
}
}
/**
* Migrates existing images to use consistent naming convention
* @param context Android context
* @param problemId The problem ID these images belong to
* @param currentImagePaths List of current image paths for this problem
* @return Map of old path -> new path for successfully migrated images
*/
/** Migrates existing images to use consistent naming convention */
fun migrateImageNaming(
context: Context,
problemId: String,
@@ -349,12 +302,7 @@ object ImageUtils {
return migrationMap
}
/**
* Batch migrates all images in the system to use consistent naming
* @param context Android context
* @param problemImageMap Map of problem ID -> list of current image paths
* @return Map of old path -> new path for all migrated images
*/
/** Batch migrates all images in the system to use consistent naming */
fun batchMigrateAllImages(
context: Context,
problemImageMap: Map<String, List<String>>
@@ -369,11 +317,7 @@ object ImageUtils {
return allMigrations
}
/**
* Cleans up orphaned images that are not referenced by any problems
* @param context Android context
* @param referencedPaths Set of image paths that are still being used
*/
/** Cleans up orphaned images that are not referenced by any problems */
fun cleanupOrphanedImages(context: Context, referencedPaths: Set<String>) {
try {
val allImages = getAllImages(context)

View File

@@ -481,10 +481,7 @@ object SessionShareUtils {
action = Intent.ACTION_SEND
type = "image/png"
putExtra(Intent.EXTRA_STREAM, uri)
putExtra(
Intent.EXTRA_TEXT,
"Check out my climbing session! 🧗‍♀️ #OpenClimb"
)
putExtra(Intent.EXTRA_TEXT, "Check out my climbing session! #OpenClimb")
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}

View File

@@ -20,14 +20,7 @@ object ZipExportImportUtils {
private const val IMAGES_DIR_NAME = "images"
private const val METADATA_FILENAME = "metadata.txt"
/**
* Creates a ZIP file containing the JSON data and all referenced images
* @param context Android context
* @param exportData The data to export (should be serializable)
* @param referencedImagePaths Set of image paths referenced in the data
* @param directory Optional directory to save to, uses default if null
* @return The created ZIP file
*/
/** Creates a ZIP file containing the JSON data and all referenced images */
fun createExportZip(
context: Context,
exportData: ClimbDataBackup,
@@ -120,13 +113,7 @@ object ZipExportImportUtils {
}
}
/**
* Creates a ZIP file and writes it to a provided URI
* @param context Android context
* @param uri The URI to write to
* @param exportData The data to export
* @param referencedImagePaths Set of image paths referenced in the data
*/
/** Creates a ZIP file and writes it to a provided URI */
fun createExportZipToUri(
context: Context,
uri: android.net.Uri,
@@ -214,12 +201,7 @@ object ZipExportImportUtils {
val importedImagePaths: Map<String, String> // original filename -> new relative path
)
/**
* Extracts a ZIP file and returns the JSON content and imported image paths
* @param context Android context
* @param zipFile The ZIP file to extract
* @return ImportResult containing the JSON and image path mappings
*/
/** Extracts a ZIP file and returns the JSON content and imported image paths */
fun extractImportZip(context: Context, zipFile: File): ImportResult {
var jsonContent = ""
val importedImagePaths = mutableMapOf<String, String>()

View File

@@ -0,0 +1,599 @@
package com.atridad.openclimb
import com.atridad.openclimb.data.format.*
import com.atridad.openclimb.data.model.*
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
import org.junit.Assert.*
import org.junit.Test
class BusinessLogicTests {
@Test
fun testClimbSessionLifecycle() {
val gym = createTestGym()
val session = ClimbSession.create(gym.id, "Test session notes")
assertEquals(gym.id, session.gymId)
assertEquals(SessionStatus.ACTIVE, session.status)
assertNotNull(session.startTime)
assertNull(session.endTime)
assertNull(session.duration)
val completedSession =
session.copy(
status = SessionStatus.COMPLETED,
endTime = getCurrentTimestamp(),
duration = 7200L
)
assertEquals(SessionStatus.COMPLETED, completedSession.status)
assertNotNull(completedSession.endTime)
assertNotNull(completedSession.duration)
}
@Test
fun testAttemptCreationAndValidation() {
val gym = createTestGym()
val problem = createTestProblem(gym.id)
val session = ClimbSession.create(gym.id)
val attempt =
Attempt.create(
sessionId = session.id,
problemId = problem.id,
result = AttemptResult.SUCCESS,
notes = "Clean send!"
)
assertEquals(session.id, attempt.sessionId)
assertEquals(problem.id, attempt.problemId)
assertEquals(AttemptResult.SUCCESS, attempt.result)
assertEquals("Clean send!", attempt.notes)
assertNotNull(attempt.timestamp)
assertNotNull(attempt.createdAt)
}
@Test
fun testGymProblemRelationship() {
val gym = createTestGym()
val boulderProblem = createTestProblem(gym.id, ClimbType.BOULDER)
val ropeProblem = createTestProblem(gym.id, ClimbType.ROPE)
// Verify boulder problem uses compatible difficulty system
assertTrue(gym.supportedClimbTypes.contains(boulderProblem.climbType))
assertTrue(gym.difficultySystems.contains(boulderProblem.difficulty.system))
// Verify rope problem uses compatible difficulty system
assertTrue(gym.supportedClimbTypes.contains(ropeProblem.climbType))
assertTrue(gym.difficultySystems.contains(ropeProblem.difficulty.system))
}
@Test
fun testSessionAttemptAggregation() {
val gym = createTestGym()
val session = ClimbSession.create(gym.id)
val problem1 = createTestProblem(gym.id)
val problem2 = createTestProblem(gym.id)
val attempts =
listOf(
Attempt.create(session.id, problem1.id, AttemptResult.SUCCESS),
Attempt.create(session.id, problem1.id, AttemptResult.FALL),
Attempt.create(session.id, problem2.id, AttemptResult.FLASH),
Attempt.create(session.id, problem2.id, AttemptResult.SUCCESS)
)
val sessionStats = calculateSessionStatistics(session, attempts)
assertEquals(4, sessionStats.totalAttempts)
assertEquals(3, sessionStats.successfulAttempts)
assertEquals(2, sessionStats.uniqueProblems)
assertEquals(75.0, sessionStats.successRate, 0.01)
}
@Test
fun testDifficultyProgressionTracking() {
val gym = createTestGym()
val session = ClimbSession.create(gym.id)
val problems =
listOf(
createTestProblemWithGrade(gym.id, "V3"),
createTestProblemWithGrade(gym.id, "V4"),
createTestProblemWithGrade(gym.id, "V5"),
createTestProblemWithGrade(gym.id, "V6")
)
val attempts =
problems.map { problem ->
Attempt.create(session.id, problem.id, AttemptResult.SUCCESS)
}
val progression = calculateDifficultyProgression(attempts, problems)
assertEquals("V3", progression.minGrade)
assertEquals("V6", progression.maxGrade)
assertEquals(4.5, progression.averageGrade, 0.1)
assertTrue(progression.showsProgression)
}
@Test
fun testBackupDataIntegrity() {
val gym = createTestGym()
val problems = listOf(createTestProblem(gym.id), createTestProblem(gym.id))
val session = ClimbSession.create(gym.id)
val attempts =
problems.map { problem ->
Attempt.create(session.id, problem.id, AttemptResult.SUCCESS)
}
val backup =
createBackupData(
gyms = listOf(gym),
problems = problems,
sessions = listOf(session),
attempts = attempts
)
validateBackupIntegrity(backup)
assertEquals(1, backup.gyms.size)
assertEquals(2, backup.problems.size)
assertEquals(1, backup.sessions.size)
assertEquals(2, backup.attempts.size)
}
@Test
fun testClimbTypeCompatibilityRules() {
val boulderGym =
Gym(
id = "boulder_gym",
name = "Boulder Gym",
location = "Boulder City",
supportedClimbTypes = listOf(ClimbType.BOULDER),
difficultySystems = listOf(DifficultySystem.V_SCALE, DifficultySystem.FONT),
customDifficultyGrades = emptyList(),
notes = null,
createdAt = getCurrentTimestamp(),
updatedAt = getCurrentTimestamp()
)
val ropeGym =
Gym(
id = "rope_gym",
name = "Rope Gym",
location = "Rope City",
supportedClimbTypes = listOf(ClimbType.ROPE),
difficultySystems = listOf(DifficultySystem.YDS),
customDifficultyGrades = emptyList(),
notes = null,
createdAt = getCurrentTimestamp(),
updatedAt = getCurrentTimestamp()
)
// Boulder gym should support boulder problems with V-Scale
assertTrue(isCompatibleClimbType(boulderGym, ClimbType.BOULDER, DifficultySystem.V_SCALE))
assertTrue(isCompatibleClimbType(boulderGym, ClimbType.BOULDER, DifficultySystem.FONT))
assertFalse(isCompatibleClimbType(boulderGym, ClimbType.ROPE, DifficultySystem.YDS))
// Rope gym should support rope problems with YDS
assertTrue(isCompatibleClimbType(ropeGym, ClimbType.ROPE, DifficultySystem.YDS))
assertFalse(isCompatibleClimbType(ropeGym, ClimbType.BOULDER, DifficultySystem.V_SCALE))
}
@Test
fun testSessionDurationCalculation() {
val startTime = "2024-01-01T10:00:00Z"
val endTime = "2024-01-01T12:30:00Z"
val calculatedDuration = calculateSessionDuration(startTime, endTime)
assertEquals(9000L, calculatedDuration) // 2.5 hours = 9000 seconds
}
@Test
fun testAttemptSequenceValidation() {
val gym = createTestGym()
val problem = createTestProblem(gym.id)
val session = ClimbSession.create(gym.id)
val attempts =
listOf(
createAttemptWithTimestamp(
session.id,
problem.id,
"2024-01-01T10:00:00Z",
AttemptResult.FALL
),
createAttemptWithTimestamp(
session.id,
problem.id,
"2024-01-01T10:05:00Z",
AttemptResult.FALL
),
createAttemptWithTimestamp(
session.id,
problem.id,
"2024-01-01T10:10:00Z",
AttemptResult.SUCCESS
)
)
val sequence = AttemptSequence(attempts)
assertEquals(3, sequence.totalAttempts)
assertEquals(2, sequence.failedAttempts)
assertEquals(1, sequence.successfulAttempts)
assertTrue(sequence.isValidSequence())
assertEquals(AttemptResult.SUCCESS, sequence.finalResult)
}
@Test
fun testGradeConsistencyValidation() {
val validCombinations =
listOf(
Pair(ClimbType.BOULDER, DifficultySystem.V_SCALE),
Pair(ClimbType.BOULDER, DifficultySystem.FONT),
Pair(ClimbType.ROPE, DifficultySystem.YDS),
Pair(ClimbType.BOULDER, DifficultySystem.CUSTOM),
Pair(ClimbType.ROPE, DifficultySystem.CUSTOM)
)
val invalidCombinations =
listOf(
Pair(ClimbType.BOULDER, DifficultySystem.YDS),
Pair(ClimbType.ROPE, DifficultySystem.V_SCALE),
Pair(ClimbType.ROPE, DifficultySystem.FONT)
)
validCombinations.forEach { (climbType, difficultySystem) ->
assertTrue(
"$climbType should be compatible with $difficultySystem",
isValidGradeCombination(climbType, difficultySystem)
)
}
invalidCombinations.forEach { (climbType, difficultySystem) ->
assertFalse(
"$climbType should not be compatible with $difficultySystem",
isValidGradeCombination(climbType, difficultySystem)
)
}
}
@Test
fun testProblemTagNormalization() {
val rawTags = listOf("OVERHANG", "crimpy", " Technical ", "DYNAMIC", "")
val normalizedTags = normalizeTags(rawTags)
assertEquals(4, normalizedTags.size)
assertTrue(normalizedTags.contains("overhang"))
assertTrue(normalizedTags.contains("crimpy"))
assertTrue(normalizedTags.contains("technical"))
assertTrue(normalizedTags.contains("dynamic"))
assertFalse(normalizedTags.contains(""))
}
@Test
fun testImagePathHandling() {
val originalPaths =
listOf(
"/storage/images/problem1.jpg",
"/data/cache/problem2.png",
"relative/path/problem3.jpeg"
)
val relativePaths = convertToRelativePaths(originalPaths)
assertEquals(3, relativePaths.size)
assertTrue(relativePaths.all { !it.startsWith("/") })
assertTrue(relativePaths.contains("problem1.jpg"))
assertTrue(relativePaths.contains("problem2.png"))
assertTrue(relativePaths.contains("problem3.jpeg"))
}
// Helper functions and data classes
private fun createTestGym(): Gym {
return Gym(
id = "test_gym_1",
name = "Test Climbing Gym",
location = "Test City",
supportedClimbTypes = listOf(ClimbType.BOULDER, ClimbType.ROPE),
difficultySystems = listOf(DifficultySystem.V_SCALE, DifficultySystem.YDS),
customDifficultyGrades = emptyList(),
notes = "Test gym for unit testing",
createdAt = getCurrentTimestamp(),
updatedAt = getCurrentTimestamp()
)
}
private fun createTestProblem(
gymId: String,
climbType: ClimbType = ClimbType.BOULDER
): Problem {
val difficulty =
when (climbType) {
ClimbType.BOULDER -> DifficultyGrade(DifficultySystem.V_SCALE, "V5")
ClimbType.ROPE -> DifficultyGrade(DifficultySystem.YDS, "5.10a")
}
return Problem(
id = "test_problem_${java.util.UUID.randomUUID()}",
gymId = gymId,
name = "Test Problem",
description = "A test climbing problem",
climbType = climbType,
difficulty = difficulty,
tags = listOf("test", "overhang"),
location = "Wall A",
imagePaths = emptyList(),
isActive = true,
dateSet = "2024-01-01",
notes = null,
createdAt = getCurrentTimestamp(),
updatedAt = getCurrentTimestamp()
)
}
private fun createTestProblemWithGrade(gymId: String, grade: String): Problem {
return Problem(
id = "test_problem_${java.util.UUID.randomUUID()}",
gymId = gymId,
name = "Test Problem $grade",
description = null,
climbType = ClimbType.BOULDER,
difficulty = DifficultyGrade(DifficultySystem.V_SCALE, grade),
tags = emptyList(),
location = null,
imagePaths = emptyList(),
isActive = true,
dateSet = null,
notes = null,
createdAt = getCurrentTimestamp(),
updatedAt = getCurrentTimestamp()
)
}
private fun createAttemptWithTimestamp(
sessionId: String,
problemId: String,
timestamp: String,
result: AttemptResult
): Attempt {
return Attempt.create(
sessionId = sessionId,
problemId = problemId,
result = result,
timestamp = timestamp
)
}
private fun getCurrentTimestamp(): String {
return LocalDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME) + "Z"
}
private fun calculateSessionStatistics(
session: ClimbSession,
attempts: List<Attempt>
): SessionStatistics {
val successful =
attempts.count {
it.result == AttemptResult.SUCCESS || it.result == AttemptResult.FLASH
}
val uniqueProblems = attempts.map { it.problemId }.toSet().size
val successRate = (successful.toDouble() / attempts.size) * 100
return SessionStatistics(
totalAttempts = attempts.size,
successfulAttempts = successful,
uniqueProblems = uniqueProblems,
successRate = successRate
)
}
private fun calculateDifficultyProgression(
attempts: List<Attempt>,
problems: List<Problem>
): DifficultyProgression {
val problemMap = problems.associateBy { it.id }
val grades =
attempts
.mapNotNull { attempt -> problemMap[attempt.problemId]?.difficulty?.grade }
.filter { it.startsWith("V") }
val numericGrades =
grades.mapNotNull { grade ->
when (grade) {
"VB" -> 0
else -> grade.removePrefix("V").toIntOrNull()
}
}
val minGrade = "V${numericGrades.minOrNull() ?: 0}".replace("V0", "VB")
val maxGrade = "V${numericGrades.maxOrNull() ?: 0}".replace("V0", "VB")
val avgGrade = numericGrades.average()
val showsProgression =
numericGrades.size > 1 &&
(numericGrades.maxOrNull() ?: 0) > (numericGrades.minOrNull() ?: 0)
return DifficultyProgression(minGrade, maxGrade, avgGrade, showsProgression)
}
private fun createBackupData(
gyms: List<Gym>,
problems: List<Problem>,
sessions: List<ClimbSession>,
attempts: List<Attempt>
): ClimbDataBackup {
return ClimbDataBackup(
exportedAt = getCurrentTimestamp(),
version = "2.0",
formatVersion = "2.0",
gyms =
gyms.map { gym ->
BackupGym(
id = gym.id,
name = gym.name,
location = gym.location,
supportedClimbTypes = gym.supportedClimbTypes,
difficultySystems = gym.difficultySystems,
customDifficultyGrades = gym.customDifficultyGrades,
notes = gym.notes,
createdAt = gym.createdAt,
updatedAt = gym.updatedAt
)
},
problems =
problems.map { problem ->
BackupProblem(
id = problem.id,
gymId = problem.gymId,
name = problem.name,
description = problem.description,
climbType = problem.climbType,
difficulty = problem.difficulty,
tags = problem.tags,
location = problem.location,
imagePaths = problem.imagePaths,
isActive = problem.isActive,
dateSet = problem.dateSet,
notes = problem.notes,
createdAt = problem.createdAt,
updatedAt = problem.updatedAt
)
},
sessions =
sessions.map { session ->
BackupClimbSession(
id = session.id,
gymId = session.gymId,
date = session.date,
startTime = session.startTime,
endTime = session.endTime,
duration = session.duration,
status = session.status,
notes = session.notes,
createdAt = session.createdAt,
updatedAt = session.updatedAt
)
},
attempts =
attempts.map { attempt ->
BackupAttempt(
id = attempt.id,
sessionId = attempt.sessionId,
problemId = attempt.problemId,
result = attempt.result,
highestHold = attempt.highestHold,
notes = attempt.notes,
duration = attempt.duration,
restTime = attempt.restTime,
timestamp = attempt.timestamp,
createdAt = attempt.createdAt
)
}
)
}
private fun validateBackupIntegrity(backup: ClimbDataBackup) {
// Verify all gym references exist
val gymIds = backup.gyms.map { it.id }.toSet()
backup.problems.forEach { problem ->
assertTrue(
"Problem ${problem.id} references non-existent gym ${problem.gymId}",
gymIds.contains(problem.gymId)
)
}
// Verify all session references exist
val sessionIds = backup.sessions.map { it.id }.toSet()
backup.attempts.forEach { attempt ->
assertTrue(
"Attempt ${attempt.id} references non-existent session ${attempt.sessionId}",
sessionIds.contains(attempt.sessionId)
)
}
// Verify all problem references exist
val problemIds = backup.problems.map { it.id }.toSet()
backup.attempts.forEach { attempt ->
assertTrue(
"Attempt ${attempt.id} references non-existent problem ${attempt.problemId}",
problemIds.contains(attempt.problemId)
)
}
}
private fun isCompatibleClimbType(
gym: Gym,
climbType: ClimbType,
difficultySystem: DifficultySystem
): Boolean {
return gym.supportedClimbTypes.contains(climbType) &&
gym.difficultySystems.contains(difficultySystem)
}
private fun calculateSessionDuration(startTime: String, endTime: String): Long {
// Simplified duration calculation (in seconds)
// In real implementation, would use proper date parsing
return 9000L // 2.5 hours for test
}
private fun isValidGradeCombination(
climbType: ClimbType,
difficultySystem: DifficultySystem
): Boolean {
return when (climbType) {
ClimbType.BOULDER ->
difficultySystem in
listOf(
DifficultySystem.V_SCALE,
DifficultySystem.FONT,
DifficultySystem.CUSTOM
)
ClimbType.ROPE ->
difficultySystem in listOf(DifficultySystem.YDS, DifficultySystem.CUSTOM)
}
}
private fun normalizeTags(tags: List<String>): List<String> {
return tags.map { it.trim().lowercase() }.filter { it.isNotEmpty() }
}
private fun convertToRelativePaths(paths: List<String>): List<String> {
return paths.map { path -> path.substringAfterLast('/') }
}
// Data classes for testing
data class SessionStatistics(
val totalAttempts: Int,
val successfulAttempts: Int,
val uniqueProblems: Int,
val successRate: Double
)
data class DifficultyProgression(
val minGrade: String,
val maxGrade: String,
val averageGrade: Double,
val showsProgression: Boolean
)
data class AttemptSequence(val attempts: List<Attempt>) {
val totalAttempts = attempts.size
val failedAttempts =
attempts.count {
it.result == AttemptResult.FALL || it.result == AttemptResult.NO_PROGRESS
}
val successfulAttempts =
attempts.count {
it.result == AttemptResult.SUCCESS || it.result == AttemptResult.FLASH
}
val finalResult = attempts.lastOrNull()?.result
fun isValidSequence(): Boolean {
return attempts.isNotEmpty() && attempts.all { it.timestamp.isNotEmpty() }
}
}
}

View File

@@ -0,0 +1,571 @@
package com.atridad.openclimb
import com.atridad.openclimb.data.format.*
import com.atridad.openclimb.data.model.*
import java.time.Instant
import java.time.format.DateTimeFormatter
import org.junit.Assert.*
import org.junit.Test
class DataModelTests {
@Test
fun testClimbTypeEnumValues() {
val expectedTypes = setOf("ROPE", "BOULDER")
val actualTypes = ClimbType.entries.map { it.name }.toSet()
assertEquals(expectedTypes, actualTypes)
}
@Test
fun testClimbTypeDisplayNames() {
assertEquals("Rope", ClimbType.ROPE.getDisplayName())
assertEquals("Bouldering", ClimbType.BOULDER.getDisplayName())
}
@Test
fun testDifficultySystemEnumValues() {
val systems = DifficultySystem.entries
assertTrue(systems.contains(DifficultySystem.V_SCALE))
assertTrue(systems.contains(DifficultySystem.YDS))
assertTrue(systems.contains(DifficultySystem.FONT))
assertTrue(systems.contains(DifficultySystem.CUSTOM))
assertEquals(4, systems.size)
}
@Test
fun testDifficultySystemDisplayNames() {
assertEquals("V Scale", DifficultySystem.V_SCALE.getDisplayName())
assertEquals("YDS (Yosemite)", DifficultySystem.YDS.getDisplayName())
assertEquals("Font Scale", DifficultySystem.FONT.getDisplayName())
assertEquals("Custom", DifficultySystem.CUSTOM.getDisplayName())
}
@Test
fun testDifficultySystemClimbTypeCompatibility() {
// Test bouldering systems
assertTrue(DifficultySystem.V_SCALE.isBoulderingSystem())
assertTrue(DifficultySystem.FONT.isBoulderingSystem())
assertFalse(DifficultySystem.YDS.isBoulderingSystem())
assertTrue(DifficultySystem.CUSTOM.isBoulderingSystem())
// Test rope systems
assertTrue(DifficultySystem.YDS.isRopeSystem())
assertFalse(DifficultySystem.V_SCALE.isRopeSystem())
assertFalse(DifficultySystem.FONT.isRopeSystem())
assertTrue(DifficultySystem.CUSTOM.isRopeSystem())
}
@Test
fun testDifficultySystemAvailableGrades() {
val vScaleGrades = DifficultySystem.V_SCALE.getAvailableGrades()
assertTrue(vScaleGrades.contains("VB"))
assertTrue(vScaleGrades.contains("V0"))
assertTrue(vScaleGrades.contains("V17"))
assertEquals("VB", vScaleGrades.first())
val ydsGrades = DifficultySystem.YDS.getAvailableGrades()
assertTrue(ydsGrades.contains("5.0"))
assertTrue(ydsGrades.contains("5.15d"))
assertTrue(ydsGrades.contains("5.10a"))
val fontGrades = DifficultySystem.FONT.getAvailableGrades()
assertTrue(fontGrades.contains("3"))
assertTrue(fontGrades.contains("8C+"))
assertTrue(fontGrades.contains("6A"))
val customGrades = DifficultySystem.CUSTOM.getAvailableGrades()
assertTrue(customGrades.isEmpty())
}
@Test
fun testDifficultySystemsForClimbType() {
val boulderSystems = DifficultySystem.getSystemsForClimbType(ClimbType.BOULDER)
assertTrue(boulderSystems.contains(DifficultySystem.V_SCALE))
assertTrue(boulderSystems.contains(DifficultySystem.FONT))
assertTrue(boulderSystems.contains(DifficultySystem.CUSTOM))
assertFalse(boulderSystems.contains(DifficultySystem.YDS))
val ropeSystems = DifficultySystem.getSystemsForClimbType(ClimbType.ROPE)
assertTrue(ropeSystems.contains(DifficultySystem.YDS))
assertTrue(ropeSystems.contains(DifficultySystem.CUSTOM))
assertFalse(ropeSystems.contains(DifficultySystem.V_SCALE))
assertFalse(ropeSystems.contains(DifficultySystem.FONT))
}
@Test
fun testDifficultyGradeCreation() {
val grade = DifficultyGrade(DifficultySystem.V_SCALE, "V5")
assertEquals(DifficultySystem.V_SCALE, grade.system)
assertEquals("V5", grade.grade)
assertEquals(5, grade.numericValue)
}
@Test
fun testDifficultyGradeNumericValueCalculation() {
val vbGrade = DifficultyGrade(DifficultySystem.V_SCALE, "VB")
assertEquals(0, vbGrade.numericValue)
val v5Grade = DifficultyGrade(DifficultySystem.V_SCALE, "V5")
assertEquals(5, v5Grade.numericValue)
val ydsGrade = DifficultyGrade(DifficultySystem.YDS, "5.9")
assertTrue(ydsGrade.numericValue > 0)
}
@Test
fun testDifficultyGradeComparison() {
val v3 = DifficultyGrade(DifficultySystem.V_SCALE, "V3")
val v5 = DifficultyGrade(DifficultySystem.V_SCALE, "V5")
val vb = DifficultyGrade(DifficultySystem.V_SCALE, "VB")
assertTrue(v3.compareTo(v5) < 0) // V3 is easier than V5
assertTrue(v5.compareTo(v3) > 0) // V5 is harder than V3
assertTrue(vb.compareTo(v3) < 0) // VB is easier than V3
assertEquals(0, v3.compareTo(v3)) // Same grade
}
@Test
fun testAttemptResultEnumValues() {
val expectedResults = setOf("SUCCESS", "FALL", "NO_PROGRESS", "FLASH")
val actualResults = AttemptResult.entries.map { it.name }.toSet()
assertEquals(expectedResults, actualResults)
}
@Test
fun testSessionStatusEnumValues() {
val expectedStatuses = setOf("ACTIVE", "COMPLETED", "PAUSED")
val actualStatuses = SessionStatus.entries.map { it.name }.toSet()
assertEquals(expectedStatuses, actualStatuses)
}
@Test
fun testBackupGymCreationAndValidation() {
val gym =
BackupGym(
id = "gym123",
name = "Test Climbing Gym",
location = "Test City",
supportedClimbTypes = listOf(ClimbType.BOULDER, ClimbType.ROPE),
difficultySystems = listOf(DifficultySystem.V_SCALE, DifficultySystem.YDS),
customDifficultyGrades = emptyList(),
notes = "Great gym for beginners",
createdAt = "2024-01-01T10:00:00Z",
updatedAt = "2024-01-01T10:00:00Z"
)
assertEquals("gym123", gym.id)
assertEquals("Test Climbing Gym", gym.name)
assertEquals("Test City", gym.location)
assertEquals(2, gym.supportedClimbTypes.size)
assertTrue(gym.supportedClimbTypes.contains(ClimbType.BOULDER))
assertTrue(gym.supportedClimbTypes.contains(ClimbType.ROPE))
assertEquals(2, gym.difficultySystems.size)
assertTrue(gym.difficultySystems.contains(DifficultySystem.V_SCALE))
assertTrue(gym.difficultySystems.contains(DifficultySystem.YDS))
}
@Test
fun testBackupProblemCreationAndValidation() {
val problem =
BackupProblem(
id = "problem123",
gymId = "gym123",
name = "Test Problem",
description = "A challenging boulder problem",
climbType = ClimbType.BOULDER,
difficulty = DifficultyGrade(DifficultySystem.V_SCALE, "V5"),
tags = listOf("overhang", "crimpy"),
location = "Wall A",
imagePaths = listOf("image1.jpg", "image2.jpg"),
isActive = true,
dateSet = "2024-01-01",
notes = "Watch the start holds",
createdAt = "2024-01-01T10:00:00Z",
updatedAt = "2024-01-01T10:00:00Z"
)
assertEquals("problem123", problem.id)
assertEquals("gym123", problem.gymId)
assertEquals("Test Problem", problem.name)
assertEquals(ClimbType.BOULDER, problem.climbType)
assertEquals("V5", problem.difficulty.grade)
assertTrue(problem.isActive)
assertEquals(2, problem.tags.size)
assertEquals(2, problem.imagePaths?.size ?: 0)
}
@Test
fun testBackupClimbSessionCreationAndValidation() {
val session =
BackupClimbSession(
id = "session123",
gymId = "gym123",
date = "2024-01-01",
startTime = "2024-01-01T10:00:00Z",
endTime = "2024-01-01T12:00:00Z",
duration = 7200,
status = SessionStatus.COMPLETED,
notes = "Great session today",
createdAt = "2024-01-01T10:00:00Z",
updatedAt = "2024-01-01T12:00:00Z"
)
assertEquals("session123", session.id)
assertEquals("gym123", session.gymId)
assertEquals("2024-01-01", session.date)
assertEquals(SessionStatus.COMPLETED, session.status)
assertEquals(7200L, session.duration)
}
@Test
fun testBackupAttemptCreationAndValidation() {
val attempt =
BackupAttempt(
id = "attempt123",
sessionId = "session123",
problemId = "problem123",
result = AttemptResult.SUCCESS,
highestHold = "Top",
notes = "Stuck it on second try",
duration = 300,
restTime = 120,
timestamp = "2024-01-01T10:30:00Z",
createdAt = "2024-01-01T10:30:00Z"
)
assertEquals("attempt123", attempt.id)
assertEquals("session123", attempt.sessionId)
assertEquals("problem123", attempt.problemId)
assertEquals(AttemptResult.SUCCESS, attempt.result)
assertEquals("Top", attempt.highestHold)
assertEquals(300L, attempt.duration)
assertEquals(120L, attempt.restTime)
}
@Test
fun testClimbDataBackupCreationAndValidation() {
val backup =
ClimbDataBackup(
exportedAt = "2024-01-01T10:00:00Z",
version = "2.0",
formatVersion = "2.0",
gyms = emptyList(),
problems = emptyList(),
sessions = emptyList(),
attempts = emptyList()
)
assertEquals("2.0", backup.version)
assertEquals("2.0", backup.formatVersion)
assertTrue(backup.gyms.isEmpty())
assertTrue(backup.problems.isEmpty())
assertTrue(backup.sessions.isEmpty())
assertTrue(backup.attempts.isEmpty())
}
@Test
fun testDateFormatValidation() {
val validDate = "2024-01-01T10:00:00Z"
val formatter = DateTimeFormatter.ISO_INSTANT
try {
val instant = Instant.from(formatter.parse(validDate))
assertNotNull(instant)
} catch (e: Exception) {
fail("Should not throw exception for valid date: $e")
}
}
@Test
fun testSessionDurationCalculation() {
val session =
BackupClimbSession(
id = "test",
gymId = "gym1",
date = "2024-01-01",
startTime = "2024-01-01T10:00:00Z",
endTime = "2024-01-01T12:00:00Z",
duration = 7200,
status = SessionStatus.COMPLETED,
notes = null,
createdAt = "2024-01-01T10:00:00Z",
updatedAt = "2024-01-01T12:00:00Z"
)
assertEquals(7200L, session.duration)
val hours = session.duration!! / 3600
assertEquals(2L, hours)
}
@Test
fun testEmptyCollectionsHandling() {
val gym =
BackupGym(
id = "gym1",
name = "Test Gym",
location = null,
supportedClimbTypes = emptyList(),
difficultySystems = emptyList(),
customDifficultyGrades = emptyList(),
notes = null,
createdAt = "2024-01-01T10:00:00Z",
updatedAt = "2024-01-01T10:00:00Z"
)
assertTrue(gym.supportedClimbTypes.isEmpty())
assertTrue(gym.difficultySystems.isEmpty())
assertTrue(gym.customDifficultyGrades.isEmpty())
assertNull(gym.location)
assertNull(gym.notes)
}
@Test
fun testNullableFieldsHandling() {
val problem =
BackupProblem(
id = "problem1",
gymId = "gym1",
name = null,
description = null,
climbType = ClimbType.BOULDER,
difficulty = DifficultyGrade(DifficultySystem.V_SCALE, "V1"),
tags = emptyList(),
location = null,
imagePaths = null,
isActive = true,
dateSet = null,
notes = null,
createdAt = "2024-01-01T10:00:00Z",
updatedAt = "2024-01-01T10:00:00Z"
)
assertNull(problem.name)
assertNull(problem.description)
assertNull(problem.location)
assertNull(problem.dateSet)
assertNull(problem.notes)
assertTrue(problem.tags.isEmpty())
assertNull(problem.imagePaths)
}
@Test
fun testUniqueIdGeneration() {
val id1 = java.util.UUID.randomUUID().toString()
val id2 = java.util.UUID.randomUUID().toString()
assertNotEquals(id1, id2)
assertEquals(36, id1.length)
assertTrue(id1.contains("-"))
}
@Test
fun testBackupDataFormatValidation() {
val testJson =
"""
{
"exportedAt": "2024-01-01T10:00:00Z",
"version": "2.0",
"formatVersion": "2.0",
"gyms": [],
"problems": [],
"sessions": [],
"attempts": []
}
""".trimIndent()
assertTrue(testJson.contains("exportedAt"))
assertTrue(testJson.contains("version"))
assertTrue(testJson.contains("gyms"))
assertTrue(testJson.contains("problems"))
assertTrue(testJson.contains("sessions"))
assertTrue(testJson.contains("attempts"))
}
@Test
fun testDateTimeFormatting() {
val currentTime = System.currentTimeMillis()
assertTrue(currentTime > 0)
val timeString = java.time.Instant.ofEpochMilli(currentTime).toString()
assertTrue(timeString.isNotEmpty())
assertTrue(timeString.contains("T"))
assertTrue(timeString.endsWith("Z"))
}
@Test
fun testClimbTypeAndDifficultySystemCompatibility() {
// Test that V_SCALE works with BOULDER
val boulderProblem =
BackupProblem(
id = "boulder1",
gymId = "gym1",
name = "Boulder Problem",
description = null,
climbType = ClimbType.BOULDER,
difficulty = DifficultyGrade(DifficultySystem.V_SCALE, "V3"),
tags = emptyList(),
location = null,
imagePaths = null,
isActive = true,
dateSet = null,
notes = null,
createdAt = "2024-01-01T10:00:00Z",
updatedAt = "2024-01-01T10:00:00Z"
)
assertEquals(ClimbType.BOULDER, boulderProblem.climbType)
assertEquals(DifficultySystem.V_SCALE, boulderProblem.difficulty.system)
// Test that YDS works with ROPE
val ropeProblem =
BackupProblem(
id = "rope1",
gymId = "gym1",
name = "Rope Problem",
description = null,
climbType = ClimbType.ROPE,
difficulty = DifficultyGrade(DifficultySystem.YDS, "5.10a"),
tags = emptyList(),
location = null,
imagePaths = null,
isActive = true,
dateSet = null,
notes = null,
createdAt = "2024-01-01T10:00:00Z",
updatedAt = "2024-01-01T10:00:00Z"
)
assertEquals(ClimbType.ROPE, ropeProblem.climbType)
assertEquals(DifficultySystem.YDS, ropeProblem.difficulty.system)
}
@Test
fun testStringOperations() {
val problemName = " Test Problem V5 "
val trimmedName = problemName.trim()
val uppercaseName = trimmedName.uppercase()
val lowercaseName = trimmedName.lowercase()
assertEquals("Test Problem V5", trimmedName)
assertEquals("TEST PROBLEM V5", uppercaseName)
assertEquals("test problem v5", lowercaseName)
val components = trimmedName.split(" ")
assertEquals(3, components.size)
assertEquals("V5", components.last())
}
@Test
fun testNumericOperations() {
val grades = listOf(3, 5, 7, 4, 6)
val sum = grades.sum()
val average = grades.average()
val maxGrade = grades.maxOrNull() ?: 0
val minGrade = grades.minOrNull() ?: 0
assertEquals(25, sum)
assertEquals(5.0, average, 0.01)
assertEquals(7, maxGrade)
assertEquals(3, minGrade)
}
@Test
fun testAttemptResultValidation() {
val validResults =
listOf(
AttemptResult.SUCCESS,
AttemptResult.FALL,
AttemptResult.NO_PROGRESS,
AttemptResult.FLASH
)
assertEquals(4, validResults.size)
assertTrue(validResults.contains(AttemptResult.SUCCESS))
assertTrue(validResults.contains(AttemptResult.FALL))
assertTrue(validResults.contains(AttemptResult.NO_PROGRESS))
assertTrue(validResults.contains(AttemptResult.FLASH))
}
@Test
fun testSessionStatusValidation() {
val validStatuses =
listOf(SessionStatus.ACTIVE, SessionStatus.COMPLETED, SessionStatus.PAUSED)
assertEquals(3, validStatuses.size)
assertTrue(validStatuses.contains(SessionStatus.ACTIVE))
assertTrue(validStatuses.contains(SessionStatus.COMPLETED))
assertTrue(validStatuses.contains(SessionStatus.PAUSED))
}
@Test
fun testClimbDataIntegrity() {
val gym =
BackupGym(
id = "gym1",
name = "Test Gym",
location = "Test City",
supportedClimbTypes = listOf(ClimbType.BOULDER),
difficultySystems = listOf(DifficultySystem.V_SCALE),
customDifficultyGrades = emptyList(),
notes = null,
createdAt = "2024-01-01T10:00:00Z",
updatedAt = "2024-01-01T10:00:00Z"
)
val problem =
BackupProblem(
id = "problem1",
gymId = gym.id,
name = "Test Problem",
description = null,
climbType = ClimbType.BOULDER,
difficulty = DifficultyGrade(DifficultySystem.V_SCALE, "V3"),
tags = emptyList(),
location = null,
imagePaths = null,
isActive = true,
dateSet = null,
notes = null,
createdAt = "2024-01-01T10:00:00Z",
updatedAt = "2024-01-01T10:00:00Z"
)
val session =
BackupClimbSession(
id = "session1",
gymId = gym.id,
date = "2024-01-01",
startTime = "2024-01-01T10:00:00Z",
endTime = "2024-01-01T11:00:00Z",
duration = 3600,
status = SessionStatus.COMPLETED,
notes = null,
createdAt = "2024-01-01T10:00:00Z",
updatedAt = "2024-01-01T11:00:00Z"
)
val attempt =
BackupAttempt(
id = "attempt1",
sessionId = session.id,
problemId = problem.id,
result = AttemptResult.SUCCESS,
highestHold = null,
notes = null,
duration = 120,
restTime = null,
timestamp = "2024-01-01T10:30:00Z",
createdAt = "2024-01-01T10:30:00Z"
)
// Verify referential integrity
assertEquals(gym.id, problem.gymId)
assertEquals(gym.id, session.gymId)
assertEquals(session.id, attempt.sessionId)
assertEquals(problem.id, attempt.problemId)
// Verify climb type compatibility
assertTrue(gym.supportedClimbTypes.contains(problem.climbType))
assertTrue(gym.difficultySystems.contains(problem.difficulty.system))
}
}

View File

@@ -1,17 +0,0 @@
package com.atridad.openclimb
import org.junit.Test
import org.junit.Assert.*
/**
* Example local unit test, which will execute on the development machine (host).
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
class ExampleUnitTest {
@Test
fun addition_isCorrect() {
assertEquals(4, 2 + 2)
}
}

View File

@@ -67,7 +67,7 @@ class SyncMergeLogicTest {
id = "attempt1",
sessionId = "session1",
problemId = "problem1",
result = AttemptResult.COMPLETED,
result = AttemptResult.SUCCESS,
highestHold = null,
notes = null,
duration = 300,
@@ -96,7 +96,7 @@ class SyncMergeLogicTest {
id = "gym1",
name = "Updated Gym 1",
location = "Updated Location",
supportedClimbTypes = listOf(ClimbType.BOULDER, ClimbType.SPORT),
supportedClimbTypes = listOf(ClimbType.BOULDER, ClimbType.ROPE),
difficultySystems =
listOf(DifficultySystem.V_SCALE, DifficultySystem.YDS),
customDifficultyGrades = emptyList(),
@@ -109,7 +109,7 @@ class SyncMergeLogicTest {
id = "gym2",
name = "Server Gym 2",
location = "Server Location",
supportedClimbTypes = listOf(ClimbType.TRAD),
supportedClimbTypes = listOf(ClimbType.ROPE),
difficultySystems = listOf(DifficultySystem.YDS),
customDifficultyGrades = emptyList(),
notes = null,
@@ -143,7 +143,7 @@ class SyncMergeLogicTest {
gymId = "gym2",
name = "Server Problem",
description = "Server description",
climbType = ClimbType.TRAD,
climbType = ClimbType.ROPE,
difficulty = DifficultyGrade(DifficultySystem.YDS, "5.10a"),
tags = listOf("server"),
location = null,
@@ -180,7 +180,7 @@ class SyncMergeLogicTest {
id = "attempt2",
sessionId = "session2",
problemId = "problem2",
result = AttemptResult.FELL,
result = AttemptResult.FALL,
highestHold = "Last move",
notes = "Almost had it",
duration = 180,

View File

@@ -0,0 +1,370 @@
package com.atridad.openclimb
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
import java.util.concurrent.TimeUnit
import org.junit.Assert.*
import org.junit.Test
class UtilityTests {
@Test
fun testDateTimeUtilities() {
val now = System.currentTimeMillis()
val dateTime = LocalDateTime.now()
assertTrue(now > 0)
assertNotNull(dateTime)
val formatted = dateTime.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME)
assertFalse(formatted.isEmpty())
assertTrue(formatted.contains("T"))
}
@Test
fun testDurationCalculations() {
val startTime = 1000L
val endTime = 4000L
val duration = endTime - startTime
assertEquals(3000L, duration)
val minutes = TimeUnit.MILLISECONDS.toMinutes(duration)
val seconds = TimeUnit.MILLISECONDS.toSeconds(duration)
assertEquals(0L, minutes)
assertEquals(3L, seconds)
}
@Test
fun testStringValidation() {
val validName = "Test Gym"
val emptyName = ""
val whitespaceName = " "
val nullName: String? = null
assertTrue(isValidString(validName))
assertFalse(isValidString(emptyName))
assertFalse(isValidString(whitespaceName))
assertFalse(isValidString(nullName))
}
@Test
fun testGradeConversion() {
val vGrade = "V5"
val ydsGrade = "5.10a"
val fontGrade = "6A"
assertTrue(isValidVGrade(vGrade))
assertTrue(isValidYDSGrade(ydsGrade))
assertTrue(isValidFontGrade(fontGrade))
assertFalse(isValidVGrade("Invalid"))
assertFalse(isValidYDSGrade("Invalid"))
assertFalse(isValidFontGrade("Invalid"))
}
@Test
fun testNumericGradeExtraction() {
assertEquals(0, extractVGradeNumber("VB"))
assertEquals(5, extractVGradeNumber("V5"))
assertEquals(12, extractVGradeNumber("V12"))
assertEquals(-1, extractVGradeNumber("Invalid"))
}
@Test
fun testClimbingStatistics() {
val attempts =
listOf(
AttemptData("SUCCESS", 120),
AttemptData("FALL", 90),
AttemptData("SUCCESS", 150),
AttemptData("FLASH", 60),
AttemptData("FALL", 110)
)
val stats = calculateAttemptStatistics(attempts)
assertEquals(5, stats.totalAttempts)
assertEquals(3, stats.successfulAttempts)
assertEquals(60.0, stats.successRate, 0.01)
assertEquals(106.0, stats.averageDuration, 0.01)
}
@Test
fun testSessionDurationFormatting() {
assertEquals("0m", formatDuration(0))
assertEquals("1m", formatDuration(60))
assertEquals("1h 30m", formatDuration(5400))
assertEquals("2h", formatDuration(7200))
assertEquals("2h 5m", formatDuration(7500))
}
@Test
fun testDifficultyComparison() {
assertTrue(compareVGrades("V3", "V5") < 0)
assertTrue(compareVGrades("V5", "V3") > 0)
assertEquals(0, compareVGrades("V5", "V5"))
assertTrue(compareVGrades("VB", "V1") < 0)
assertTrue(compareVGrades("V1", "VB") > 0)
}
@Test
fun testClimbTypeValidation() {
val validTypes = listOf("BOULDER", "ROPE")
val invalidTypes = listOf("INVALID", "", "sport", "trad")
validTypes.forEach { type -> assertTrue("$type should be valid", isValidClimbType(type)) }
invalidTypes.forEach { type ->
assertFalse("$type should be invalid", isValidClimbType(type))
}
}
@Test
fun testImagePathValidation() {
val validPaths = listOf("image.jpg", "photo.jpeg", "picture.png", "diagram.webp")
val invalidPaths = listOf("", "file.txt", "document.pdf", "video.mp4")
validPaths.forEach { path ->
assertTrue("$path should be valid image", isValidImagePath(path))
}
invalidPaths.forEach { path ->
assertFalse("$path should be invalid image", isValidImagePath(path))
}
}
@Test
fun testLocationValidation() {
assertTrue(isValidLocation("Wall A"))
assertTrue(isValidLocation("Area 51"))
assertTrue(isValidLocation("Overhang Section"))
assertFalse(isValidLocation(""))
assertFalse(isValidLocation(" "))
assertFalse(isValidLocation(null))
}
@Test
fun testTagProcessing() {
val rawTags = "overhang, crimpy, technical,DYNAMIC "
val processedTags = processTags(rawTags)
assertEquals(4, processedTags.size)
assertTrue(processedTags.contains("overhang"))
assertTrue(processedTags.contains("crimpy"))
assertTrue(processedTags.contains("technical"))
assertTrue(processedTags.contains("dynamic"))
}
@Test
fun testSearchFiltering() {
val problems =
listOf(
ProblemData(
"id1",
"Crimpy Problem",
"BOULDER",
"V5",
listOf("crimpy", "overhang")
),
ProblemData("id2", "Easy Route", "ROPE", "5.6", listOf("beginner", "slab")),
ProblemData(
"id3",
"Hard Boulder",
"BOULDER",
"V10",
listOf("powerful", "roof")
)
)
val boulderProblems = filterByClimbType(problems, "BOULDER")
assertEquals(2, boulderProblems.size)
val crimpyProblems = filterByTag(problems, "crimpy")
assertEquals(1, crimpyProblems.size)
val easyProblems = filterByDifficultyRange(problems, "VB", "V6")
assertEquals(2, easyProblems.size)
}
@Test
fun testDataSynchronization() {
val localData = mapOf("key1" to "local_value", "key2" to "shared_value")
val serverData = mapOf("key2" to "server_value", "key3" to "new_value")
val merged = mergeData(localData, serverData)
assertEquals(3, merged.size)
assertEquals("local_value", merged["key1"])
assertEquals("server_value", merged["key2"]) // Server wins
assertEquals("new_value", merged["key3"])
}
@Test
fun testBackupValidation() {
val validBackup =
BackupData(
version = "2.0",
formatVersion = "2.0",
exportedAt = "2024-01-01T10:00:00Z",
dataCount = 5
)
val invalidBackup =
BackupData(
version = "1.0",
formatVersion = "2.0",
exportedAt = "invalid-date",
dataCount = -1
)
assertTrue(isValidBackup(validBackup))
assertFalse(isValidBackup(invalidBackup))
}
// Helper functions and data classes
private fun isValidString(str: String?): Boolean {
return str != null && str.trim().isNotEmpty()
}
private fun isValidVGrade(grade: String): Boolean {
return grade.matches(Regex("^V(B|[0-9]|1[0-7])$"))
}
private fun isValidYDSGrade(grade: String): Boolean {
return grade.matches(Regex("^5\\.[0-9]+([abcd])?$"))
}
private fun isValidFontGrade(grade: String): Boolean {
return grade.matches(Regex("^[3-8][ABC]?\\+?$"))
}
private fun extractVGradeNumber(grade: String): Int {
return when {
grade == "VB" -> 0
grade.startsWith("V") -> grade.substring(1).toIntOrNull() ?: -1
else -> -1
}
}
private fun calculateAttemptStatistics(attempts: List<AttemptData>): AttemptStatistics {
val successful = attempts.count { it.result == "SUCCESS" || it.result == "FLASH" }
val avgDuration = attempts.map { it.duration }.average()
val successRate = (successful.toDouble() / attempts.size) * 100
return AttemptStatistics(
totalAttempts = attempts.size,
successfulAttempts = successful,
successRate = successRate,
averageDuration = avgDuration
)
}
private fun formatDuration(seconds: Long): String {
val hours = seconds / 3600
val minutes = (seconds % 3600) / 60
return when {
hours > 0 && minutes > 0 -> "${hours}h ${minutes}m"
hours > 0 -> "${hours}h"
minutes > 0 -> "${minutes}m"
else -> "0m"
}
}
private fun compareVGrades(grade1: String, grade2: String): Int {
val num1 = extractVGradeNumber(grade1)
val num2 = extractVGradeNumber(grade2)
return num1.compareTo(num2)
}
private fun isValidClimbType(type: String): Boolean {
return type in listOf("BOULDER", "ROPE")
}
private fun isValidImagePath(path: String): Boolean {
val validExtensions = listOf(".jpg", ".jpeg", ".png", ".webp")
return path.isNotEmpty() && validExtensions.any { path.endsWith(it, ignoreCase = true) }
}
private fun isValidLocation(location: String?): Boolean {
return isValidString(location)
}
private fun processTags(rawTags: String): List<String> {
return rawTags.split(",").map { it.trim().lowercase() }.filter { it.isNotEmpty() }
}
private fun filterByClimbType(
problems: List<ProblemData>,
climbType: String
): List<ProblemData> {
return problems.filter { it.climbType == climbType }
}
private fun filterByTag(problems: List<ProblemData>, tag: String): List<ProblemData> {
return problems.filter { it.tags.contains(tag) }
}
private fun filterByDifficultyRange(
problems: List<ProblemData>,
minGrade: String,
maxGrade: String
): List<ProblemData> {
return problems.filter { problem ->
if (problem.climbType == "BOULDER" && problem.difficulty.startsWith("V")) {
val gradeNum = extractVGradeNumber(problem.difficulty)
val minNum = extractVGradeNumber(minGrade)
val maxNum = extractVGradeNumber(maxGrade)
gradeNum in minNum..maxNum
} else {
true // Simplified for other grade systems
}
}
}
private fun mergeData(
local: Map<String, String>,
server: Map<String, String>
): Map<String, String> {
return (local.keys + server.keys).associateWith { key -> server[key] ?: local[key]!! }
}
private fun isValidBackup(backup: BackupData): Boolean {
return backup.version == "2.0" &&
backup.formatVersion == "2.0" &&
backup.exportedAt.matches(Regex("\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}Z")) &&
backup.dataCount >= 0
}
// Data classes for testing
data class AttemptData(val result: String, val duration: Int)
data class AttemptStatistics(
val totalAttempts: Int,
val successfulAttempts: Int,
val successRate: Double,
val averageDuration: Double
)
data class ProblemData(
val id: String,
val name: String,
val climbType: String,
val difficulty: String,
val tags: List<String>
)
data class BackupData(
val version: String,
val formatVersion: String,
val exportedAt: String,
val dataCount: Int
)
}