fix(program): preserve V1 digest during Android V2 upgrade
This commit is contained in:
parent
c4df2c3b3f
commit
df41300353
2 changed files with 54 additions and 12 deletions
|
|
@ -11829,6 +11829,34 @@ class TrainlogRepository(
|
||||||
|
|
||||||
fun applyProgramsV2Json(json: String): ProgramsImportResult = applyProgramsJson(json, 2)
|
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 {
|
private fun applyProgramsJson(json: String, documentVersion: Int): ProgramsImportResult {
|
||||||
if (json.toByteArray(StandardCharsets.UTF_8).size > 8 * 1024 * 1024 ||
|
if (json.toByteArray(StandardCharsets.UTF_8).size > 8 * 1024 * 1024 ||
|
||||||
!jsonHasUniqueObjectKeys(json)) return ProgramsImportResult.Invalid("Invalid Programs JSON.")
|
!jsonHasUniqueObjectKeys(json)) return ProgramsImportResult.Invalid("Invalid Programs JSON.")
|
||||||
|
|
@ -12015,18 +12043,8 @@ class TrainlogRepository(
|
||||||
if (existing?.revisionId == program.getString("revision_id")) {
|
if (existing?.revisionId == program.getString("revision_id")) {
|
||||||
if (existing.digest == digest) { skipped++; continue }
|
if (existing.digest == digest) { skipped++; continue }
|
||||||
if (documentVersion != 2 || existing.version != 1 ||
|
if (documentVersion != 2 || existing.version != 1 ||
|
||||||
existing.digest != causalDigest(JSONObject(program.toString()).apply {
|
existing.digest != legacyProgramDigest(program))
|
||||||
remove("revision_sequence")
|
throw ProgramsImportInvalid("Program revision identity conflict.")
|
||||||
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.")
|
|
||||||
}
|
}
|
||||||
if (documentVersion == 2) {
|
if (documentVersion == 2) {
|
||||||
val activeProgramSession = db.rawQuery(
|
val activeProgramSession = db.rawQuery(
|
||||||
|
|
|
||||||
|
|
@ -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(
|
private fun preparationArtifact(
|
||||||
includeProgramProvenance: Boolean = true,
|
includeProgramProvenance: Boolean = true,
|
||||||
deliveryState: String = "pending",
|
deliveryState: String = "pending",
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue