fix(program): preserve V1 digest during Android V2 upgrade

This commit is contained in:
fy59 2026-10-01 07:53:07 +02:00
parent c4df2c3b3f
commit df41300353
2 changed files with 54 additions and 12 deletions

View file

@ -11829,6 +11829,34 @@ class TrainlogRepository(
fun applyProgramsV2Json(json: String): ProgramsImportResult = applyProgramsJson(json, 2)
private fun legacyProgramDigest(program: JSONObject): String {
// WHY: serializing and reparsing JSONObject changes integral doubles (for
// example 52.0 to 52), which changes a V1 revision's causal digest.
// CONTRACT: discard only V2 fields while retaining the parsed V1 values.
val legacy = JSONObject()
program.keys().forEach { key ->
when (key) {
"revision_sequence" -> Unit
"sessions" -> {
val rows = JSONArray()
val sessions = program.getJSONArray("sessions")
for (index in 0 until sessions.length()) {
val source = sessions.getJSONObject(index)
val row = JSONObject()
source.keys().forEach { sessionKey ->
if (sessionKey !in setOf("position", "current_for", "planning_state"))
row.put(sessionKey, source.get(sessionKey))
}
rows.put(row)
}
legacy.put(key, rows)
}
else -> legacy.put(key, program.get(key))
}
}
return causalDigest(legacy)
}
private fun applyProgramsJson(json: String, documentVersion: Int): ProgramsImportResult {
if (json.toByteArray(StandardCharsets.UTF_8).size > 8 * 1024 * 1024 ||
!jsonHasUniqueObjectKeys(json)) return ProgramsImportResult.Invalid("Invalid Programs JSON.")
@ -12015,18 +12043,8 @@ class TrainlogRepository(
if (existing?.revisionId == program.getString("revision_id")) {
if (existing.digest == digest) { skipped++; continue }
if (documentVersion != 2 || existing.version != 1 ||
existing.digest != causalDigest(JSONObject(program.toString()).apply {
remove("revision_sequence")
getJSONArray("sessions").let { rows ->
for (sessionIndex in 0 until rows.length()) {
rows.getJSONObject(sessionIndex).apply {
remove("position")
remove("current_for")
remove("planning_state")
}
}
}
})) throw ProgramsImportInvalid("Program revision identity conflict.")
existing.digest != legacyProgramDigest(program))
throw ProgramsImportInvalid("Program revision identity conflict.")
}
if (documentVersion == 2) {
val activeProgramSession = db.rawQuery(

View file

@ -131,6 +131,30 @@ class ProgramsProjectionRepositoryTest {
}
}
@Test
fun v2UpgradeKeepsV1DigestForIntegralDecimalWeights() {
val name = "program-decimal-upgrade-${UUID.randomUUID()}.db"
val repository = TrainlogRepository(context, name)
try {
// The desktop JSON exporter writes SQLite REAL values as 40.0.
// JSONObject's own serialization writes the same value as 40.
val v1 = artifact(JSONArray().put(program()))
.replace("\"target_weight_kg\":40", "\"target_weight_kg\":40.0")
val v2 = artifactV2(program())
.replace("\"target_weight_kg\":40", "\"target_weight_kg\":40.0")
assertTrue(v1.contains("\"target_weight_kg\":40.0"))
assertTrue(v2.contains("\"target_weight_kg\":40.0"))
assertEquals(ProgramsImportResult.Applied(1, 0, 0), repository.applyProgramsV1Json(v1))
assertEquals(ProgramsImportResult.Applied(1, 0, 0), repository.applyProgramsV2Json(v2))
assertEquals("2026-10-01", repository.getSyncedProgram(program().getString("program_id"))!!
.sessions.single().currentFor)
assertEquals(ProgramsImportResult.Applied(0, 0, 1), repository.applyProgramsV2Json(v2))
} finally {
repository.close()
context.deleteDatabase(name)
}
}
private fun preparationArtifact(
includeProgramProvenance: Boolean = true,
deliveryState: String = "pending",