diff --git a/app/build.gradle.kts b/app/build.gradle.kts index c8c12f8..70e391d 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -112,6 +112,10 @@ dependencies { implementation(libs.mcumgr.ble) // Firmware DFU (SMP-over-BLE) testImplementation(libs.junit) + // org.json 은 안드로이드 단위테스트에서 **모든 메서드가 스텁**이라 호출하면 예외가 + // 난다(isReturnDefaultValues 로도 안 된다 — 반환값이 아니라 동작이 없다). + // 매니페스트 판정처럼 JSON 을 실제로 파싱하는 로직을 시험하려면 진짜 구현이 필요하다. + testImplementation("org.json:json:20240303") androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) androidTestImplementation(platform(libs.androidx.compose.bom)) diff --git a/app/src/main/java/com/medithings/vesiscan/services/HospitalRunStore.kt b/app/src/main/java/com/medithings/vesiscan/services/HospitalRunStore.kt index 3e83101..409f9b4 100644 --- a/app/src/main/java/com/medithings/vesiscan/services/HospitalRunStore.kt +++ b/app/src/main/java/com/medithings/vesiscan/services/HospitalRunStore.kt @@ -220,31 +220,59 @@ object HospitalRunStore { * 매니페스트는 `finally` 에서 쓰므로 중단된 실행에도 남는다. 그 경우 조합이 * 모자라거나 saved < planned 라 PARTIAL 로 잡힌다. * - * 같은 조건을 여러 번 실행했으면(정상적인 재측정) **가장 좋은 결과**를 취한다. - * 한 번이라도 제대로 받았으면 그 조건은 끝난 것이다. + * 같은 조건의 여러 실행은 **조합 단위로 합산한다.** + * + * 실행 하나만 보고 판정하면 안 된다 — 화면에서 조합을 골라 나눠 잴 수 있기 때문이다 + * (2.3MHz·c5 만 먼저 재고 나머지를 나중에). 실행별로 보면 어느 것도 6조합을 채우지 + * 못해 영원히 PARTIAL 로 남고, 그러면 "덜 됐다"는 표시가 의미를 잃는다. + * + * 조합 하나가 여러 실행에 나오면(재측정) **한 번이라도 계획을 채운 실행**이 있으면 + * 그 조합은 끝난 것으로 본다. 20회 중 3회에서 끊긴 뒤 다시 20회를 채웠다면 그 + * 조합은 완료다. * * @return (자세 label, 방광 %) → 진행 상태 */ fun scanProgress(patient: String, day: Date): Map, RunProgress> { val dir = runDirIfExists(patient, day) ?: return emptyMap() - val out = HashMap, RunProgress>() - dir.listFiles { f -> f.isFile && f.name.startsWith("run_") && f.name.endsWith(".json") } - ?.forEach { f -> - runCatching { - val o = JSONObject(f.readText(Charsets.UTF_8)) - val key = o.getString("posture") to o.getInt("fill_pct") - val combos = o.getJSONArray("combinations") - // 6조합이 다 있고, 각 조합이 계획한 횟수를 채웠을 때만 DONE. - var done = combos.length() == EXPECTED_COMBINATIONS - for (i in 0 until combos.length()) { - val c = combos.getJSONObject(i) - if (c.optInt("saved") < c.optInt("planned", Int.MAX_VALUE)) done = false + val texts = dir.listFiles { f -> + f.isFile && f.name.startsWith("run_") && f.name.endsWith(".json") + }?.mapNotNull { runCatching { it.readText(Charsets.UTF_8) }.getOrNull() } ?: emptyList() + return progressFrom(texts) + } + + /** + * 매니페스트 본문들 → 조건별 진행 상태. **판정 전부가 여기 있다.** + * + * 파일 읽기와 분리해 둔 이유는 시험 때문이다. 이 판정이 틀리면 간호사가 "다 됐다"고 + * 믿고 방광을 비우는데 데이터는 못 쓰는 상황이 되므로, 실기기 없이도 고정해 둬야 한다. + */ + internal fun progressFrom(manifests: List): Map, RunProgress> { + // (자세, 충만도) → 계획을 채운 조합들. 조합 식별자는 "주파수옵션/cycle" 이다. + val filled = HashMap, MutableSet>() + // 매니페스트는 있는데 채운 조합이 하나도 없는 경우도 PARTIAL 로 남겨야 한다. + val seen = HashSet>() + manifests.forEach { text -> + runCatching { + val o = JSONObject(text) + val key = o.getString("posture") to o.getInt("fill_pct") + seen.add(key) + val combos = o.getJSONArray("combinations") + for (i in 0 until combos.length()) { + val c = combos.getJSONObject(i) + // planned 가 없으면 판정할 수 없다 — 채운 것으로 보지 않는다. + val planned = c.optInt("planned", Int.MAX_VALUE) + if (c.optInt("saved") >= planned) { + filled.getOrPut(key) { HashSet() } + .add("${c.optInt("freq_option")}/${c.optInt("cycles")}") } - val st = if (done) RunProgress.DONE else RunProgress.PARTIAL - // 재측정 — 더 좋은 쪽으로만 올린다. - if (out[key] != RunProgress.DONE) out[key] = st } } + } + val out = HashMap, RunProgress>() + for (key in seen) { + val n = filled[key]?.size ?: 0 + out[key] = if (n >= EXPECTED_COMBINATIONS) RunProgress.DONE else RunProgress.PARTIAL + } return out } diff --git a/app/src/test/java/com/medithings/vesiscan/services/HospitalProgressTest.kt b/app/src/test/java/com/medithings/vesiscan/services/HospitalProgressTest.kt new file mode 100644 index 0000000..6057559 --- /dev/null +++ b/app/src/test/java/com/medithings/vesiscan/services/HospitalProgressTest.kt @@ -0,0 +1,97 @@ +package com.medithings.vesiscan.services + +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * 조건별 진행 판정 — "이 자세·충만도를 다 쟀는가". + * + * 이 값이 화면 칩의 초록 체크를 정한다. 틀리면 간호사가 다 됐다고 믿고 방광을 비우는데 + * 데이터는 못 쓰고, 그 단계는 그날 다시 못 잰다. 실기기 없이도 고정해 둔다. + * + * 2026-09-07 에 판정이 **실행 단위 → 조합 단위 합산**으로 바뀌었다. 화면에서 6조합 중 + * 일부만 골라 돌릴 수 있게 되면서, 실행 하나만 보면 어느 것도 6조합을 못 채워 영원히 + * PARTIAL 로 남기 때문이다. + */ +class HospitalProgressTest { + + /** 조합 하나의 매니페스트 항목. */ + private fun combo(freqOption: Int, cycles: Int, planned: Int = 20, saved: Int = 20) = + """{"freq_option":$freqOption,"cycles":$cycles,"planned":$planned,"saved":$saved}""" + + private fun manifest(posture: String, fill: Int, vararg combos: String) = + """{"posture":"$posture","fill_pct":$fill,"combinations":[${combos.joinToString(",")}]}""" + + /** 프로토콜 6조합 = 주파수 옵션 0·5 × cycle 3·5·7. */ + private fun allSix(saved: Int = 20) = listOf(0, 5).flatMap { f -> + listOf(3, 5, 7).map { c -> combo(f, c, saved = saved) } + }.toTypedArray() + + @Test fun `6조합을 한 번에 채우면 완료`() { + val r = HospitalRunStore.progressFrom(listOf(manifest("Supine", 40, *allSix()))) + assertEquals(HospitalRunStore.RunProgress.DONE, r["Supine" to 40]) + } + + @Test fun `나눠 재도 다 채우면 완료다`() { + // 이번 변경의 핵심. 조합을 골라 세 번에 나눠 쟀다. + val r = HospitalRunStore.progressFrom(listOf( + manifest("Supine", 40, combo(0, 3), combo(0, 5)), + manifest("Supine", 40, combo(0, 7), combo(5, 3)), + manifest("Supine", 40, combo(5, 5), combo(5, 7)), + )) + assertEquals(HospitalRunStore.RunProgress.DONE, r["Supine" to 40]) + } + + @Test fun `한 조합이 비면 완료가 아니다`() { + val five = allSix().dropLast(1).toTypedArray() + val r = HospitalRunStore.progressFrom(listOf(manifest("Sitting", 60, *five))) + assertEquals(HospitalRunStore.RunProgress.PARTIAL, r["Sitting" to 60]) + } + + @Test fun `계획을 못 채운 조합은 안 센다`() { + // 20회 중 3회에서 끊긴 것을 완료로 보면 안 된다 — 그게 제일 위험하다. + val partial = listOf(0, 5).flatMap { f -> + listOf(3, 5, 7).map { c -> combo(f, c, planned = 20, saved = if (c == 7) 3 else 20) } + }.toTypedArray() + val r = HospitalRunStore.progressFrom(listOf(manifest("Supine", 0, *partial))) + assertEquals(HospitalRunStore.RunProgress.PARTIAL, r["Supine" to 0]) + } + + @Test fun `끊긴 뒤 다시 채우면 완료다`() { + // 재측정. 같은 조합이 두 실행에 나오고, 뒤쪽이 계획을 채웠다. + val r = HospitalRunStore.progressFrom(listOf( + manifest("Supine", 20, *listOf(0, 5).flatMap { f -> + listOf(3, 5, 7).map { c -> combo(f, c, saved = if (f == 5 && c == 7) 2 else 20) } + }.toTypedArray()), + manifest("Supine", 20, combo(5, 7, saved = 20)), + )) + assertEquals(HospitalRunStore.RunProgress.DONE, r["Supine" to 20]) + } + + @Test fun `조건이 다르면 섞이지 않는다`() { + val r = HospitalRunStore.progressFrom(listOf( + manifest("Supine", 40, *allSix()), + manifest("Sitting", 40, combo(0, 3)), + )) + assertEquals(HospitalRunStore.RunProgress.DONE, r["Supine" to 40]) + assertEquals(HospitalRunStore.RunProgress.PARTIAL, r["Sitting" to 40]) + assertEquals(null, r["Supine" to 60]) // 안 잰 조건은 아예 없다 + } + + @Test fun `planned 가 없으면 채운 것으로 보지 않는다`() { + // 옛 매니페스트나 깨진 파일. 모르는 것을 완료로 올리면 안 된다. + val noPlanned = listOf(0, 5).flatMap { f -> + listOf(3, 5, 7).map { c -> """{"freq_option":$f,"cycles":$c,"saved":20}""" } + }.toTypedArray() + val r = HospitalRunStore.progressFrom(listOf(manifest("Supine", 80, *noPlanned))) + assertEquals(HospitalRunStore.RunProgress.PARTIAL, r["Supine" to 80]) + } + + @Test fun `깨진 JSON 은 그 파일만 건너뛴다`() { + val r = HospitalRunStore.progressFrom(listOf( + "not json at all", + manifest("Supine", 100, *allSix()), + )) + assertEquals(HospitalRunStore.RunProgress.DONE, r["Supine" to 100]) + } +}