Files
Ascently/android/app/src/test/java/com/atridad/openclimb/BusinessLogicTests.kt

601 lines
21 KiB
Kotlin

package com.atridad.ascently
import com.atridad.ascently.data.format.*
import com.atridad.ascently.data.model.*
import org.junit.Assert.*
import org.junit.Test
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
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,
updatedAt = attempt.updatedAt,
)
},
)
}
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() }
}
}
}