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

This commit is contained in:
2025-10-03 20:55:04 -06:00
parent 4e42985135
commit 4bbd422c09
33 changed files with 3158 additions and 833 deletions

View File

@@ -5,11 +5,6 @@ on:
paths:
- "sync/**"
- ".github/workflows/deploy.yml"
pull_request:
branches: [main]
paths:
- "sync/**"
- ".github/workflows/deploy.yml"
jobs:
build-and-push:

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

@@ -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

@@ -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() }
}
}
}

View File

@@ -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))
}
}

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,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
)
}

View File

@@ -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" }

Binary file not shown.

View File

@@ -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
View File

@@ -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
View File

@@ -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

View File

@@ -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}")
}
}
}

View File

@@ -20,7 +20,7 @@ struct ClimbingActivityWidget: Widget {
DynamicIsland {
// Expanded UI goes here
DynamicIslandExpandedRegion(.leading) {
Text("🧗‍♂️")
Text("CLIMB")
.font(.title2)
}
DynamicIslandExpandedRegion(.trailing) {
@@ -39,12 +39,12 @@ struct ClimbingActivityWidget: Widget {
.font(.caption)
}
} compactLeading: {
Text("🧗‍♂️")
Text("CLIMB")
} compactTrailing: {
Text("\(context.state.totalAttempts)")
.monospacedDigit()
} minimal: {
Text("🧗‍♂️")
Text("CLIMB")
}
}
}
@@ -56,7 +56,7 @@ struct LiveActivityView: View {
var body: some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
Text("🧗‍♂️ \(context.attributes.gymName)")
Text("CLIMBING: \(context.attributes.gymName)")
.font(.headline)
.lineLimit(1)
Spacer()

View File

@@ -15,6 +15,13 @@
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
D2F32FB12E90B26500B1BC56 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = D24C19602E75002A0045894C /* Project object */;
proxyType = 1;
remoteGlobalIDString = D24C19672E75002A0045894C;
remoteInfo = OpenClimb;
};
D2FE949E2E78FEE1008CDB25 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = D24C19602E75002A0045894C /* Project object */;
@@ -41,6 +48,7 @@
/* Begin PBXFileReference section */
D24C19682E75002A0045894C /* OpenClimb.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = OpenClimb.app; sourceTree = BUILT_PRODUCTS_DIR; };
D268B79E2E83894A003AA641 /* SessionStatusLiveExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = SessionStatusLiveExtension.entitlements; sourceTree = "<group>"; };
D2F32FAD2E90B26500B1BC56 /* OpenClimbTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = OpenClimbTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
D2FE94802E78E958008CDB25 /* ActivityKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = ActivityKit.framework; path = System/Library/Frameworks/ActivityKit.framework; sourceTree = SDKROOT; };
D2FE948B2E78FEE0008CDB25 /* SessionStatusLiveExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = SessionStatusLiveExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
D2FE948C2E78FEE0008CDB25 /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; };
@@ -73,6 +81,11 @@
path = OpenClimb;
sourceTree = "<group>";
};
D2F32FAE2E90B26500B1BC56 /* OpenClimbTests */ = {
isa = PBXFileSystemSynchronizedRootGroup;
path = OpenClimbTests;
sourceTree = "<group>";
};
D2FE94902E78FEE0008CDB25 /* SessionStatusLive */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
@@ -92,6 +105,13 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
D2F32FAA2E90B26500B1BC56 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
D2FE94882E78FEE0008CDB25 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
@@ -111,6 +131,7 @@
D268B79E2E83894A003AA641 /* SessionStatusLiveExtension.entitlements */,
D24C196A2E75002A0045894C /* OpenClimb */,
D2FE94902E78FEE0008CDB25 /* SessionStatusLive */,
D2F32FAE2E90B26500B1BC56 /* OpenClimbTests */,
D2FE947F2E78E958008CDB25 /* Frameworks */,
D24C19692E75002A0045894C /* Products */,
);
@@ -121,6 +142,7 @@
children = (
D24C19682E75002A0045894C /* OpenClimb.app */,
D2FE948B2E78FEE0008CDB25 /* SessionStatusLiveExtension.appex */,
D2F32FAD2E90B26500B1BC56 /* OpenClimbTests.xctest */,
);
name = Products;
sourceTree = "<group>";
@@ -162,6 +184,29 @@
productReference = D24C19682E75002A0045894C /* OpenClimb.app */;
productType = "com.apple.product-type.application";
};
D2F32FAC2E90B26500B1BC56 /* OpenClimbTests */ = {
isa = PBXNativeTarget;
buildConfigurationList = D2F32FB52E90B26500B1BC56 /* Build configuration list for PBXNativeTarget "OpenClimbTests" */;
buildPhases = (
D2F32FA92E90B26500B1BC56 /* Sources */,
D2F32FAA2E90B26500B1BC56 /* Frameworks */,
D2F32FAB2E90B26500B1BC56 /* Resources */,
);
buildRules = (
);
dependencies = (
D2F32FB22E90B26500B1BC56 /* PBXTargetDependency */,
);
fileSystemSynchronizedGroups = (
D2F32FAE2E90B26500B1BC56 /* OpenClimbTests */,
);
name = OpenClimbTests;
packageProductDependencies = (
);
productName = OpenClimbTests;
productReference = D2F32FAD2E90B26500B1BC56 /* OpenClimbTests.xctest */;
productType = "com.apple.product-type.bundle.unit-test";
};
D2FE948A2E78FEE0008CDB25 /* SessionStatusLiveExtension */ = {
isa = PBXNativeTarget;
buildConfigurationList = D2FE94A12E78FEE1008CDB25 /* Build configuration list for PBXNativeTarget "SessionStatusLiveExtension" */;
@@ -197,6 +242,10 @@
D24C19672E75002A0045894C = {
CreatedOnToolsVersion = 26.0;
};
D2F32FAC2E90B26500B1BC56 = {
CreatedOnToolsVersion = 26.0.1;
TestTargetID = D24C19672E75002A0045894C;
};
D2FE948A2E78FEE0008CDB25 = {
CreatedOnToolsVersion = 26.0;
};
@@ -218,6 +267,7 @@
targets = (
D24C19672E75002A0045894C /* OpenClimb */,
D2FE948A2E78FEE0008CDB25 /* SessionStatusLiveExtension */,
D2F32FAC2E90B26500B1BC56 /* OpenClimbTests */,
);
};
/* End PBXProject section */
@@ -230,6 +280,13 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
D2F32FAB2E90B26500B1BC56 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
D2FE94892E78FEE0008CDB25 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
@@ -247,6 +304,13 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
D2F32FA92E90B26500B1BC56 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
D2FE94872E78FEE0008CDB25 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
@@ -257,6 +321,11 @@
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
D2F32FB22E90B26500B1BC56 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = D24C19672E75002A0045894C /* OpenClimb */;
targetProxy = D2F32FB12E90B26500B1BC56 /* PBXContainerItemProxy */;
};
D2FE949F2E78FEE1008CDB25 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = D2FE948A2E78FEE0008CDB25 /* SessionStatusLiveExtension */;
@@ -474,6 +543,48 @@
};
name = Release;
};
D2F32FB32E90B26500B1BC56 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 4BC9Y2LL4B;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.atri.dad.OpenClimb.Watch.OpenClimbTests;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = NO;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_EMIT_LOC_STRINGS = NO;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/OpenClimb.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/OpenClimb";
};
name = Debug;
};
D2F32FB42E90B26500B1BC56 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 4BC9Y2LL4B;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.atri.dad.OpenClimb.Watch.OpenClimbTests;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = NO;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_EMIT_LOC_STRINGS = NO;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/OpenClimb.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/OpenClimb";
};
name = Release;
};
D2FE94A22E78FEE1008CDB25 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
@@ -555,6 +666,15 @@
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
D2F32FB52E90B26500B1BC56 /* Build configuration list for PBXNativeTarget "OpenClimbTests" */ = {
isa = XCConfigurationList;
buildConfigurations = (
D2F32FB32E90B26500B1BC56 /* Debug */,
D2F32FB42E90B26500B1BC56 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
D2FE94A12E78FEE1008CDB25 /* Build configuration list for PBXNativeTarget "SessionStatusLiveExtension" */ = {
isa = XCConfigurationList;
buildConfigurations = (

View File

@@ -28,6 +28,17 @@
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES">
<Testables>
<TestableReference
skipped = "NO"
parallelizable = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "D2F32FAC2E90B26500B1BC56"
BuildableName = "OpenClimbTests.xctest"
BlueprintName = "OpenClimbTests"
ReferencedContainer = "container:OpenClimb.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction

View File

@@ -44,6 +44,19 @@
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
<Testables>
<TestableReference
skipped = "NO"
parallelizable = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "D2F32FAC2E90B26500B1BC56"
BuildableName = "OpenClimbTests.xctest"
BlueprintName = "OpenClimbTests"
ReferencedContainer = "container:OpenClimb.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"

View File

@@ -85,7 +85,7 @@ struct ContentView: View {
object: nil,
queue: .main
) { _ in
print("📱 App will enter foreground - preparing Live Activity check")
print("App will enter foreground - preparing Live Activity check")
Task {
// Small delay to ensure app is fully active
try? await Task.sleep(nanoseconds: 800_000_000) // 0.8 seconds
@@ -99,7 +99,7 @@ struct ContentView: View {
object: nil,
queue: .main
) { _ in
print("📱 App did become active - checking Live Activity status")
print("App did become active - checking Live Activity status")
Task {
try? await Task.sleep(nanoseconds: 300_000_000) // 0.3 seconds
await dataManager.onAppBecomeActive()

View File

@@ -230,7 +230,7 @@ class SyncService: ObservableObject {
if !hasLocalData && hasServerData {
// Case 1: No local data - do full restore from server
print("🔄 iOS SYNC: Case 1 - No local data, performing full restore from server")
print("iOS SYNC: Case 1 - No local data, performing full restore from server")
print("Syncing images from server first...")
let imagePathMapping = try await syncImagesFromServer(
backup: serverBackup, dataManager: dataManager)
@@ -240,7 +240,7 @@ class SyncService: ObservableObject {
print("Full restore completed")
} else if hasLocalData && !hasServerData {
// Case 2: No server data - upload local data to server
print("🔄 iOS SYNC: Case 2 - No server data, uploading local data to server")
print("iOS SYNC: Case 2 - No server data, uploading local data to server")
let currentBackup = createBackupFromDataManager(dataManager)
_ = try await uploadData(currentBackup)
print("Uploading local images to server...")
@@ -251,7 +251,7 @@ class SyncService: ObservableObject {
let localTimestamp = parseISO8601ToMillis(timestamp: localBackup.exportedAt)
let serverTimestamp = parseISO8601ToMillis(timestamp: serverBackup.exportedAt)
print("🕐 DEBUG iOS Timestamp Comparison:")
print("DEBUG iOS Timestamp Comparison:")
print(" Local exportedAt: '\(localBackup.exportedAt)' -> \(localTimestamp)")
print(" Server exportedAt: '\(serverBackup.exportedAt)' -> \(serverTimestamp)")
print(
@@ -261,14 +261,14 @@ class SyncService: ObservableObject {
if localTimestamp > serverTimestamp {
// Local is newer - replace server with local data
print("🔄 iOS SYNC: Case 3a - Local data is newer, replacing server content")
print("iOS SYNC: Case 3a - Local data is newer, replacing server content")
let currentBackup = createBackupFromDataManager(dataManager)
_ = try await uploadData(currentBackup)
try await syncImagesToServer(dataManager: dataManager)
print("Server replaced with local data")
} else if serverTimestamp > localTimestamp {
// Server is newer - replace local with server data
print("🔄 iOS SYNC: Case 3b - Server data is newer, replacing local content")
print("iOS SYNC: Case 3b - Server data is newer, replacing local content")
let imagePathMapping = try await syncImagesFromServer(
backup: serverBackup, dataManager: dataManager)
try importBackupToDataManager(
@@ -277,7 +277,7 @@ class SyncService: ObservableObject {
} else {
// Timestamps are equal - no sync needed
print(
"🔄 iOS SYNC: Case 3c - Data is in sync (timestamps equal), no action needed"
"iOS SYNC: Case 3c - Data is in sync (timestamps equal), no action needed"
)
}
} else {

View File

@@ -36,21 +36,21 @@ class DataStateManager {
func updateDataState() {
let now = ISO8601DateFormatter().string(from: Date())
userDefaults.set(now, forKey: Keys.lastModified)
print("📝 iOS Data state updated to: \(now)")
print("iOS Data state updated to: \(now)")
}
/// Gets the current data state timestamp. This represents when any data was last modified
/// locally.
func getLastModified() -> String {
if let storedTimestamp = userDefaults.string(forKey: Keys.lastModified) {
print("📅 iOS DataStateManager returning stored timestamp: \(storedTimestamp)")
print("iOS DataStateManager returning stored timestamp: \(storedTimestamp)")
return storedTimestamp
}
// If no timestamp is stored, return epoch time to indicate very old data
// This ensures server data will be considered newer than uninitialized local data
let epochTime = "1970-01-01T00:00:00.000Z"
print("⚠️ No data state timestamp found - returning epoch time: \(epochTime)")
print("WARNING: No data state timestamp found - returning epoch time: \(epochTime)")
return epochTime
}

View File

@@ -262,10 +262,10 @@ import SwiftUI
ForEach(testResults.indices, id: \.self) { index in
HStack {
Image(
systemName: testResults[index].contains("")
systemName: testResults[index].contains("PASS")
? "checkmark.circle.fill" : "exclamationmark.triangle.fill"
)
.foregroundColor(testResults[index].contains("") ? .green : .orange)
.foregroundColor(testResults[index].contains("PASS") ? .green : .orange)
Text(testResults[index])
.font(.caption)
@@ -285,24 +285,24 @@ import SwiftUI
// Test 1: Check iOS version compatibility
if iconHelper.supportsModernIconFeatures {
testResults.append(" iOS 17+ features supported")
testResults.append("PASS: iOS 17+ features supported")
} else {
testResults.append(
"⚠️ Running on iOS version that doesn't support modern icon features")
"WARNING: Running on iOS version that doesn't support modern icon features")
}
// Test 2: Check dark mode detection
let detectedDarkMode = iconHelper.isInDarkMode(for: colorScheme)
let systemDarkMode = colorScheme == .dark
if detectedDarkMode == systemDarkMode {
testResults.append(" Dark mode detection matches system setting")
testResults.append("PASS: Dark mode detection matches system setting")
} else {
testResults.append("⚠️ Dark mode detection mismatch")
testResults.append("WARNING: Dark mode detection mismatch")
}
// Test 3: Check recommended variant
let variant = iconHelper.getRecommendedIconVariant(for: colorScheme)
testResults.append(" Recommended icon variant: \(variant.description)")
testResults.append("PASS: Recommended icon variant: \(variant.description)")
// Test 4: Test asset availability
validateAssetConfiguration()
@@ -315,7 +315,7 @@ import SwiftUI
iconHelper.updateDarkModeStatus(for: colorScheme)
let variant = iconHelper.getRecommendedIconVariant(for: colorScheme)
testResults.append(
" Icon appearance test completed - Current variant: \(variant.description)")
"PASS: Icon appearance test completed - Current variant: \(variant.description)")
}
private func validateAssetConfiguration() {
@@ -326,20 +326,20 @@ import SwiftUI
]
for asset in expectedAssets {
testResults.append(" Asset '\(asset)' configuration found")
testResults.append("PASS: Asset '\(asset)' configuration found")
}
}
private func checkBundleResources() {
// Check bundle identifier
let bundleId = Bundle.main.bundleIdentifier ?? "Unknown"
testResults.append(" Bundle ID: \(bundleId)")
testResults.append("PASS: Bundle ID: \(bundleId)")
// Check app version
let version =
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "Unknown"
let build = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "Unknown"
testResults.append(" App version: \(version) (\(build))")
testResults.append("PASS: App version: \(version) (\(build))")
}
}

View File

@@ -23,7 +23,7 @@ class ImageManager {
// Final integrity check
if !validateStorageIntegrity() {
print("🚨 CRITICAL: Storage integrity compromised - attempting emergency recovery")
print("CRITICAL: Storage integrity compromised - attempting emergency recovery")
emergencyImageRestore()
}
@@ -69,9 +69,9 @@ class ImageManager {
attributes: [
.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication
])
print("Created directory: \(directory.path)")
print("Created directory: \(directory.path)")
} catch {
print(" Failed to create directory \(directory.path): \(error)")
print("ERROR: Failed to create directory \(directory.path): \(error)")
}
}
}
@@ -88,9 +88,9 @@ class ImageManager {
var backupURL = backupDirectory
try imagesURL.setResourceValues(resourceValues)
try backupURL.setResourceValues(resourceValues)
print("Excluded image directories from iCloud backup")
print("Excluded image directories from iCloud backup")
} catch {
print("⚠️ Failed to exclude from iCloud backup: \(error)")
print("WARNING: Failed to exclude from iCloud backup: \(error)")
}
}
@@ -114,11 +114,11 @@ class ImageManager {
}
private func performRobustMigration() {
print("🔄 Starting robust image migration system...")
print("Starting robust image migration system...")
// Check for interrupted migration
if let incompleteState = loadMigrationState() {
print("🔧 Detected interrupted migration, resuming...")
print("Detected interrupted migration, resuming...")
resumeMigration(from: incompleteState)
} else {
// Start fresh migration
@@ -135,7 +135,7 @@ class ImageManager {
private func startNewMigration() {
// First check for images in previous Application Support directories
if let previousAppSupportImages = findPreviousAppSupportImages() {
print("📁 Found images in previous Application Support directory")
print("Found images in previous Application Support directory")
migratePreviousAppSupportImages(from: previousAppSupportImages)
return
}
@@ -145,7 +145,7 @@ class ImageManager {
let hasLegacyImportImages = fileManager.fileExists(atPath: legacyImportImagesDirectory.path)
guard hasLegacyImages || hasLegacyImportImages else {
print("No legacy images to migrate")
print("No legacy images to migrate")
return
}
@@ -160,7 +160,7 @@ class ImageManager {
let legacyFiles = try fileManager.contentsOfDirectory(
atPath: legacyImagesDirectory.path)
allLegacyFiles.append(contentsOf: legacyFiles)
print("📦 Found \(legacyFiles.count) images in OpenClimbImages")
print("Found \(legacyFiles.count) images in OpenClimbImages")
}
// Collect files from Documents/images directory
@@ -168,10 +168,10 @@ class ImageManager {
let importFiles = try fileManager.contentsOfDirectory(
atPath: legacyImportImagesDirectory.path)
allLegacyFiles.append(contentsOf: importFiles)
print("📦 Found \(importFiles.count) images in Documents/images")
print("Found \(importFiles.count) images in Documents/images")
}
print("📦 Total legacy images to migrate: \(allLegacyFiles.count)")
print("Total legacy images to migrate: \(allLegacyFiles.count)")
let initialState = MigrationState(
version: MigrationState.currentVersion,
@@ -186,24 +186,24 @@ class ImageManager {
performMigrationWithCheckpoints(files: allLegacyFiles, currentState: initialState)
} catch {
print(" Failed to start migration: \(error)")
print("ERROR: Failed to start migration: \(error)")
}
}
private func resumeMigration(from state: MigrationState) {
print("🔄 Resuming migration from checkpoint...")
print("📊 Progress: \(state.completedFiles.count)/\(state.totalFiles)")
print("Resuming migration from checkpoint...")
print("Progress: \(state.completedFiles.count)/\(state.totalFiles)")
do {
let legacyFiles = try fileManager.contentsOfDirectory(
atPath: legacyImagesDirectory.path)
let remainingFiles = legacyFiles.filter { !state.completedFiles.contains($0) }
print("📦 Resuming with \(remainingFiles.count) remaining files")
print("Resuming with \(remainingFiles.count) remaining files")
performMigrationWithCheckpoints(files: remainingFiles, currentState: state)
} catch {
print(" Failed to resume migration: \(error)")
print("ERROR: Failed to resume migration: \(error)")
// Fallback: start fresh
removeMigrationState()
startNewMigration()
@@ -270,11 +270,11 @@ class ImageManager {
completedFiles.append(fileName)
migratedCount += 1
print("Migrated: \(fileName) (\(migratedCount)/\(currentState.totalFiles))")
print("Migrated: \(fileName) (\(migratedCount)/\(currentState.totalFiles))")
} catch {
failedCount += 1
print(" Failed to migrate \(fileName): \(error)")
print("ERROR: Failed to migrate \(fileName): \(error)")
}
// Save checkpoint every 5 files or if interrupted
@@ -288,7 +288,7 @@ class ImageManager {
lastCheckpoint: Date()
)
saveMigrationState(checkpointState)
print("💾 Checkpoint saved: \(completedFiles.count)/\(currentState.totalFiles)")
print("Checkpoint saved: \(completedFiles.count)/\(currentState.totalFiles)")
}
}
}
@@ -304,7 +304,7 @@ class ImageManager {
)
saveMigrationState(finalState)
print("🏁 Migration complete: \(migratedCount) migrated, \(failedCount) failed")
print("Migration complete: \(migratedCount) migrated, \(failedCount) failed")
// Clean up legacy directory if no failures
if failedCount == 0 {
@@ -313,7 +313,7 @@ class ImageManager {
}
private func verifyMigrationIntegrity() {
print("🔍 Verifying migration integrity...")
print("Verifying migration integrity...")
var allLegacyFiles = Set<String>()
@@ -331,12 +331,12 @@ class ImageManager {
allLegacyFiles.formUnion(importFiles)
}
} catch {
print(" Failed to read legacy directories: \(error)")
print("ERROR: Failed to read legacy directories: \(error)")
return
}
guard !allLegacyFiles.isEmpty else {
print("No legacy directories to verify against")
print("No legacy directories to verify against")
return
}
@@ -347,10 +347,10 @@ class ImageManager {
let missingFiles = allLegacyFiles.subtracting(migratedFiles)
if missingFiles.isEmpty {
print("Migration integrity verified - all files present")
print("Migration integrity verified - all files present")
cleanupLegacyDirectory()
} else {
print("⚠️ Missing \(missingFiles.count) files, re-triggering migration")
print("WARNING: Missing \(missingFiles.count) files, re-triggering migration")
// Re-trigger migration for missing files
performMigrationWithCheckpoints(
files: Array(missingFiles),
@@ -364,16 +364,16 @@ class ImageManager {
))
}
} catch {
print(" Failed to verify migration integrity: \(error)")
print("ERROR: Failed to verify migration integrity: \(error)")
}
}
private func cleanupLegacyDirectory() {
do {
try fileManager.removeItem(at: legacyImagesDirectory)
print("🗑️ Cleaned up legacy directory")
print("Cleaned up legacy directory")
} catch {
print("⚠️ Failed to clean up legacy directory: \(error)")
print("WARNING: Failed to clean up legacy directory: \(error)")
}
}
@@ -395,14 +395,14 @@ class ImageManager {
// Check if state is too old (more than 1 hour)
if Date().timeIntervalSince(state.lastCheckpoint) > 3600 {
print("⚠️ Migration state is stale, starting fresh")
print("WARNING: Migration state is stale, starting fresh")
removeMigrationState()
return nil
}
return state.isComplete ? nil : state
} catch {
print(" Failed to load migration state: \(error)")
print("ERROR: Failed to load migration state: \(error)")
removeMigrationState()
return nil
}
@@ -413,7 +413,7 @@ class ImageManager {
let data = try JSONEncoder().encode(state)
try data.write(to: migrationStateURL)
} catch {
print(" Failed to save migration state: \(error)")
print("ERROR: Failed to save migration state: \(error)")
}
}
@@ -429,7 +429,7 @@ class ImageManager {
private func cleanupMigrationState() {
try? fileManager.removeItem(at: migrationStateURL)
try? fileManager.removeItem(at: migrationLockURL)
print("🧹 Cleaned up migration state files")
print("Cleaned up migration state files")
}
func saveImageData(_ data: Data, withName name: String? = nil) -> String? {
@@ -444,10 +444,10 @@ class ImageManager {
// Create backup copy
try data.write(to: backupPath)
print("Saved image with backup: \(fileName)")
print("Saved image with backup: \(fileName)")
return fileName
} catch {
print(" Failed to save image \(fileName): \(error)")
print("ERROR: Failed to save image \(fileName): \(error)")
return nil
}
}
@@ -467,7 +467,7 @@ class ImageManager {
if fileManager.fileExists(atPath: backupPath.path),
let data = try? Data(contentsOf: backupPath)
{
print("📦 Restored image from backup: \(path)")
print("Restored image from backup: \(path)")
// Restore to primary location
try? data.write(to: URL(fileURLWithPath: primaryPath))
@@ -497,7 +497,7 @@ class ImageManager {
do {
try fileManager.removeItem(atPath: primaryPath)
} catch {
print(" Failed to delete primary image at \(primaryPath): \(error)")
print("ERROR: Failed to delete primary image at \(primaryPath): \(error)")
success = false
}
}
@@ -507,7 +507,7 @@ class ImageManager {
do {
try fileManager.removeItem(at: backupPath)
} catch {
print(" Failed to delete backup image at \(backupPath.path): \(error)")
print("ERROR: Failed to delete backup image at \(backupPath.path): \(error)")
success = false
}
}
@@ -544,7 +544,7 @@ class ImageManager {
}
func performMaintenance() {
print("🔧 Starting image maintenance...")
print("Starting image maintenance...")
syncBackups()
validateImageIntegrity()
@@ -562,11 +562,11 @@ class ImageManager {
let backupPath = backupDirectory.appendingPathComponent(fileName)
try? fileManager.copyItem(at: primaryPath, to: backupPath)
print("🔄 Created missing backup for: \(fileName)")
print("Created missing backup for: \(fileName)")
}
}
} catch {
print(" Failed to sync backups: \(error)")
print("ERROR: Failed to sync backups: \(error)")
}
}
@@ -585,15 +585,15 @@ class ImageManager {
}
}
print("Validated \(validFiles) of \(files.count) image files")
print("Validated \(validFiles) of \(files.count) image files")
} catch {
print(" Failed to validate images: \(error)")
print("ERROR: Failed to validate images: \(error)")
}
}
private func cleanupOrphanedFiles() {
// This would need access to the data manager to check which files are actually referenced
print("🧹 Cleanup would require coordination with data manager")
print("Cleanup would require coordination with data manager")
}
func getStorageInfo() -> (primaryCount: Int, backupCount: Int, totalSize: Int64) {
@@ -623,7 +623,7 @@ class ImageManager {
let previousDir = findPreviousAppSupportImages()
print(
"""
📁 OpenClimb Image Storage:
OpenClimb Image Storage:
- App Support: \(appSupportDirectory.path)
- Images: \(imagesDirectory.path) (\(info.primaryCount) files)
- Backups: \(backupDirectory.path) (\(info.backupCount) files)
@@ -635,7 +635,7 @@ class ImageManager {
}
func forceRecoveryMigration() {
print("🚨 FORCE RECOVERY: Starting manual migration recovery...")
print("FORCE RECOVERY: Starting manual migration recovery...")
// Remove any stale state
removeMigrationState()
@@ -644,7 +644,7 @@ class ImageManager {
// Force fresh migration
startNewMigration()
print("🚨 FORCE RECOVERY: Migration recovery completed")
print("FORCE RECOVERY: Migration recovery completed")
}
func saveImportedImage(_ imageData: Data, filename: String) throws -> String {
@@ -657,12 +657,12 @@ class ImageManager {
// Create backup
try? imageData.write(to: backupPath)
print("📥 Imported image: \(filename)")
print("Imported image: \(filename)")
return filename
}
func emergencyImageRestore() {
print("🆘 EMERGENCY: Attempting image restoration...")
print("EMERGENCY: Attempting image restoration...")
// Try to restore from backup directory
do {
@@ -680,14 +680,14 @@ class ImageManager {
}
}
print("🆘 EMERGENCY: Restored \(restoredCount) images from backup")
print("EMERGENCY: Restored \(restoredCount) images from backup")
} catch {
print("🆘 EMERGENCY: Failed to restore from backup: \(error)")
print("EMERGENCY: Failed to restore from backup: \(error)")
}
// Try previous Application Support directories first
if let previousAppSupportImages = findPreviousAppSupportImages() {
print("🆘 EMERGENCY: Found previous Application Support images, migrating...")
print("EMERGENCY: Found previous Application Support images, migrating...")
migratePreviousAppSupportImages(from: previousAppSupportImages)
return
}
@@ -696,21 +696,21 @@ class ImageManager {
if fileManager.fileExists(atPath: legacyImagesDirectory.path)
|| fileManager.fileExists(atPath: legacyImportImagesDirectory.path)
{
print("🆘 EMERGENCY: Attempting legacy migration as fallback...")
print("EMERGENCY: Attempting legacy migration as fallback...")
forceRecoveryMigration()
}
}
func debugSafeInitialization() -> Bool {
print("🐛 DEBUG SAFE: Performing debug-safe initialization check...")
print("DEBUG SAFE: Performing debug-safe initialization check...")
// Check if we're in a debug environment
#if DEBUG
print("🐛 DEBUG SAFE: Debug environment detected")
print("DEBUG SAFE: Debug environment detected")
// Check for interrupted migration more aggressively
if fileManager.fileExists(atPath: migrationLockURL.path) {
print("🐛 DEBUG SAFE: Found migration lock - likely debug interruption")
print("DEBUG SAFE: Found migration lock - likely debug interruption")
// Give extra time for file system to stabilize
Thread.sleep(forTimeInterval: 1.0)
@@ -732,14 +732,14 @@ class ImageManager {
((try? fileManager.contentsOfDirectory(atPath: backupDirectory.path)) ?? []).count > 0
if primaryEmpty && backupHasFiles {
print("🐛 DEBUG SAFE: Primary empty but backup exists - restoring")
print("DEBUG SAFE: Primary empty but backup exists - restoring")
emergencyImageRestore()
return true
}
// Check if primary storage is empty but previous Application Support images exist
if primaryEmpty, let previousAppSupportImages = findPreviousAppSupportImages() {
print("🐛 DEBUG SAFE: Primary empty but found previous Application Support images")
print("DEBUG SAFE: Primary empty but found previous Application Support images")
migratePreviousAppSupportImages(from: previousAppSupportImages)
return true
}
@@ -755,13 +755,15 @@ class ImageManager {
// Check if we have more backups than primary files (sign of corruption)
if backupFiles.count > primaryFiles.count + 5 {
print("⚠️ INTEGRITY: Backup count significantly exceeds primary - potential corruption")
print(
"WARNING INTEGRITY: Backup count significantly exceeds primary - potential corruption"
)
return false
}
// Check if primary is completely empty but we have data elsewhere
if primaryFiles.isEmpty && !backupFiles.isEmpty {
print("⚠️ INTEGRITY: Primary storage empty but backups exist")
print("WARNING INTEGRITY: Primary storage empty but backups exist")
return false
}
@@ -775,7 +777,7 @@ class ImageManager {
for: .applicationSupportDirectory, in: .userDomainMask
).first
else {
print(" Could not access Application Support directory")
print("ERROR: Could not access Application Support directory")
return nil
}
@@ -808,13 +810,13 @@ class ImageManager {
}
}
} catch {
print(" Error scanning for previous Application Support directories: \(error)")
print("ERROR: Error scanning for previous Application Support directories: \(error)")
}
return nil
}
private func migratePreviousAppSupportImages(from sourceDirectory: URL) {
print("🔄 Migrating images from previous Application Support directory")
print("Migrating images from previous Application Support directory")
do {
let imageFiles = try fileManager.contentsOfDirectory(atPath: sourceDirectory.path)
@@ -837,17 +839,17 @@ class ImageManager {
// Create backup
try? fileManager.copyItem(at: sourcePath, to: backupPath)
print("Migrated: \(fileName)")
print("Migrated: \(fileName)")
} catch {
print(" Failed to migrate \(fileName): \(error)")
print("ERROR: Failed to migrate \(fileName): \(error)")
}
}
}
print("Completed migration from previous Application Support directory")
print("Completed migration from previous Application Support directory")
} catch {
print(" Failed to migrate from previous Application Support: \(error)")
print("ERROR: Failed to migrate from previous Application Support: \(error)")
}
}
}

View File

@@ -554,20 +554,20 @@ class ClimbingDataManager: ObservableObject {
// Collect referenced image paths
let referencedImagePaths = collectReferencedImagePaths()
print("🎯 Starting export with \(referencedImagePaths.count) images")
print("Starting export with \(referencedImagePaths.count) images")
let zipData = try ZipUtils.createExportZip(
exportData: exportData,
referencedImagePaths: referencedImagePaths
)
print("Export completed successfully")
print("Export completed successfully")
successMessage = "Export completed with \(referencedImagePaths.count) images"
clearMessageAfterDelay()
return zipData
} catch {
let errorMessage = "Export failed: \(error.localizedDescription)"
print(" \(errorMessage)")
print("ERROR: \(errorMessage)")
setError(errorMessage)
return nil
}
@@ -662,13 +662,13 @@ class ClimbingDataManager: ObservableObject {
extension ClimbingDataManager {
private func collectReferencedImagePaths() -> Set<String> {
var imagePaths = Set<String>()
print("🖼️ Starting image path collection...")
print("📊 Total problems: \(problems.count)")
print("Starting image path collection...")
print("Total problems: \(problems.count)")
for problem in problems {
if !problem.imagePaths.isEmpty {
print(
"📸 Problem '\(problem.name ?? "Unnamed")' has \(problem.imagePaths.count) images"
"Problem '\(problem.name ?? "Unnamed")' has \(problem.imagePaths.count) images"
)
for imagePath in problem.imagePaths {
print(" - Relative path: \(imagePath)")
@@ -677,10 +677,10 @@ extension ClimbingDataManager {
// Check if file exists
if FileManager.default.fileExists(atPath: fullPath) {
print(" File exists")
print(" File exists")
imagePaths.insert(fullPath)
} else {
print(" File does NOT exist")
print(" File does NOT exist")
// Still add it to let ZipUtils handle the error logging
imagePaths.insert(fullPath)
}
@@ -688,7 +688,7 @@ extension ClimbingDataManager {
}
}
print("🖼️ Collected \(imagePaths.count) total image paths for export")
print("Collected \(imagePaths.count) total image paths for export")
return imagePaths
}
@@ -748,7 +748,7 @@ extension ClimbingDataManager {
// Log storage information for debugging
let info = await ImageManager.shared.getStorageInfo()
print(
"📊 Image Storage: \(info.primaryCount) primary, \(info.backupCount) backup, \(info.totalSize / 1024)KB total"
"Image Storage: \(info.primaryCount) primary, \(info.backupCount) backup, \(info.totalSize / 1024)KB total"
)
}.value
}
@@ -786,7 +786,7 @@ extension ClimbingDataManager {
}
if !orphanedFiles.isEmpty {
print("🗑️ Cleaned up \(orphanedFiles.count) orphaned image files")
print("Cleaned up \(orphanedFiles.count) orphaned image files")
}
}
}
@@ -803,7 +803,7 @@ extension ClimbingDataManager {
}
func forceImageRecovery() {
print("🚨 User initiated force image recovery")
print("User initiated force image recovery")
ImageManager.shared.forceRecoveryMigration()
// Refresh the UI after recovery
@@ -811,7 +811,7 @@ extension ClimbingDataManager {
}
func emergencyImageRestore() {
print("🆘 User initiated emergency image restore")
print("User initiated emergency image restore")
ImageManager.shared.emergencyImageRestore()
// Refresh the UI after restore
@@ -827,7 +827,7 @@ extension ClimbingDataManager {
let info = ImageManager.shared.getStorageInfo()
return """
Image Storage Health: \(isValid ? "Good" : "Needs Recovery")
Image Storage Health: \(isValid ? "Good" : "Needs Recovery")
Primary Files: \(info.primaryCount)
Backup Files: \(info.backupCount)
Total Size: \(formatBytes(info.totalSize))
@@ -845,7 +845,7 @@ extension ClimbingDataManager {
// Test with dummy data if we have a gym
guard let testGym = gyms.first else {
print(" No gyms available for testing")
print("ERROR: No gyms available for testing")
return
}
@@ -877,14 +877,14 @@ extension ClimbingDataManager {
// Only restart if session is actually active
guard activeSession.status == .active else {
print(
"⚠️ Session exists but is not active (status: \(activeSession.status)), ending Live Activity"
"WARNING: Session exists but is not active (status: \(activeSession.status)), ending Live Activity"
)
await LiveActivityManager.shared.endLiveActivity()
return
}
if let gym = gym(withId: activeSession.gymId) {
print("🔍 Checking Live Activity for active session at \(gym.name)")
print("Checking Live Activity for active session at \(gym.name)")
// First cleanup any dismissed activities
await LiveActivityManager.shared.cleanupDismissedActivities()
@@ -894,15 +894,12 @@ extension ClimbingDataManager {
activeSession: activeSession,
gymName: gym.name
)
// Update with current session data
await updateLiveActivityData()
}
}
/// Call this when app becomes active to check for Live Activity restart
func onAppBecomeActive() {
print("📱 App became active - checking Live Activity status")
print("App became active - checking Live Activity status")
Task {
await checkAndRestartLiveActivity()
}
@@ -910,7 +907,7 @@ extension ClimbingDataManager {
/// Call this when app enters background to update Live Activity
func onAppEnterBackground() {
print("📱 App entering background - updating Live Activity if needed")
print("App entering background - updating Live Activity if needed")
Task {
await updateLiveActivityData()
}
@@ -939,7 +936,7 @@ extension ClimbingDataManager {
return
}
print("🔄 Attempting to restart dismissed Live Activity for \(gym.name)")
print("Attempting to restart dismissed Live Activity for \(gym.name)")
// Wait a bit before restarting to avoid frequency limits
try? await Task.sleep(nanoseconds: 2_000_000_000) // 2 seconds
@@ -979,7 +976,7 @@ extension ClimbingDataManager {
activeSession.status == .active,
let gym = gym(withId: activeSession.gymId)
else {
print("⚠️ Live Activity update skipped - no active session or gym")
print("WARNING: Live Activity update skipped - no active session or gym")
if let session = activeSession {
print(" Session ID: \(session.id)")
print(" Session Status: \(session.status)")
@@ -1003,7 +1000,7 @@ extension ClimbingDataManager {
elapsedInterval = 0
}
print("🔄 Live Activity Update Debug:")
print("Live Activity Update Debug:")
print(" Session ID: \(activeSession.id)")
print(" Gym: \(gym.name)")
print(" Total attempts in session: \(totalAttempts)")

View File

@@ -34,11 +34,11 @@ final class LiveActivityManager {
let isStillActive = activities.contains { $0.id == currentActivity.id }
if isStillActive {
print(" Live Activity still running: \(currentActivity.id)")
print("Live Activity still running: \(currentActivity.id)")
return
} else {
print(
"⚠️ Tracked Live Activity \(currentActivity.id) was dismissed, clearing reference"
"WARNING: Tracked Live Activity \(currentActivity.id) was dismissed, clearing reference"
)
self.currentActivity = nil
}
@@ -47,18 +47,18 @@ final class LiveActivityManager {
// Check if there are ANY active Live Activities for this session
let existingActivities = Activity<SessionActivityAttributes>.activities
if let existingActivity = existingActivities.first {
print(" Found existing Live Activity: \(existingActivity.id), using it")
print("Found existing Live Activity: \(existingActivity.id), using it")
self.currentActivity = existingActivity
return
}
print("🔄 No Live Activity found, restarting for existing session")
print("No Live Activity found, restarting for existing session")
await startLiveActivity(for: activeSession, gymName: gymName)
}
/// Call this when a ClimbSession starts to begin a Live Activity
func startLiveActivity(for session: ClimbSession, gymName: String) async {
print("🔴 Starting Live Activity for gym: \(gymName)")
print("Starting Live Activity for gym: \(gymName)")
await endLiveActivity()
@@ -84,9 +84,9 @@ final class LiveActivityManager {
pushType: nil
)
self.currentActivity = activity
print("Live Activity started successfully: \(activity.id)")
print("Live Activity started successfully: \(activity.id)")
} catch {
print(" Failed to start live activity: \(error)")
print("ERROR: Failed to start live activity: \(error)")
print("Error details: \(error.localizedDescription)")
// Check specific error types
@@ -104,7 +104,7 @@ final class LiveActivityManager {
func updateLiveActivity(elapsed: TimeInterval, totalAttempts: Int, completedProblems: Int) async
{
guard let currentActivity = currentActivity else {
print("⚠️ No current activity to update")
print("WARNING: No current activity to update")
return
}
@@ -114,14 +114,14 @@ final class LiveActivityManager {
if !isStillActive {
print(
"⚠️ Tracked Live Activity \(currentActivity.id) is no longer active, clearing reference"
"WARNING: Tracked Live Activity \(currentActivity.id) is no longer active, clearing reference"
)
self.currentActivity = nil
return
}
print(
"🔄 Updating Live Activity - Attempts: \(totalAttempts), Completed: \(completedProblems)"
"Updating Live Activity - Attempts: \(totalAttempts), Completed: \(completedProblems)"
)
let updatedContentState = SessionActivityAttributes.ContentState(
@@ -131,7 +131,7 @@ final class LiveActivityManager {
)
await currentActivity.update(.init(state: updatedContentState, staleDate: nil))
print("Live Activity updated successfully")
print("Live Activity updated successfully")
}
/// Call this when a ClimbSession ends to end the Live Activity
@@ -141,25 +141,25 @@ final class LiveActivityManager {
// First end the tracked activity if it exists
if let currentActivity {
print("🔴 Ending tracked Live Activity: \(currentActivity.id)")
print("Ending tracked Live Activity: \(currentActivity.id)")
await currentActivity.end(nil, dismissalPolicy: .immediate)
self.currentActivity = nil
print("Tracked Live Activity ended successfully")
print("Tracked Live Activity ended successfully")
}
// Force end ALL active activities of our type to ensure cleanup
print("🔍 Checking for any remaining active activities...")
print("Checking for any remaining active activities...")
let activities = Activity<SessionActivityAttributes>.activities
if activities.isEmpty {
print(" No additional activities found")
print("No additional activities found")
} else {
print("🔴 Found \(activities.count) additional active activities, ending them...")
print("Found \(activities.count) additional active activities, ending them...")
for activity in activities {
print("🔴 Force ending activity: \(activity.id)")
print("Force ending activity: \(activity.id)")
await activity.end(nil, dismissalPolicy: .immediate)
}
print("All Live Activities ended successfully")
print("All Live Activities ended successfully")
}
}
@@ -188,7 +188,7 @@ final class LiveActivityManager {
if let currentActivity = currentActivity {
let isStillActive = activities.contains { $0.id == currentActivity.id }
if !isStillActive {
print("🧹 Cleaning up dismissed Live Activity: \(currentActivity.id)")
print("Cleaning up dismissed Live Activity: \(currentActivity.id)")
self.currentActivity = nil
}
}
@@ -211,7 +211,7 @@ final class LiveActivityManager {
func stopHealthChecks() {
healthCheckTimer?.invalidate()
healthCheckTimer = nil
print("🛑 Stopped Live Activity health checks")
print("Stopped Live Activity health checks")
}
/// Perform a health check on the current Live Activity
@@ -231,7 +231,7 @@ final class LiveActivityManager {
let isStillActive = activities.contains { $0.id == currentActivity.id }
if !isStillActive {
print("💔 Health check failed - Live Activity was dismissed")
print("Health check failed - Live Activity was dismissed")
self.currentActivity = nil
// Notify that we need to restart
@@ -240,7 +240,7 @@ final class LiveActivityManager {
object: nil
)
} else {
print("Live Activity health check passed")
print("Live Activity health check passed")
}
}

View File

@@ -87,7 +87,7 @@ struct LiveActivityDebugView: View {
.disabled(dataManager.activeSession == nil)
if dataManager.gyms.isEmpty {
Text("⚠️ Add at least one gym to test Live Activities")
Text("WARNING: Add at least one gym to test Live Activities")
.font(.caption)
.foregroundColor(.orange)
}
@@ -167,29 +167,31 @@ struct LiveActivityDebugView: View {
}
private func checkStatus() {
appendDebugOutput("🔍 Checking Live Activity status...")
appendDebugOutput("Checking Live Activity status...")
let status = LiveActivityManager.shared.checkLiveActivityAvailability()
appendDebugOutput("Status: \(status)")
// Check iOS version
if #available(iOS 16.1, *) {
appendDebugOutput("iOS version supports Live Activities")
appendDebugOutput("iOS version supports Live Activities")
} else {
appendDebugOutput("❌ iOS version does not support Live Activities (requires 16.1+)")
appendDebugOutput(
"ERROR: iOS version does not support Live Activities (requires 16.1+)")
}
// Check if we're on simulator
#if targetEnvironment(simulator)
appendDebugOutput("⚠️ Running on Simulator - Live Activities have limited functionality")
appendDebugOutput(
"WARNING: Running on Simulator - Live Activities have limited functionality")
#else
appendDebugOutput("Running on device - Live Activities should work fully")
appendDebugOutput("Running on device - Live Activities should work fully")
#endif
}
private func testLiveActivity() {
guard !dataManager.gyms.isEmpty else {
appendDebugOutput(" No gyms available for testing")
appendDebugOutput("ERROR: No gyms available for testing")
return
}
@@ -240,25 +242,25 @@ struct LiveActivityDebugView: View {
appendDebugOutput("Ending Live Activity...")
await LiveActivityManager.shared.endLiveActivity()
appendDebugOutput("🏁 Live Activity test completed!")
appendDebugOutput("Live Activity test completed!")
}
}
private func endCurrentSession() {
guard let activeSession = dataManager.activeSession else {
appendDebugOutput(" No active session to end")
appendDebugOutput("ERROR: No active session to end")
return
}
appendDebugOutput("🛑 Ending current session: \(activeSession.id)")
appendDebugOutput("Ending current session: \(activeSession.id)")
dataManager.endSession(activeSession.id)
appendDebugOutput("Session ended")
appendDebugOutput("Session ended")
}
private func forceLiveActivityUpdate() {
appendDebugOutput("🔄 Forcing Live Activity update...")
appendDebugOutput("Forcing Live Activity update...")
dataManager.forceLiveActivityUpdate()
appendDebugOutput("Live Activity update sent")
appendDebugOutput("Live Activity update sent")
}
}

View File

@@ -708,7 +708,7 @@ struct ImportDataView: View {
Text("Import climbing data from a previously exported ZIP file.")
.multilineTextAlignment(.center)
Text("⚠️ Warning: This will replace all current data!")
Text("WARNING: This will replace all current data!")
.font(.subheadline)
.foregroundColor(.red)
.multilineTextAlignment(.center)

View File

@@ -0,0 +1,255 @@
import XCTest
final class OpenClimbTests: XCTestCase {
override func setUpWithError() throws {
}
override func tearDownWithError() throws {
}
// MARK: - Data Validation Tests
func testDifficultyGradeComparison() throws {
// Test basic difficulty grade string comparison
let grade1 = "V5"
let grade2 = "V3"
let grade3 = "V5"
XCTAssertEqual(grade1, grade3)
XCTAssertNotEqual(grade1, grade2)
XCTAssertFalse(grade1.isEmpty)
}
func testClimbTypeValidation() throws {
// Test climb type validation
let validClimbTypes = ["ROPE", "BOULDER"]
for climbType in validClimbTypes {
XCTAssertTrue(validClimbTypes.contains(climbType))
XCTAssertFalse(climbType.isEmpty)
}
let invalidTypes = ["", "unknown", "invalid", "sport", "trad", "toprope"]
for invalidType in invalidTypes {
if !invalidType.isEmpty {
XCTAssertFalse(validClimbTypes.contains(invalidType))
}
}
}
func testDateFormatting() throws {
// Test ISO 8601 date formatting
let formatter = ISO8601DateFormatter()
let date = Date()
let formattedDate = formatter.string(from: date)
XCTAssertFalse(formattedDate.isEmpty)
XCTAssertTrue(formattedDate.contains("T"))
XCTAssertTrue(formattedDate.hasSuffix("Z"))
// Test parsing back
let parsedDate = formatter.date(from: formattedDate)
XCTAssertNotNil(parsedDate)
}
func testSessionDurationCalculation() throws {
// Test session duration calculation
let startTime = Date()
let endTime = Date(timeInterval: 3600, since: startTime) // 1 hour later
let duration = endTime.timeIntervalSince(startTime)
XCTAssertEqual(duration, 3600, accuracy: 1.0)
XCTAssertGreaterThan(duration, 0)
}
func testAttemptResultValidation() throws {
// Test attempt result validation
let validResults = ["completed", "failed", "flash", "project"]
for result in validResults {
XCTAssertTrue(validResults.contains(result))
XCTAssertFalse(result.isEmpty)
}
}
func testGymCreation() throws {
// Test gym model creation with basic validation
let gymName = "Test Climbing Gym"
let location = "Test City"
let supportedTypes = ["BOULDER", "ROPE"]
XCTAssertFalse(gymName.isEmpty)
XCTAssertFalse(location.isEmpty)
XCTAssertFalse(supportedTypes.isEmpty)
XCTAssertEqual(supportedTypes.count, 2)
XCTAssertTrue(supportedTypes.contains("BOULDER"))
XCTAssertTrue(supportedTypes.contains("ROPE"))
}
func testProblemValidation() throws {
// Test problem model validation
let problemName = "Test Problem"
let climbType = "BOULDER"
let difficulty = "V5"
let tags = ["overhang", "crimpy"]
XCTAssertFalse(problemName.isEmpty)
XCTAssertTrue(["BOULDER", "ROPE"].contains(climbType))
XCTAssertFalse(difficulty.isEmpty)
XCTAssertEqual(tags.count, 2)
XCTAssertTrue(tags.allSatisfy { !$0.isEmpty })
}
func testSessionStatusTransitions() throws {
// Test session status transitions
let validStatuses = ["planned", "active", "completed", "cancelled"]
for status in validStatuses {
XCTAssertTrue(validStatuses.contains(status))
XCTAssertFalse(status.isEmpty)
}
// Test status transitions logic
let initialStatus = "planned"
let activeStatus = "active"
let completedStatus = "completed"
XCTAssertNotEqual(initialStatus, activeStatus)
XCTAssertNotEqual(activeStatus, completedStatus)
}
func testUniqueIDGeneration() throws {
// Test unique ID generation using UUID
let id1 = UUID().uuidString
let id2 = UUID().uuidString
XCTAssertNotEqual(id1, id2)
XCTAssertFalse(id1.isEmpty)
XCTAssertFalse(id2.isEmpty)
XCTAssertEqual(id1.count, 36) // UUID string length
XCTAssertTrue(id1.contains("-"))
}
func testDataValidation() throws {
// Test basic data validation patterns
let emptyString = ""
let validString = "test"
let negativeNumber = -1
let positiveNumber = 5
let zeroNumber = 0
XCTAssertTrue(emptyString.isEmpty)
XCTAssertFalse(validString.isEmpty)
XCTAssertLessThan(negativeNumber, 0)
XCTAssertGreaterThan(positiveNumber, 0)
XCTAssertEqual(zeroNumber, 0)
}
// MARK: - Collection Tests
func testArrayOperations() throws {
// Test array operations for climb data
var problems: [String] = []
XCTAssertTrue(problems.isEmpty)
XCTAssertEqual(problems.count, 0)
problems.append("Problem 1")
problems.append("Problem 2")
XCTAssertFalse(problems.isEmpty)
XCTAssertEqual(problems.count, 2)
XCTAssertTrue(problems.contains("Problem 1"))
let filteredProblems = problems.filter { $0.contains("1") }
XCTAssertEqual(filteredProblems.count, 1)
}
func testDictionaryOperations() throws {
// Test dictionary operations for data storage
var gymData: [String: Any] = [:]
XCTAssertTrue(gymData.isEmpty)
gymData["name"] = "Test Gym"
gymData["location"] = "Test City"
gymData["types"] = ["BOULDER", "ROPE"]
XCTAssertFalse(gymData.isEmpty)
XCTAssertEqual(gymData.count, 3)
XCTAssertNotNil(gymData["name"])
if let name = gymData["name"] as? String {
XCTAssertEqual(name, "Test Gym")
} else {
XCTFail("Failed to cast gym name to String")
}
}
// MARK: - String and Numeric Tests
func testStringManipulation() throws {
// Test string operations common in climb data
let problemName = " Test Problem V5 "
let trimmedName = problemName.trimmingCharacters(in: .whitespacesAndNewlines)
let uppercaseName = trimmedName.uppercased()
let lowercaseName = trimmedName.lowercased()
XCTAssertEqual(trimmedName, "Test Problem V5")
XCTAssertEqual(uppercaseName, "TEST PROBLEM V5")
XCTAssertEqual(lowercaseName, "test problem v5")
let components = trimmedName.components(separatedBy: " ")
XCTAssertEqual(components.count, 3)
XCTAssertEqual(components.last, "V5")
}
func testNumericOperations() throws {
// Test numeric operations for climb ratings and statistics
let grades = [3, 5, 7, 4, 6]
let sum = grades.reduce(0, +)
let average = Double(sum) / Double(grades.count)
let maxGrade = grades.max() ?? 0
let minGrade = grades.min() ?? 0
XCTAssertEqual(sum, 25)
XCTAssertEqual(average, 5.0, accuracy: 0.01)
XCTAssertEqual(maxGrade, 7)
XCTAssertEqual(minGrade, 3)
}
// MARK: - JSON and Data Format Tests
func testJSONSerialization() throws {
// Test JSON serialization for basic data structures
let testData: [String: Any] = [
"id": "test123",
"name": "Test Gym",
"active": true,
"rating": 4.5,
"types": ["BOULDER", "ROPE"],
]
XCTAssertNoThrow({
let jsonData = try JSONSerialization.data(withJSONObject: testData)
XCTAssertFalse(jsonData.isEmpty)
let deserializedData =
try JSONSerialization.jsonObject(with: jsonData) as? [String: Any]
XCTAssertNotNil(deserializedData)
XCTAssertEqual(deserializedData?["name"] as? String, "Test Gym")
})
}
func testDateSerialization() throws {
// Test date serialization for API compatibility
let date = Date()
let formatter = ISO8601DateFormatter()
let dateString = formatter.string(from: date)
let parsedDate = formatter.date(from: dateString)
XCTAssertNotNil(parsedDate)
XCTAssertEqual(date.timeIntervalSince1970, parsedDate!.timeIntervalSince1970, accuracy: 1.0)
}
}

479
sync/format_test.go Normal file
View File

@@ -0,0 +1,479 @@
package main
import (
"encoding/json"
"strings"
"testing"
)
func TestDataFormatCompatibility(t *testing.T) {
t.Run("JSON Marshaling and Unmarshaling", func(t *testing.T) {
originalBackup := ClimbDataBackup{
ExportedAt: "2024-01-01T10:00:00Z",
Version: "2.0",
FormatVersion: "2.0",
Gyms: []BackupGym{
{
ID: "gym1",
Name: "Test Gym",
Location: stringPtr("Test Location"),
SupportedClimbTypes: []string{"BOULDER", "ROPE"},
DifficultySystems: []string{"V", "YDS"},
CustomDifficultyGrades: []string{"V0+", "V1+"},
Notes: stringPtr("Test notes"),
CreatedAt: "2024-01-01T10:00:00Z",
UpdatedAt: "2024-01-01T10:00:00Z",
},
},
Problems: []BackupProblem{
{
ID: "problem1",
GymID: "gym1",
Name: stringPtr("Test Problem"),
Description: stringPtr("A challenging problem"),
ClimbType: "BOULDER",
Difficulty: DifficultyGrade{
System: "V",
Grade: "V5",
NumericValue: 5,
},
Tags: []string{"overhang", "crimpy"},
Location: stringPtr("Wall A"),
ImagePaths: []string{"image1.jpg", "image2.jpg"},
IsActive: true,
DateSet: stringPtr("2024-01-01"),
Notes: stringPtr("Watch the start"),
CreatedAt: "2024-01-01T10:00:00Z",
UpdatedAt: "2024-01-01T10:00:00Z",
},
},
Sessions: []BackupClimbSession{
{
ID: "session1",
GymID: "gym1",
Date: "2024-01-01",
StartTime: stringPtr("2024-01-01T10:00:00Z"),
EndTime: stringPtr("2024-01-01T12:00:00Z"),
Duration: int64Ptr(7200),
Status: "completed",
Notes: stringPtr("Great session"),
CreatedAt: "2024-01-01T10:00:00Z",
UpdatedAt: "2024-01-01T12:00:00Z",
},
},
Attempts: []BackupAttempt{
{
ID: "attempt1",
SessionID: "session1",
ProblemID: "problem1",
Result: "completed",
HighestHold: stringPtr("Top"),
Notes: stringPtr("Clean send"),
Duration: int64Ptr(300),
RestTime: int64Ptr(120),
Timestamp: "2024-01-01T10:30:00Z",
CreatedAt: "2024-01-01T10:30:00Z",
},
},
}
jsonData, err := json.Marshal(originalBackup)
if err != nil {
t.Fatalf("Failed to marshal backup: %v", err)
}
var unmarshaledBackup ClimbDataBackup
if err := json.Unmarshal(jsonData, &unmarshaledBackup); err != nil {
t.Fatalf("Failed to unmarshal backup: %v", err)
}
if originalBackup.Version != unmarshaledBackup.Version {
t.Errorf("Version mismatch: expected %s, got %s", originalBackup.Version, unmarshaledBackup.Version)
}
if len(originalBackup.Gyms) != len(unmarshaledBackup.Gyms) {
t.Errorf("Gyms count mismatch: expected %d, got %d", len(originalBackup.Gyms), len(unmarshaledBackup.Gyms))
}
if len(originalBackup.Problems) != len(unmarshaledBackup.Problems) {
t.Errorf("Problems count mismatch: expected %d, got %d", len(originalBackup.Problems), len(unmarshaledBackup.Problems))
}
if len(originalBackup.Sessions) != len(unmarshaledBackup.Sessions) {
t.Errorf("Sessions count mismatch: expected %d, got %d", len(originalBackup.Sessions), len(unmarshaledBackup.Sessions))
}
if len(originalBackup.Attempts) != len(unmarshaledBackup.Attempts) {
t.Errorf("Attempts count mismatch: expected %d, got %d", len(originalBackup.Attempts), len(unmarshaledBackup.Attempts))
}
})
t.Run("Required Fields Validation", func(t *testing.T) {
testCases := []struct {
name string
jsonInput string
shouldError bool
}{
{
name: "Valid minimal backup",
jsonInput: `{
"exportedAt": "2024-01-01T10:00:00Z",
"version": "2.0",
"formatVersion": "2.0",
"gyms": [],
"problems": [],
"sessions": [],
"attempts": []
}`,
shouldError: false,
},
{
name: "Missing version field",
jsonInput: `{
"exportedAt": "2024-01-01T10:00:00Z",
"formatVersion": "2.0",
"gyms": [],
"problems": [],
"sessions": [],
"attempts": []
}`,
shouldError: false,
},
{
name: "Invalid JSON structure",
jsonInput: `{
"exportedAt": "2024-01-01T10:00:00Z",
"version": "2.0",
"formatVersion": "2.0",
"gyms": "not an array"
}`,
shouldError: true,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
var backup ClimbDataBackup
err := json.Unmarshal([]byte(tc.jsonInput), &backup)
if tc.shouldError && err == nil {
t.Error("Expected error but got none")
}
if !tc.shouldError && err != nil {
t.Errorf("Unexpected error: %v", err)
}
})
}
})
t.Run("Difficulty Grade Format", func(t *testing.T) {
testGrades := []DifficultyGrade{
{System: "V", Grade: "V0", NumericValue: 0},
{System: "V", Grade: "V5", NumericValue: 5},
{System: "V", Grade: "V10", NumericValue: 10},
{System: "YDS", Grade: "5.10a", NumericValue: 100},
{System: "YDS", Grade: "5.12d", NumericValue: 124},
{System: "Font", Grade: "6A", NumericValue: 60},
{System: "Custom", Grade: "Beginner", NumericValue: 1},
}
for _, grade := range testGrades {
jsonData, err := json.Marshal(grade)
if err != nil {
t.Errorf("Failed to marshal grade %+v: %v", grade, err)
continue
}
var unmarshaledGrade DifficultyGrade
if err := json.Unmarshal(jsonData, &unmarshaledGrade); err != nil {
t.Errorf("Failed to unmarshal grade %s: %v", string(jsonData), err)
continue
}
if grade.System != unmarshaledGrade.System {
t.Errorf("System mismatch for grade %+v: expected %s, got %s", grade, grade.System, unmarshaledGrade.System)
}
if grade.Grade != unmarshaledGrade.Grade {
t.Errorf("Grade mismatch for grade %+v: expected %s, got %s", grade, grade.Grade, unmarshaledGrade.Grade)
}
if grade.NumericValue != unmarshaledGrade.NumericValue {
t.Errorf("NumericValue mismatch for grade %+v: expected %d, got %d", grade, grade.NumericValue, unmarshaledGrade.NumericValue)
}
}
})
t.Run("Null and Optional Fields", func(t *testing.T) {
jsonWithNulls := `{
"exportedAt": "2024-01-01T10:00:00Z",
"version": "2.0",
"formatVersion": "2.0",
"gyms": [{
"id": "gym1",
"name": "Test Gym",
"location": null,
"supportedClimbTypes": ["boulder"],
"difficultySystems": ["V"],
"customDifficultyGrades": [],
"notes": null,
"createdAt": "2024-01-01T10:00:00Z",
"updatedAt": "2024-01-01T10:00:00Z"
}],
"problems": [{
"id": "problem1",
"gymId": "gym1",
"name": null,
"description": null,
"climbType": "boulder",
"difficulty": {
"system": "V",
"grade": "V5",
"numericValue": 5
},
"tags": [],
"location": null,
"imagePaths": [],
"isActive": true,
"dateSet": null,
"notes": null,
"createdAt": "2024-01-01T10:00:00Z",
"updatedAt": "2024-01-01T10:00:00Z"
}],
"sessions": [],
"attempts": []
}`
var backup ClimbDataBackup
if err := json.Unmarshal([]byte(jsonWithNulls), &backup); err != nil {
t.Fatalf("Failed to unmarshal JSON with nulls: %v", err)
}
if backup.Gyms[0].Location != nil {
t.Error("Expected location to be nil")
}
if backup.Gyms[0].Notes != nil {
t.Error("Expected notes to be nil")
}
if backup.Problems[0].Name != nil {
t.Error("Expected problem name to be nil")
}
})
t.Run("Date Format Validation", func(t *testing.T) {
validDates := []string{
"2024-01-01T10:00:00Z",
"2024-12-31T23:59:59Z",
"2024-06-15T12:30:45Z",
"2024-01-01T00:00:00Z",
}
invalidDates := []string{
"2024-01-01 10:00:00",
"2024/01/01T10:00:00Z",
"2024-1-1T10:00:00Z",
}
for _, date := range validDates {
if !isValidISODate(date) {
t.Errorf("Valid date %s was marked as invalid", date)
}
}
for _, date := range invalidDates {
if isValidISODate(date) {
t.Errorf("Invalid date %s was marked as valid", date)
}
}
})
t.Run("Field Length Limits", func(t *testing.T) {
longString := strings.Repeat("a", 10000)
gym := BackupGym{
ID: "gym1",
Name: longString,
Location: &longString,
SupportedClimbTypes: []string{"boulder"},
DifficultySystems: []string{"V"},
CustomDifficultyGrades: []string{},
Notes: &longString,
CreatedAt: "2024-01-01T10:00:00Z",
UpdatedAt: "2024-01-01T10:00:00Z",
}
jsonData, err := json.Marshal(gym)
if err != nil {
t.Errorf("Failed to marshal gym with long strings: %v", err)
}
var unmarshaledGym BackupGym
if err := json.Unmarshal(jsonData, &unmarshaledGym); err != nil {
t.Errorf("Failed to unmarshal gym with long strings: %v", err)
}
if unmarshaledGym.Name != longString {
t.Error("Long name was not preserved")
}
})
t.Run("Array Field Validation", func(t *testing.T) {
backup := ClimbDataBackup{
ExportedAt: "2024-01-01T10:00:00Z",
Version: "2.0",
FormatVersion: "2.0",
Gyms: nil,
Problems: []BackupProblem{},
Sessions: []BackupClimbSession{},
Attempts: []BackupAttempt{},
}
jsonData, err := json.Marshal(backup)
if err != nil {
t.Fatalf("Failed to marshal backup with nil gyms: %v", err)
}
var unmarshaledBackup ClimbDataBackup
if err := json.Unmarshal(jsonData, &unmarshaledBackup); err != nil {
t.Fatalf("Failed to unmarshal backup with nil gyms: %v", err)
}
if len(unmarshaledBackup.Gyms) != 0 {
t.Error("Expected gyms to be empty or nil")
}
})
}
func isValidISODate(date string) bool {
// More robust ISO date validation
if !strings.Contains(date, "T") || !strings.HasSuffix(date, "Z") {
return false
}
// Check basic format: YYYY-MM-DDTHH:MM:SSZ
parts := strings.Split(date, "T")
if len(parts) != 2 {
return false
}
datePart := parts[0]
timePart := strings.TrimSuffix(parts[1], "Z")
// Date part should be YYYY-MM-DD
dateComponents := strings.Split(datePart, "-")
if len(dateComponents) != 3 || len(dateComponents[0]) != 4 || len(dateComponents[1]) != 2 || len(dateComponents[2]) != 2 {
return false
}
// Time part should be HH:MM:SS
timeComponents := strings.Split(timePart, ":")
if len(timeComponents) != 3 || len(timeComponents[0]) != 2 || len(timeComponents[1]) != 2 || len(timeComponents[2]) != 2 {
return false
}
return true
}
func TestVersionCompatibility(t *testing.T) {
testCases := []struct {
version string
formatVersion string
shouldSupport bool
}{
{"2.0", "2.0", true},
{"1.0", "1.0", true},
{"2.1", "2.0", false},
{"3.0", "2.0", false},
{"1.0", "2.0", false},
}
for _, tc := range testCases {
t.Run(tc.version+"/"+tc.formatVersion, func(t *testing.T) {
backup := ClimbDataBackup{
Version: tc.version,
FormatVersion: tc.formatVersion,
}
// Only exact version matches are supported for now
isSupported := backup.Version == "2.0" && backup.FormatVersion == "2.0"
if backup.Version == "1.0" && backup.FormatVersion == "1.0" {
isSupported = true
}
if isSupported != tc.shouldSupport {
t.Errorf("Version %s support expectation mismatch: expected %v, got %v",
tc.version, tc.shouldSupport, isSupported)
}
})
}
}
func TestClimbTypeValidation(t *testing.T) {
validClimbTypes := []string{"boulder", "sport", "trad", "toprope", "aid", "ice", "mixed"}
invalidClimbTypes := []string{"", "invalid", "BOULDER", "Sport", "unknown"}
for _, climbType := range validClimbTypes {
if !isValidClimbType(climbType) {
t.Errorf("Valid climb type %s was marked as invalid", climbType)
}
}
for _, climbType := range invalidClimbTypes {
if isValidClimbType(climbType) {
t.Errorf("Invalid climb type %s was marked as valid", climbType)
}
}
}
func isValidClimbType(climbType string) bool {
validTypes := map[string]bool{
"boulder": true,
"sport": true,
"trad": true,
"toprope": true,
"aid": true,
"ice": true,
"mixed": true,
}
return validTypes[climbType]
}
func TestAttemptResultValidation(t *testing.T) {
validResults := []string{"completed", "failed", "flash", "project", "attempt"}
invalidResults := []string{"", "invalid", "COMPLETED", "Failed", "unknown"}
for _, result := range validResults {
if !isValidAttemptResult(result) {
t.Errorf("Valid attempt result %s was marked as invalid", result)
}
}
for _, result := range invalidResults {
if isValidAttemptResult(result) {
t.Errorf("Invalid attempt result %s was marked as valid", result)
}
}
}
// Helper functions for creating pointers
func stringPtr(s string) *string {
return &s
}
func int64Ptr(i int64) *int64 {
return &i
}
func isValidAttemptResult(result string) bool {
validResults := map[string]bool{
"completed": true,
"failed": true,
"flash": true,
"project": true,
"attempt": true,
}
return validResults[result]
}

View File

@@ -1,3 +1,3 @@
module openclimb-sync
go 1.25
go 1.21

361
sync/main_test.go Normal file
View File

@@ -0,0 +1,361 @@
package main
import (
"encoding/json"
"path/filepath"
"strings"
"testing"
"time"
)
func TestSyncServerAuthentication(t *testing.T) {
server := &SyncServer{authToken: "test-token"}
tests := []struct {
name string
token string
expected bool
}{
{"Valid token", "test-token", true},
{"Invalid token", "wrong-token", false},
{"Empty token", "", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Test the authentication logic directly without HTTP
result := strings.Compare(tt.token, server.authToken) == 0
if result != tt.expected {
t.Errorf("authenticate() = %v, want %v", result, tt.expected)
}
})
}
}
func TestLoadDataNonExistentFile(t *testing.T) {
tempDir := t.TempDir()
server := &SyncServer{
dataFile: filepath.Join(tempDir, "nonexistent.json"),
}
backup, err := server.loadData()
if err != nil {
t.Errorf("loadData() error = %v, want nil", err)
}
if backup == nil {
t.Error("Expected backup to be non-nil")
}
if len(backup.Gyms) != 0 || len(backup.Problems) != 0 || len(backup.Sessions) != 0 || len(backup.Attempts) != 0 {
t.Error("Expected empty backup data")
}
if backup.Version != "2.0" || backup.FormatVersion != "2.0" {
t.Error("Expected version and format version to be 2.0")
}
}
func TestSaveAndLoadData(t *testing.T) {
tempDir := t.TempDir()
server := &SyncServer{
dataFile: filepath.Join(tempDir, "test.json"),
imagesDir: filepath.Join(tempDir, "images"),
}
testData := &ClimbDataBackup{
Version: "2.0",
FormatVersion: "2.0",
Gyms: []BackupGym{
{
ID: "gym1",
Name: "Test Gym",
},
},
Problems: []BackupProblem{
{
ID: "problem1",
GymID: "gym1",
ClimbType: "BOULDER",
Difficulty: DifficultyGrade{
System: "V",
Grade: "V5",
NumericValue: 5,
},
IsActive: true,
},
},
Sessions: []BackupClimbSession{},
Attempts: []BackupAttempt{},
}
err := server.saveData(testData)
if err != nil {
t.Errorf("saveData() error = %v", err)
}
loadedData, err := server.loadData()
if err != nil {
t.Errorf("loadData() error = %v", err)
}
if len(loadedData.Gyms) != 1 || loadedData.Gyms[0].ID != "gym1" {
t.Error("Loaded gym data doesn't match saved data")
}
if len(loadedData.Problems) != 1 || loadedData.Problems[0].ID != "problem1" {
t.Error("Loaded problem data doesn't match saved data")
}
}
func TestMinFunction(t *testing.T) {
tests := []struct {
a, b, expected int
}{
{5, 3, 3},
{2, 8, 2},
{4, 4, 4},
{0, 1, 0},
{-1, 2, -1},
}
for _, tt := range tests {
result := min(tt.a, tt.b)
if result != tt.expected {
t.Errorf("min(%d, %d) = %d, want %d", tt.a, tt.b, result, tt.expected)
}
}
}
func TestClimbDataBackupValidation(t *testing.T) {
tests := []struct {
name string
backup ClimbDataBackup
isValid bool
}{
{
name: "Valid backup",
backup: ClimbDataBackup{
Version: "2.0",
FormatVersion: "2.0",
Gyms: []BackupGym{},
Problems: []BackupProblem{},
Sessions: []BackupClimbSession{},
Attempts: []BackupAttempt{},
},
isValid: true,
},
{
name: "Missing version",
backup: ClimbDataBackup{
FormatVersion: "2.0",
Gyms: []BackupGym{},
Problems: []BackupProblem{},
Sessions: []BackupClimbSession{},
Attempts: []BackupAttempt{},
},
isValid: false,
},
{
name: "Missing format version",
backup: ClimbDataBackup{
Version: "2.0",
Gyms: []BackupGym{},
Problems: []BackupProblem{},
Sessions: []BackupClimbSession{},
Attempts: []BackupAttempt{},
},
isValid: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Test basic validation logic
hasVersion := tt.backup.Version != ""
hasFormatVersion := tt.backup.FormatVersion != ""
isValid := hasVersion && hasFormatVersion
if isValid != tt.isValid {
t.Errorf("validation = %v, want %v", isValid, tt.isValid)
}
})
}
}
func TestBackupDataStructures(t *testing.T) {
t.Run("BackupGym", func(t *testing.T) {
gym := BackupGym{
ID: "gym1",
Name: "Test Gym",
SupportedClimbTypes: []string{"BOULDER", "ROPE"},
DifficultySystems: []string{"V", "YDS"},
CustomDifficultyGrades: []string{},
CreatedAt: "2024-01-01T10:00:00Z",
UpdatedAt: "2024-01-01T10:00:00Z",
}
if gym.ID != "gym1" {
t.Errorf("Expected gym ID 'gym1', got %s", gym.ID)
}
if len(gym.SupportedClimbTypes) != 2 {
t.Errorf("Expected 2 climb types, got %d", len(gym.SupportedClimbTypes))
}
})
t.Run("BackupProblem", func(t *testing.T) {
problem := BackupProblem{
ID: "problem1",
GymID: "gym1",
ClimbType: "BOULDER",
Difficulty: DifficultyGrade{
System: "V",
Grade: "V5",
NumericValue: 5,
},
IsActive: true,
CreatedAt: "2024-01-01T10:00:00Z",
UpdatedAt: "2024-01-01T10:00:00Z",
}
if problem.ClimbType != "BOULDER" {
t.Errorf("Expected climb type 'BOULDER', got %s", problem.ClimbType)
}
if problem.Difficulty.Grade != "V5" {
t.Errorf("Expected difficulty 'V5', got %s", problem.Difficulty.Grade)
}
})
}
func TestDifficultyGrade(t *testing.T) {
tests := []struct {
name string
grade DifficultyGrade
expectedGrade string
expectedValue int
}{
{
name: "V-Scale grade",
grade: DifficultyGrade{
System: "V",
Grade: "V5",
NumericValue: 5,
},
expectedGrade: "V5",
expectedValue: 5,
},
{
name: "YDS grade",
grade: DifficultyGrade{
System: "YDS",
Grade: "5.10a",
NumericValue: 10,
},
expectedGrade: "5.10a",
expectedValue: 10,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.grade.Grade != tt.expectedGrade {
t.Errorf("Expected grade %s, got %s", tt.expectedGrade, tt.grade.Grade)
}
if tt.grade.NumericValue != tt.expectedValue {
t.Errorf("Expected numeric value %d, got %d", tt.expectedValue, tt.grade.NumericValue)
}
})
}
}
func TestJSONSerialization(t *testing.T) {
backup := ClimbDataBackup{
Version: "2.0",
FormatVersion: "2.0",
Gyms: []BackupGym{
{
ID: "gym1",
Name: "Test Gym",
},
},
Problems: []BackupProblem{
{
ID: "problem1",
GymID: "gym1",
ClimbType: "BOULDER",
Difficulty: DifficultyGrade{
System: "V",
Grade: "V5",
NumericValue: 5,
},
IsActive: true,
},
},
Sessions: []BackupClimbSession{},
Attempts: []BackupAttempt{},
}
// Test JSON marshaling
jsonData, err := json.Marshal(backup)
if err != nil {
t.Errorf("Failed to marshal JSON: %v", err)
}
// Test JSON unmarshaling
var unmarshaledBackup ClimbDataBackup
err = json.Unmarshal(jsonData, &unmarshaledBackup)
if err != nil {
t.Errorf("Failed to unmarshal JSON: %v", err)
}
if unmarshaledBackup.Version != backup.Version {
t.Errorf("Version mismatch after JSON round-trip")
}
if len(unmarshaledBackup.Gyms) != len(backup.Gyms) {
t.Errorf("Gyms count mismatch after JSON round-trip")
}
}
func TestTimestampHandling(t *testing.T) {
now := time.Now().UTC()
timestamp := now.Format(time.RFC3339)
// Test that timestamp is in correct format
parsedTime, err := time.Parse(time.RFC3339, timestamp)
if err != nil {
t.Errorf("Failed to parse timestamp: %v", err)
}
if parsedTime.Year() != now.Year() {
t.Errorf("Year mismatch in timestamp")
}
}
func TestFilePathHandling(t *testing.T) {
tempDir := t.TempDir()
tests := []struct {
name string
filename string
isValid bool
}{
{"Valid filename", "test.json", true},
{"Valid path", filepath.Join(tempDir, "data.json"), true},
{"Empty filename", "", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
isEmpty := tt.filename == ""
isValid := !isEmpty
if isValid != tt.isValid {
t.Errorf("File path validation = %v, want %v", isValid, tt.isValid)
}
})
}
}