1.6.0 for Android and 1.1.0 for iOS - Finalizing Export and Import

formats :)
This commit is contained in:
2025-09-28 02:37:03 -06:00
parent cf2e2f7c57
commit c3f847e1e6
48 changed files with 6944 additions and 1107 deletions

View File

@@ -0,0 +1,451 @@
package com.atridad.openclimb
import com.atridad.openclimb.data.format.*
import com.atridad.openclimb.data.model.*
import org.junit.Assert.*
import org.junit.Test
class SyncMergeLogicTest {
@Test
fun `test intelligent merge preserves all data`() {
// Create local data
val localGyms =
listOf(
BackupGym(
id = "gym1",
name = "Local Gym 1",
location = "Local Location",
supportedClimbTypes = listOf(ClimbType.BOULDER),
difficultySystems = listOf(DifficultySystem.V_SCALE),
customDifficultyGrades = emptyList(),
notes = null,
createdAt = "2024-01-01T10:00:00",
updatedAt = "2024-01-01T10:00:00"
)
)
val localProblems =
listOf(
BackupProblem(
id = "problem1",
gymId = "gym1",
name = "Local Problem",
description = "Local description",
climbType = ClimbType.BOULDER,
difficulty = DifficultyGrade(DifficultySystem.V_SCALE, "V5"),
tags = listOf("local"),
location = null,
imagePaths = listOf("local_image.jpg"),
isActive = true,
dateSet = null,
notes = null,
createdAt = "2024-01-01T10:00:00",
updatedAt = "2024-01-01T10:00:00"
)
)
val localSessions =
listOf(
BackupClimbSession(
id = "session1",
gymId = "gym1",
date = "2024-01-01",
startTime = "2024-01-01T10:00:00",
endTime = "2024-01-01T12:00:00",
duration = 7200,
status = SessionStatus.COMPLETED,
notes = null,
createdAt = "2024-01-01T10:00:00",
updatedAt = "2024-01-01T10:00:00"
)
)
val localAttempts =
listOf(
BackupAttempt(
id = "attempt1",
sessionId = "session1",
problemId = "problem1",
result = AttemptResult.COMPLETED,
highestHold = null,
notes = null,
duration = 300,
restTime = null,
timestamp = "2024-01-01T10:30:00",
createdAt = "2024-01-01T10:30:00"
)
)
val localBackup =
ClimbDataBackup(
exportedAt = "2024-01-01T10:00:00",
version = "2.0",
formatVersion = "2.0",
gyms = localGyms,
problems = localProblems,
sessions = localSessions,
attempts = localAttempts
)
// Create server data with some overlapping and some unique data
val serverGyms =
listOf(
// Same gym but with newer update
BackupGym(
id = "gym1",
name = "Updated Gym 1",
location = "Updated Location",
supportedClimbTypes = listOf(ClimbType.BOULDER, ClimbType.SPORT),
difficultySystems =
listOf(DifficultySystem.V_SCALE, DifficultySystem.YDS),
customDifficultyGrades = emptyList(),
notes = "Updated notes",
createdAt = "2024-01-01T10:00:00",
updatedAt = "2024-01-01T12:00:00" // Newer update
),
// Unique server gym
BackupGym(
id = "gym2",
name = "Server Gym 2",
location = "Server Location",
supportedClimbTypes = listOf(ClimbType.TRAD),
difficultySystems = listOf(DifficultySystem.YDS),
customDifficultyGrades = emptyList(),
notes = null,
createdAt = "2024-01-01T11:00:00",
updatedAt = "2024-01-01T11:00:00"
)
)
val serverProblems =
listOf(
// Same problem but with newer update and different images
BackupProblem(
id = "problem1",
gymId = "gym1",
name = "Updated Problem",
description = "Updated description",
climbType = ClimbType.BOULDER,
difficulty = DifficultyGrade(DifficultySystem.V_SCALE, "V5"),
tags = listOf("updated", "server"),
location = "Updated location",
imagePaths = listOf("server_image.jpg"),
isActive = true,
dateSet = "2024-01-01",
notes = "Updated notes",
createdAt = "2024-01-01T10:00:00",
updatedAt = "2024-01-01T11:00:00" // Newer update
),
// Unique server problem
BackupProblem(
id = "problem2",
gymId = "gym2",
name = "Server Problem",
description = "Server description",
climbType = ClimbType.TRAD,
difficulty = DifficultyGrade(DifficultySystem.YDS, "5.10a"),
tags = listOf("server"),
location = null,
imagePaths = null,
isActive = true,
dateSet = null,
notes = null,
createdAt = "2024-01-01T11:00:00",
updatedAt = "2024-01-01T11:00:00"
)
)
val serverSessions =
listOf(
// Unique server session
BackupClimbSession(
id = "session2",
gymId = "gym2",
date = "2024-01-02",
startTime = "2024-01-02T14:00:00",
endTime = "2024-01-02T16:00:00",
duration = 7200,
status = SessionStatus.COMPLETED,
notes = "Server session",
createdAt = "2024-01-02T14:00:00",
updatedAt = "2024-01-02T14:00:00"
)
)
val serverAttempts =
listOf(
// Unique server attempt
BackupAttempt(
id = "attempt2",
sessionId = "session2",
problemId = "problem2",
result = AttemptResult.FELL,
highestHold = "Last move",
notes = "Almost had it",
duration = 180,
restTime = 60,
timestamp = "2024-01-02T14:30:00",
createdAt = "2024-01-02T14:30:00"
)
)
val serverBackup =
ClimbDataBackup(
exportedAt = "2024-01-01T12:00:00",
version = "2.0",
formatVersion = "2.0",
gyms = serverGyms,
problems = serverProblems,
sessions = serverSessions,
attempts = serverAttempts
)
// Simulate merge logic
val mergedBackup = performIntelligentMerge(localBackup, serverBackup)
// Verify merge results
assertEquals("Should have 2 gyms (1 updated, 1 new)", 2, mergedBackup.gyms.size)
assertEquals("Should have 2 problems (1 updated, 1 new)", 2, mergedBackup.problems.size)
assertEquals("Should have 2 sessions (1 local, 1 server)", 2, mergedBackup.sessions.size)
assertEquals("Should have 2 attempts (1 local, 1 server)", 2, mergedBackup.attempts.size)
// Verify gym merge - server version should win (newer update)
val mergedGym1 = mergedBackup.gyms.find { it.id == "gym1" }!!
assertEquals("Updated Gym 1", mergedGym1.name)
assertEquals("Updated Location", mergedGym1.location)
assertEquals("Updated notes", mergedGym1.notes)
assertEquals("2024-01-01T12:00:00", mergedGym1.updatedAt)
// Verify unique server gym is preserved
val mergedGym2 = mergedBackup.gyms.find { it.id == "gym2" }!!
assertEquals("Server Gym 2", mergedGym2.name)
// Verify problem merge - server version should win but images should be merged
val mergedProblem1 = mergedBackup.problems.find { it.id == "problem1" }!!
assertEquals("Updated Problem", mergedProblem1.name)
assertEquals("Updated description", mergedProblem1.description)
assertEquals("2024-01-01T11:00:00", mergedProblem1.updatedAt)
// Images should be merged (both local and server images preserved)
assertTrue(
"Should contain local image",
mergedProblem1.imagePaths!!.contains("local_image.jpg")
)
assertTrue(
"Should contain server image",
mergedProblem1.imagePaths!!.contains("server_image.jpg")
)
assertEquals("Should have 2 images total", 2, mergedProblem1.imagePaths!!.size)
// Verify unique server problem is preserved
val mergedProblem2 = mergedBackup.problems.find { it.id == "problem2" }!!
assertEquals("Server Problem", mergedProblem2.name)
// Verify all sessions are preserved
assertTrue(
"Should contain local session",
mergedBackup.sessions.any { it.id == "session1" }
)
assertTrue(
"Should contain server session",
mergedBackup.sessions.any { it.id == "session2" }
)
// Verify all attempts are preserved
assertTrue(
"Should contain local attempt",
mergedBackup.attempts.any { it.id == "attempt1" }
)
assertTrue(
"Should contain server attempt",
mergedBackup.attempts.any { it.id == "attempt2" }
)
}
@Test
fun `test date comparison logic`() {
assertTrue(
"ISO instant should be newer",
isNewerThan("2024-01-01T12:00:00Z", "2024-01-01T10:00:00Z")
)
assertFalse(
"ISO instant should be older",
isNewerThan("2024-01-01T10:00:00Z", "2024-01-01T12:00:00Z")
)
assertTrue(
"String comparison should work as fallback",
isNewerThan("2024-01-02T10:00:00", "2024-01-01T10:00:00")
)
}
@Test
fun `test empty data scenarios`() {
val emptyBackup =
ClimbDataBackup(
exportedAt = "2024-01-01T10:00:00",
version = "2.0",
formatVersion = "2.0",
gyms = emptyList(),
problems = emptyList(),
sessions = emptyList(),
attempts = emptyList()
)
val dataBackup =
ClimbDataBackup(
exportedAt = "2024-01-01T10:00:00",
version = "2.0",
formatVersion = "2.0",
gyms =
listOf(
BackupGym(
id = "gym1",
name = "Test Gym",
location = null,
supportedClimbTypes = listOf(ClimbType.BOULDER),
difficultySystems =
listOf(DifficultySystem.V_SCALE),
customDifficultyGrades = emptyList(),
notes = null,
createdAt = "2024-01-01T10:00:00",
updatedAt = "2024-01-01T10:00:00"
)
),
problems = emptyList(),
sessions = emptyList(),
attempts = emptyList()
)
// Test merging empty with data
val merged1 = performIntelligentMerge(emptyBackup, dataBackup)
assertEquals("Should preserve data from non-empty backup", 1, merged1.gyms.size)
// Test merging data with empty
val merged2 = performIntelligentMerge(dataBackup, emptyBackup)
assertEquals("Should preserve data from non-empty backup", 1, merged2.gyms.size)
// Test merging empty with empty
val merged3 = performIntelligentMerge(emptyBackup, emptyBackup)
assertEquals("Should remain empty", 0, merged3.gyms.size)
}
// Helper methods that simulate the merge logic from SyncService
private fun performIntelligentMerge(
local: ClimbDataBackup,
server: ClimbDataBackup
): ClimbDataBackup {
val mergedGyms = mergeGyms(local.gyms, server.gyms)
val mergedProblems = mergeProblems(local.problems, server.problems)
val mergedSessions = mergeSessions(local.sessions, server.sessions)
val mergedAttempts = mergeAttempts(local.attempts, server.attempts)
return ClimbDataBackup(
exportedAt = "2024-01-01T12:00:00",
version = "2.0",
formatVersion = "2.0",
gyms = mergedGyms,
problems = mergedProblems,
sessions = mergedSessions,
attempts = mergedAttempts
)
}
private fun mergeGyms(local: List<BackupGym>, server: List<BackupGym>): List<BackupGym> {
val merged = mutableMapOf<String, BackupGym>()
// Add all local gyms
local.forEach { gym -> merged[gym.id] = gym }
// Add server gyms, preferring newer updates
server.forEach { serverGym ->
val localGym = merged[serverGym.id]
if (localGym == null || isNewerThan(serverGym.updatedAt, localGym.updatedAt)) {
merged[serverGym.id] = serverGym
}
}
return merged.values.toList()
}
private fun mergeProblems(
local: List<BackupProblem>,
server: List<BackupProblem>
): List<BackupProblem> {
val merged = mutableMapOf<String, BackupProblem>()
// Add all local problems
local.forEach { problem -> merged[problem.id] = problem }
// Add server problems, preferring newer updates
server.forEach { serverProblem ->
val localProblem = merged[serverProblem.id]
if (localProblem == null || isNewerThan(serverProblem.updatedAt, localProblem.updatedAt)
) {
// Merge image paths to preserve all images
val allImagePaths = mutableSetOf<String>()
localProblem?.imagePaths?.let { allImagePaths.addAll(it) }
serverProblem.imagePaths?.let { allImagePaths.addAll(it) }
merged[serverProblem.id] =
serverProblem.withUpdatedImagePaths(allImagePaths.toList())
}
}
return merged.values.toList()
}
private fun mergeSessions(
local: List<BackupClimbSession>,
server: List<BackupClimbSession>
): List<BackupClimbSession> {
val merged = mutableMapOf<String, BackupClimbSession>()
// Add all local sessions
local.forEach { session -> merged[session.id] = session }
// Add server sessions, preferring newer updates
server.forEach { serverSession ->
val localSession = merged[serverSession.id]
if (localSession == null || isNewerThan(serverSession.updatedAt, localSession.updatedAt)
) {
merged[serverSession.id] = serverSession
}
}
return merged.values.toList()
}
private fun mergeAttempts(
local: List<BackupAttempt>,
server: List<BackupAttempt>
): List<BackupAttempt> {
val merged = mutableMapOf<String, BackupAttempt>()
// Add all local attempts
local.forEach { attempt -> merged[attempt.id] = attempt }
// Add server attempts, preferring newer updates
server.forEach { serverAttempt ->
val localAttempt = merged[serverAttempt.id]
if (localAttempt == null || isNewerThan(serverAttempt.createdAt, localAttempt.createdAt)
) {
merged[serverAttempt.id] = serverAttempt
}
}
return merged.values.toList()
}
private fun isNewerThan(dateString1: String, dateString2: String): Boolean {
return try {
// Try parsing as instant first
val date1 = java.time.Instant.parse(dateString1)
val date2 = java.time.Instant.parse(dateString2)
date1.isAfter(date2)
} catch (e: Exception) {
// Fallback to string comparison
dateString1 > dateString2
}
}
}