601 lines
21 KiB
Kotlin
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() }
|
|
}
|
|
}
|
|
}
|