Added a proper set of Unit Tests for each sub-project
All checks were successful
OpenClimb Docker Deploy / build-and-push (push) Successful in 2m28s
All checks were successful
OpenClimb Docker Deploy / build-and-push (push) Successful in 2m28s
This commit is contained in:
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,603 @@
|
||||
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
|
||||
|
||||
/**
|
||||
* Business logic and integration tests for OpenClimb Android app. Tests complex workflows, business
|
||||
* rules, and data relationships.
|
||||
*/
|
||||
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() }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,575 @@
|
||||
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
|
||||
|
||||
/**
|
||||
* Comprehensive unit tests for OpenClimb Android data models and utilities. These tests verify core
|
||||
* functionality without requiring Android context.
|
||||
*/
|
||||
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))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
374
android/app/src/test/java/com/atridad/openclimb/UtilityTests.kt
Normal file
374
android/app/src/test/java/com/atridad/openclimb/UtilityTests.kt
Normal file
@@ -0,0 +1,374 @@
|
||||
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
|
||||
|
||||
/**
|
||||
* Comprehensive utility and service tests for OpenClimb Android app. Tests core utility functions,
|
||||
* date handling, string operations, and business logic.
|
||||
*/
|
||||
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
|
||||
)
|
||||
}
|
||||
@@ -19,7 +19,7 @@ kotlinxSerialization = "1.9.0"
|
||||
kotlinxCoroutines = "1.10.2"
|
||||
coil = "2.7.0"
|
||||
ksp = "2.2.10-2.0.2"
|
||||
okhttp = "4.12.0"
|
||||
okhttp = "5.1.0"
|
||||
|
||||
[libraries]
|
||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
||||
@@ -61,16 +61,11 @@ kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-
|
||||
kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinxCoroutines" }
|
||||
|
||||
# Testing
|
||||
mockk = { group = "io.mockk", name = "mockk", version = "1.14.5" }
|
||||
mockk = { group = "io.mockk", name = "mockk", version = "1.14.6" }
|
||||
|
||||
# Image Loading
|
||||
coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" }
|
||||
|
||||
# HTTP Client
|
||||
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
|
||||
|
||||
|
||||
|
||||
[plugins]
|
||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
|
||||
|
||||
BIN
android/gradle/wrapper/gradle-wrapper.jar
vendored
BIN
android/gradle/wrapper/gradle-wrapper.jar
vendored
Binary file not shown.
@@ -1,6 +1,7 @@
|
||||
#Fri Aug 15 11:23:25 MDT 2025
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip
|
||||
networkTimeout=10000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
|
||||
295
android/gradlew
vendored
295
android/gradlew
vendored
@@ -1,7 +1,7 @@
|
||||
#!/usr/bin/env sh
|
||||
#!/bin/sh
|
||||
|
||||
#
|
||||
# Copyright 2015 the original author or authors.
|
||||
# Copyright © 2015 the original authors.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
@@ -15,81 +15,114 @@
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
#
|
||||
|
||||
##############################################################################
|
||||
##
|
||||
## Gradle start up script for UN*X
|
||||
##
|
||||
#
|
||||
# Gradle start up script for POSIX generated by Gradle.
|
||||
#
|
||||
# Important for running:
|
||||
#
|
||||
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||
# noncompliant, but you have some other compliant shell such as ksh or
|
||||
# bash, then to run this script, type that shell name before the whole
|
||||
# command line, like:
|
||||
#
|
||||
# ksh Gradle
|
||||
#
|
||||
# Busybox and similar reduced shells will NOT work, because this script
|
||||
# requires all of these POSIX shell features:
|
||||
# * functions;
|
||||
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||
# * compound commands having a testable exit status, especially «case»;
|
||||
# * various built-in commands including «command», «set», and «ulimit».
|
||||
#
|
||||
# Important for patching:
|
||||
#
|
||||
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||
#
|
||||
# The "traditional" practice of packing multiple parameters into a
|
||||
# space-separated string is a well documented source of bugs and security
|
||||
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||
# options in "$@", and eventually passing that to Java.
|
||||
#
|
||||
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||
# see the in-line comments for details.
|
||||
#
|
||||
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||
# Darwin, MinGW, and NonStop.
|
||||
#
|
||||
# (3) This script is generated from the Groovy template
|
||||
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||
# within the Gradle project.
|
||||
#
|
||||
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||
#
|
||||
##############################################################################
|
||||
|
||||
# Attempt to set APP_HOME
|
||||
|
||||
# Resolve links: $0 may be a link
|
||||
PRG="$0"
|
||||
# Need this for relative symlinks.
|
||||
while [ -h "$PRG" ] ; do
|
||||
ls=`ls -ld "$PRG"`
|
||||
link=`expr "$ls" : '.*-> \(.*\)$'`
|
||||
if expr "$link" : '/.*' > /dev/null; then
|
||||
PRG="$link"
|
||||
else
|
||||
PRG=`dirname "$PRG"`"/$link"
|
||||
fi
|
||||
app_path=$0
|
||||
|
||||
# Need this for daisy-chained symlinks.
|
||||
while
|
||||
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||
[ -h "$app_path" ]
|
||||
do
|
||||
ls=$( ls -ld "$app_path" )
|
||||
link=${ls#*' -> '}
|
||||
case $link in #(
|
||||
/*) app_path=$link ;; #(
|
||||
*) app_path=$APP_HOME$link ;;
|
||||
esac
|
||||
done
|
||||
SAVED="`pwd`"
|
||||
cd "`dirname \"$PRG\"`/" >/dev/null
|
||||
APP_HOME="`pwd -P`"
|
||||
cd "$SAVED" >/dev/null
|
||||
|
||||
APP_NAME="Gradle"
|
||||
APP_BASE_NAME=`basename "$0"`
|
||||
|
||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||
# This is normally unused
|
||||
# shellcheck disable=SC2034
|
||||
APP_BASE_NAME=${0##*/}
|
||||
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
|
||||
|
||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||
MAX_FD="maximum"
|
||||
MAX_FD=maximum
|
||||
|
||||
warn () {
|
||||
echo "$*"
|
||||
}
|
||||
} >&2
|
||||
|
||||
die () {
|
||||
echo
|
||||
echo "$*"
|
||||
echo
|
||||
exit 1
|
||||
}
|
||||
} >&2
|
||||
|
||||
# OS specific support (must be 'true' or 'false').
|
||||
cygwin=false
|
||||
msys=false
|
||||
darwin=false
|
||||
nonstop=false
|
||||
case "`uname`" in
|
||||
CYGWIN* )
|
||||
cygwin=true
|
||||
;;
|
||||
Darwin* )
|
||||
darwin=true
|
||||
;;
|
||||
MINGW* )
|
||||
msys=true
|
||||
;;
|
||||
NONSTOP* )
|
||||
nonstop=true
|
||||
;;
|
||||
case "$( uname )" in #(
|
||||
CYGWIN* ) cygwin=true ;; #(
|
||||
Darwin* ) darwin=true ;; #(
|
||||
MSYS* | MINGW* ) msys=true ;; #(
|
||||
NONSTOP* ) nonstop=true ;;
|
||||
esac
|
||||
|
||||
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
|
||||
|
||||
|
||||
# Determine the Java command to use to start the JVM.
|
||||
if [ -n "$JAVA_HOME" ] ; then
|
||||
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||
# IBM's JDK on AIX uses strange locations for the executables
|
||||
JAVACMD="$JAVA_HOME/jre/sh/java"
|
||||
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||
else
|
||||
JAVACMD="$JAVA_HOME/bin/java"
|
||||
JAVACMD=$JAVA_HOME/bin/java
|
||||
fi
|
||||
if [ ! -x "$JAVACMD" ] ; then
|
||||
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||
@@ -98,88 +131,118 @@ Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
else
|
||||
JAVACMD="java"
|
||||
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
JAVACMD=java
|
||||
if ! command -v java >/dev/null 2>&1
|
||||
then
|
||||
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
fi
|
||||
|
||||
# Increase the maximum file descriptors if we can.
|
||||
if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
|
||||
MAX_FD_LIMIT=`ulimit -H -n`
|
||||
if [ $? -eq 0 ] ; then
|
||||
if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
|
||||
MAX_FD="$MAX_FD_LIMIT"
|
||||
fi
|
||||
ulimit -n $MAX_FD
|
||||
if [ $? -ne 0 ] ; then
|
||||
warn "Could not set maximum file descriptor limit: $MAX_FD"
|
||||
fi
|
||||
else
|
||||
warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
|
||||
fi
|
||||
fi
|
||||
|
||||
# For Darwin, add options to specify how the application appears in the dock
|
||||
if $darwin; then
|
||||
GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
|
||||
fi
|
||||
|
||||
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||
if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
|
||||
APP_HOME=`cygpath --path --mixed "$APP_HOME"`
|
||||
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
|
||||
|
||||
JAVACMD=`cygpath --unix "$JAVACMD"`
|
||||
|
||||
# We build the pattern for arguments to be converted via cygpath
|
||||
ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
|
||||
SEP=""
|
||||
for dir in $ROOTDIRSRAW ; do
|
||||
ROOTDIRS="$ROOTDIRS$SEP$dir"
|
||||
SEP="|"
|
||||
done
|
||||
OURCYGPATTERN="(^($ROOTDIRS))"
|
||||
# Add a user-defined pattern to the cygpath arguments
|
||||
if [ "$GRADLE_CYGPATTERN" != "" ] ; then
|
||||
OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
|
||||
fi
|
||||
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||
i=0
|
||||
for arg in "$@" ; do
|
||||
CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
|
||||
CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
|
||||
|
||||
if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
|
||||
eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
|
||||
else
|
||||
eval `echo args$i`="\"$arg\""
|
||||
fi
|
||||
i=`expr $i + 1`
|
||||
done
|
||||
case $i in
|
||||
0) set -- ;;
|
||||
1) set -- "$args0" ;;
|
||||
2) set -- "$args0" "$args1" ;;
|
||||
3) set -- "$args0" "$args1" "$args2" ;;
|
||||
4) set -- "$args0" "$args1" "$args2" "$args3" ;;
|
||||
5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
|
||||
6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
|
||||
7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
|
||||
8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
|
||||
9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
|
||||
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||
case $MAX_FD in #(
|
||||
max*)
|
||||
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
MAX_FD=$( ulimit -H -n ) ||
|
||||
warn "Could not query maximum file descriptor limit"
|
||||
esac
|
||||
case $MAX_FD in #(
|
||||
'' | soft) :;; #(
|
||||
*)
|
||||
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
ulimit -n "$MAX_FD" ||
|
||||
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||
esac
|
||||
fi
|
||||
|
||||
# Escape application args
|
||||
save () {
|
||||
for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
|
||||
echo " "
|
||||
}
|
||||
APP_ARGS=`save "$@"`
|
||||
# Collect all arguments for the java command, stacking in reverse order:
|
||||
# * args from the command line
|
||||
# * the main class name
|
||||
# * -classpath
|
||||
# * -D...appname settings
|
||||
# * --module-path (only if needed)
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||
|
||||
# Collect all arguments for the java command, following the shell quoting and substitution rules
|
||||
eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
|
||||
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||
if "$cygwin" || "$msys" ; then
|
||||
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||
|
||||
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||
|
||||
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||
for arg do
|
||||
if
|
||||
case $arg in #(
|
||||
-*) false ;; # don't mess with options #(
|
||||
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||
[ -e "$t" ] ;; #(
|
||||
*) false ;;
|
||||
esac
|
||||
then
|
||||
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||
fi
|
||||
# Roll the args list around exactly as many times as the number of
|
||||
# args, so each arg winds up back in the position where it started, but
|
||||
# possibly modified.
|
||||
#
|
||||
# NB: a `for` loop captures its iteration list before it begins, so
|
||||
# changing the positional parameters here affects neither the number of
|
||||
# iterations, nor the values presented in `arg`.
|
||||
shift # remove old arg
|
||||
set -- "$@" "$arg" # push replacement arg
|
||||
done
|
||||
fi
|
||||
|
||||
|
||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||
|
||||
# Collect all arguments for the java command:
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||
# and any embedded shellness will be escaped.
|
||||
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||
# treated as '${Hostname}' itself on the command line.
|
||||
|
||||
set -- \
|
||||
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
|
||||
"$@"
|
||||
|
||||
# Stop when "xargs" is not available.
|
||||
if ! command -v xargs >/dev/null 2>&1
|
||||
then
|
||||
die "xargs is not available"
|
||||
fi
|
||||
|
||||
# Use "xargs" to parse quoted args.
|
||||
#
|
||||
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||
#
|
||||
# In Bash we could simply go:
|
||||
#
|
||||
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||
# set -- "${ARGS[@]}" "$@"
|
||||
#
|
||||
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||
# character that might be a shell metacharacter, then use eval to reverse
|
||||
# that process (while maintaining the separation between arguments), and wrap
|
||||
# the whole thing up as a single "set" statement.
|
||||
#
|
||||
# This will of course break if any of these variables contains a newline or
|
||||
# an unmatched quote.
|
||||
#
|
||||
|
||||
eval "set -- $(
|
||||
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||
xargs -n1 |
|
||||
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||
tr '\n' ' '
|
||||
)" '"$@"'
|
||||
|
||||
exec "$JAVACMD" "$@"
|
||||
|
||||
40
android/gradlew.bat
vendored
40
android/gradlew.bat
vendored
@@ -13,8 +13,10 @@
|
||||
@rem See the License for the specific language governing permissions and
|
||||
@rem limitations under the License.
|
||||
@rem
|
||||
@rem SPDX-License-Identifier: Apache-2.0
|
||||
@rem
|
||||
|
||||
@if "%DEBUG%" == "" @echo off
|
||||
@if "%DEBUG%"=="" @echo off
|
||||
@rem ##########################################################################
|
||||
@rem
|
||||
@rem Gradle startup script for Windows
|
||||
@@ -25,7 +27,8 @@
|
||||
if "%OS%"=="Windows_NT" setlocal
|
||||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%" == "" set DIRNAME=.
|
||||
if "%DIRNAME%"=="" set DIRNAME=.
|
||||
@rem This is normally unused
|
||||
set APP_BASE_NAME=%~n0
|
||||
set APP_HOME=%DIRNAME%
|
||||
|
||||
@@ -40,13 +43,13 @@ if defined JAVA_HOME goto findJavaFromJavaHome
|
||||
|
||||
set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if "%ERRORLEVEL%" == "0" goto execute
|
||||
if %ERRORLEVEL% equ 0 goto execute
|
||||
|
||||
echo.
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
echo.
|
||||
echo Please set the JAVA_HOME variable in your environment to match the
|
||||
echo location of your Java installation.
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
@@ -56,32 +59,33 @@ set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||
|
||||
if exist "%JAVA_EXE%" goto execute
|
||||
|
||||
echo.
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
|
||||
echo.
|
||||
echo Please set the JAVA_HOME variable in your environment to match the
|
||||
echo location of your Java installation.
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
|
||||
|
||||
|
||||
@rem Execute Gradle
|
||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
|
||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
|
||||
|
||||
:end
|
||||
@rem End local scope for the variables with windows NT shell
|
||||
if "%ERRORLEVEL%"=="0" goto mainEnd
|
||||
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||
|
||||
:fail
|
||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||
rem the _cmd.exe /c_ return code!
|
||||
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
|
||||
exit /b 1
|
||||
set EXIT_CODE=%ERRORLEVEL%
|
||||
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||
exit /b %EXIT_CODE%
|
||||
|
||||
:mainEnd
|
||||
if "%OS%"=="Windows_NT" endlocal
|
||||
|
||||
@@ -1,383 +0,0 @@
|
||||
package com.atridad.openclimb.data.repository
|
||||
|
||||
import android.content.Context
|
||||
import com.atridad.openclimb.data.database.OpenClimbDatabase
|
||||
import com.atridad.openclimb.data.format.ClimbDataBackup
|
||||
import com.atridad.openclimb.data.model.*
|
||||
import com.atridad.openclimb.utils.ZipExportImportUtils
|
||||
import java.io.File
|
||||
import java.time.LocalDateTime
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
class ClimbRepository(database: OpenClimbDatabase, private val context: Context) {
|
||||
private val gymDao = database.gymDao()
|
||||
private val problemDao = database.problemDao()
|
||||
private val sessionDao = database.climbSessionDao()
|
||||
private val attemptDao = database.attemptDao()
|
||||
|
||||
private val json = Json {
|
||||
prettyPrint = true
|
||||
ignoreUnknownKeys = true
|
||||
}
|
||||
|
||||
// Gym operations
|
||||
fun getAllGyms(): Flow<List<Gym>> = gymDao.getAllGyms()
|
||||
suspend fun getGymById(id: String): Gym? = gymDao.getGymById(id)
|
||||
suspend fun insertGym(gym: Gym) = gymDao.insertGym(gym)
|
||||
suspend fun updateGym(gym: Gym) = gymDao.updateGym(gym)
|
||||
suspend fun deleteGym(gym: Gym) = gymDao.deleteGym(gym)
|
||||
fun searchGyms(query: String): Flow<List<Gym>> = gymDao.searchGyms(query)
|
||||
|
||||
// Problem operations
|
||||
fun getAllProblems(): Flow<List<Problem>> = problemDao.getAllProblems()
|
||||
suspend fun getProblemById(id: String): Problem? = problemDao.getProblemById(id)
|
||||
fun getProblemsByGym(gymId: String): Flow<List<Problem>> = problemDao.getProblemsByGym(gymId)
|
||||
suspend fun insertProblem(problem: Problem) = problemDao.insertProblem(problem)
|
||||
suspend fun updateProblem(problem: Problem) = problemDao.updateProblem(problem)
|
||||
suspend fun deleteProblem(problem: Problem) = problemDao.deleteProblem(problem)
|
||||
fun searchProblems(query: String): Flow<List<Problem>> = problemDao.searchProblems(query)
|
||||
|
||||
// Session operations
|
||||
fun getAllSessions(): Flow<List<ClimbSession>> = sessionDao.getAllSessions()
|
||||
suspend fun getSessionById(id: String): ClimbSession? = sessionDao.getSessionById(id)
|
||||
fun getSessionsByGym(gymId: String): Flow<List<ClimbSession>> =
|
||||
sessionDao.getSessionsByGym(gymId)
|
||||
suspend fun getActiveSession(): ClimbSession? = sessionDao.getActiveSession()
|
||||
fun getActiveSessionFlow(): Flow<ClimbSession?> = sessionDao.getActiveSessionFlow()
|
||||
suspend fun insertSession(session: ClimbSession) = sessionDao.insertSession(session)
|
||||
suspend fun updateSession(session: ClimbSession) = sessionDao.updateSession(session)
|
||||
suspend fun deleteSession(session: ClimbSession) = sessionDao.deleteSession(session)
|
||||
suspend fun getLastUsedGym(): Gym? {
|
||||
val recentSessions = sessionDao.getRecentSessions(1).first()
|
||||
return if (recentSessions.isNotEmpty()) {
|
||||
getGymById(recentSessions.first().gymId)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
// Attempt operations
|
||||
fun getAllAttempts(): Flow<List<Attempt>> = attemptDao.getAllAttempts()
|
||||
fun getAttemptsBySession(sessionId: String): Flow<List<Attempt>> =
|
||||
attemptDao.getAttemptsBySession(sessionId)
|
||||
fun getAttemptsByProblem(problemId: String): Flow<List<Attempt>> =
|
||||
attemptDao.getAttemptsByProblem(problemId)
|
||||
suspend fun insertAttempt(attempt: Attempt) = attemptDao.insertAttempt(attempt)
|
||||
suspend fun updateAttempt(attempt: Attempt) = attemptDao.updateAttempt(attempt)
|
||||
suspend fun deleteAttempt(attempt: Attempt) = attemptDao.deleteAttempt(attempt)
|
||||
|
||||
// ZIP Export with images - Single format for reliability
|
||||
suspend fun exportAllDataToZip(directory: File? = null): File {
|
||||
return try {
|
||||
// Collect all data with proper error handling
|
||||
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 = LocalDateTime.now().toString(),
|
||||
version = "2.0",
|
||||
formatVersion = "2.0",
|
||||
gyms =
|
||||
allGyms.map {
|
||||
com.atridad.openclimb.data.format.BackupGym.fromGym(it)
|
||||
},
|
||||
problems =
|
||||
allProblems.map {
|
||||
com.atridad.openclimb.data.format.BackupProblem.fromProblem(
|
||||
it
|
||||
)
|
||||
},
|
||||
sessions =
|
||||
allSessions.map {
|
||||
com.atridad.openclimb.data.format.BackupClimbSession
|
||||
.fromClimbSession(it)
|
||||
},
|
||||
attempts =
|
||||
allAttempts.map {
|
||||
com.atridad.openclimb.data.format.BackupAttempt.fromAttempt(
|
||||
it
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
// Collect all referenced image paths and validate they exist
|
||||
val referencedImagePaths = allProblems.flatMap { it.imagePaths }.toSet()
|
||||
val validImagePaths =
|
||||
referencedImagePaths
|
||||
.filter { imagePath ->
|
||||
try {
|
||||
val imageFile =
|
||||
com.atridad.openclimb.utils.ImageUtils.getImageFile(
|
||||
context,
|
||||
imagePath
|
||||
)
|
||||
imageFile.exists() && imageFile.length() > 0
|
||||
} catch (e: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
.toSet()
|
||||
|
||||
// Log any missing images for debugging
|
||||
val missingImages = referencedImagePaths - validImagePaths
|
||||
if (missingImages.isNotEmpty()) {
|
||||
android.util.Log.w(
|
||||
"ClimbRepository",
|
||||
"Some referenced images are missing: $missingImages"
|
||||
)
|
||||
}
|
||||
|
||||
ZipExportImportUtils.createExportZip(
|
||||
context = context,
|
||||
exportData = backupData,
|
||||
referencedImagePaths = validImagePaths,
|
||||
directory = directory
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
throw Exception("Export failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun exportAllDataToZipUri(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 = LocalDateTime.now().toString(),
|
||||
version = "2.0",
|
||||
formatVersion = "2.0",
|
||||
gyms =
|
||||
allGyms.map {
|
||||
com.atridad.openclimb.data.format.BackupGym.fromGym(it)
|
||||
},
|
||||
problems =
|
||||
allProblems.map {
|
||||
com.atridad.openclimb.data.format.BackupProblem.fromProblem(
|
||||
it
|
||||
)
|
||||
},
|
||||
sessions =
|
||||
allSessions.map {
|
||||
com.atridad.openclimb.data.format.BackupClimbSession
|
||||
.fromClimbSession(it)
|
||||
},
|
||||
attempts =
|
||||
allAttempts.map {
|
||||
com.atridad.openclimb.data.format.BackupAttempt.fromAttempt(
|
||||
it
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
// Collect all referenced image paths and validate they exist
|
||||
val referencedImagePaths = allProblems.flatMap { it.imagePaths }.toSet()
|
||||
val validImagePaths =
|
||||
referencedImagePaths
|
||||
.filter { imagePath ->
|
||||
try {
|
||||
val imageFile =
|
||||
com.atridad.openclimb.utils.ImageUtils.getImageFile(
|
||||
context,
|
||||
imagePath
|
||||
)
|
||||
imageFile.exists() && imageFile.length() > 0
|
||||
} catch (e: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
.toSet()
|
||||
|
||||
ZipExportImportUtils.createExportZipToUri(
|
||||
context = context,
|
||||
uri = uri,
|
||||
exportData = backupData,
|
||||
referencedImagePaths = validImagePaths
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
throw Exception("Export failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
} catch (e: Exception) {
|
||||
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)
|
||||
importData.gyms.forEach { backupGym ->
|
||||
try {
|
||||
gymDao.insertGym(backupGym.toGym())
|
||||
} catch (e: Exception) {
|
||||
throw Exception("Failed to import gym '${backupGym.name}': ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
// Import problems with updated image paths
|
||||
val updatedBackupProblems =
|
||||
ZipExportImportUtils.updateProblemImagePaths(
|
||||
importData.problems,
|
||||
importResult.importedImagePaths
|
||||
)
|
||||
|
||||
// Import problems (depends on gyms)
|
||||
updatedBackupProblems.forEach { backupProblem ->
|
||||
try {
|
||||
problemDao.insertProblem(backupProblem.toProblem())
|
||||
} catch (e: Exception) {
|
||||
throw Exception(
|
||||
"Failed to import problem '${backupProblem.name}': ${e.message}"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Import sessions
|
||||
importData.sessions.forEach { backupSession ->
|
||||
try {
|
||||
sessionDao.insertSession(backupSession.toClimbSession())
|
||||
} catch (e: Exception) {
|
||||
throw Exception("Failed to import session '${backupSession.id}': ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
// Import attempts last (depends on problems and sessions)
|
||||
importData.attempts.forEach { backupAttempt ->
|
||||
try {
|
||||
attemptDao.insertAttempt(backupAttempt.toAttempt())
|
||||
} catch (e: Exception) {
|
||||
throw Exception("Failed to import attempt '${backupAttempt.id}': ${e.message}")
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
throw Exception("Import failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun validateDataIntegrity(
|
||||
gyms: List<Gym>,
|
||||
problems: List<Problem>,
|
||||
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()) {
|
||||
throw Exception(
|
||||
"Data integrity error: ${invalidProblems.size} problems reference non-existent gyms"
|
||||
)
|
||||
}
|
||||
|
||||
// Validate that all sessions reference valid gyms
|
||||
val invalidSessions = sessions.filter { it.gymId !in gymIds }
|
||||
if (invalidSessions.isNotEmpty()) {
|
||||
throw Exception(
|
||||
"Data integrity error: ${invalidSessions.size} sessions reference non-existent gyms"
|
||||
)
|
||||
}
|
||||
|
||||
// Validate that all attempts reference valid problems and sessions
|
||||
val problemIds = problems.map { it.id }.toSet()
|
||||
val sessionIds = sessions.map { it.id }.toSet()
|
||||
|
||||
val invalidAttempts =
|
||||
attempts.filter { it.problemId !in problemIds || it.sessionId !in sessionIds }
|
||||
if (invalidAttempts.isNotEmpty()) {
|
||||
throw Exception(
|
||||
"Data integrity error: ${invalidAttempts.size} attempts reference non-existent problems or sessions"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun validateImportData(importData: ClimbDataBackup) {
|
||||
if (importData.gyms.isEmpty()) {
|
||||
throw Exception("Import data is invalid: no gyms found")
|
||||
}
|
||||
|
||||
if (importData.version.isBlank()) {
|
||||
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 ||
|
||||
importData.attempts.size > 100000
|
||||
) {
|
||||
throw Exception("Import data is too large: possible corruption or malicious file")
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun resetAllData() {
|
||||
try {
|
||||
// Clear all data from database
|
||||
attemptDao.deleteAllAttempts()
|
||||
sessionDao.deleteAllSessions()
|
||||
problemDao.deleteAllProblems()
|
||||
gymDao.deleteAllGyms()
|
||||
|
||||
// Clear all images from storage
|
||||
clearAllImages()
|
||||
} catch (e: Exception) {
|
||||
throw Exception("Reset failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
imagesDir.deleteRecursively()
|
||||
android.util.Log.i("ClimbRepository", "Cleared $deletedCount image files")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.w("ClimbRepository", "Failed to clear some images: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user