fix(clinical): 조건 완료 판정을 실행 단위 → 조합 단위 합산으로
화면에서 6조합 중 일부만 골라 돌릴 수 있게 되면서(다음 커밋) 판정이 깨진다. 종전에는 실행 하나만 보고 "6조합이 다 있나"를 따졌는데, 나눠 재면 어느 실행도 6개를 못 채워 **영원히 PARTIAL** 로 남는다. 그러면 "덜 됐다"는 표시가 의미를 잃는다. 같은 자세·충만도의 여러 실행을 조합 단위로 합산한다. 조합 하나가 여러 실행에 나오면 한 번이라도 계획을 채운 실행이 있으면 그 조합은 끝난 것으로 본다 — 20회 중 3회에서 끊긴 뒤 다시 20회를 채웠다면 완료다. 판정 전부를 progressFrom(manifests) 순수 함수로 떼어 시험으로 고정했다. 이 판정이 틀리면 간호사가 다 됐다고 믿고 방광을 비우는데 데이터는 못 쓰고, 그 단계는 그날 다시 못 잰다. 실기기 없이도 고정해 둬야 한다. 나눠 재기 · 재측정 · 계획 미달 · planned 없는 옛 매니페스트 · 깨진 JSON · 조건 간 혼선 8건. org.json 은 안드로이드 단위테스트에서 모든 메서드가 스텁이라(isReturnDefaultValues 로도 안 된다 — 반환값이 아니라 동작이 없다) 실제 구현을 테스트 의존성으로 넣었다. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -112,6 +112,10 @@ dependencies {
|
|||||||
implementation(libs.mcumgr.ble) // Firmware DFU (SMP-over-BLE)
|
implementation(libs.mcumgr.ble) // Firmware DFU (SMP-over-BLE)
|
||||||
|
|
||||||
testImplementation(libs.junit)
|
testImplementation(libs.junit)
|
||||||
|
// org.json 은 안드로이드 단위테스트에서 **모든 메서드가 스텁**이라 호출하면 예외가
|
||||||
|
// 난다(isReturnDefaultValues 로도 안 된다 — 반환값이 아니라 동작이 없다).
|
||||||
|
// 매니페스트 판정처럼 JSON 을 실제로 파싱하는 로직을 시험하려면 진짜 구현이 필요하다.
|
||||||
|
testImplementation("org.json:json:20240303")
|
||||||
androidTestImplementation(libs.androidx.junit)
|
androidTestImplementation(libs.androidx.junit)
|
||||||
androidTestImplementation(libs.androidx.espresso.core)
|
androidTestImplementation(libs.androidx.espresso.core)
|
||||||
androidTestImplementation(platform(libs.androidx.compose.bom))
|
androidTestImplementation(platform(libs.androidx.compose.bom))
|
||||||
|
|||||||
@@ -220,31 +220,59 @@ object HospitalRunStore {
|
|||||||
* 매니페스트는 `finally` 에서 쓰므로 중단된 실행에도 남는다. 그 경우 조합이
|
* 매니페스트는 `finally` 에서 쓰므로 중단된 실행에도 남는다. 그 경우 조합이
|
||||||
* 모자라거나 saved < planned 라 PARTIAL 로 잡힌다.
|
* 모자라거나 saved < planned 라 PARTIAL 로 잡힌다.
|
||||||
*
|
*
|
||||||
* 같은 조건을 여러 번 실행했으면(정상적인 재측정) **가장 좋은 결과**를 취한다.
|
* 같은 조건의 여러 실행은 **조합 단위로 합산한다.**
|
||||||
* 한 번이라도 제대로 받았으면 그 조건은 끝난 것이다.
|
*
|
||||||
|
* 실행 하나만 보고 판정하면 안 된다 — 화면에서 조합을 골라 나눠 잴 수 있기 때문이다
|
||||||
|
* (2.3MHz·c5 만 먼저 재고 나머지를 나중에). 실행별로 보면 어느 것도 6조합을 채우지
|
||||||
|
* 못해 영원히 PARTIAL 로 남고, 그러면 "덜 됐다"는 표시가 의미를 잃는다.
|
||||||
|
*
|
||||||
|
* 조합 하나가 여러 실행에 나오면(재측정) **한 번이라도 계획을 채운 실행**이 있으면
|
||||||
|
* 그 조합은 끝난 것으로 본다. 20회 중 3회에서 끊긴 뒤 다시 20회를 채웠다면 그
|
||||||
|
* 조합은 완료다.
|
||||||
*
|
*
|
||||||
* @return (자세 label, 방광 %) → 진행 상태
|
* @return (자세 label, 방광 %) → 진행 상태
|
||||||
*/
|
*/
|
||||||
fun scanProgress(patient: String, day: Date): Map<Pair<String, Int>, RunProgress> {
|
fun scanProgress(patient: String, day: Date): Map<Pair<String, Int>, RunProgress> {
|
||||||
val dir = runDirIfExists(patient, day) ?: return emptyMap()
|
val dir = runDirIfExists(patient, day) ?: return emptyMap()
|
||||||
val out = HashMap<Pair<String, Int>, RunProgress>()
|
val texts = dir.listFiles { f ->
|
||||||
dir.listFiles { f -> f.isFile && f.name.startsWith("run_") && f.name.endsWith(".json") }
|
f.isFile && f.name.startsWith("run_") && f.name.endsWith(".json")
|
||||||
?.forEach { f ->
|
}?.mapNotNull { runCatching { it.readText(Charsets.UTF_8) }.getOrNull() } ?: emptyList()
|
||||||
|
return progressFrom(texts)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 매니페스트 본문들 → 조건별 진행 상태. **판정 전부가 여기 있다.**
|
||||||
|
*
|
||||||
|
* 파일 읽기와 분리해 둔 이유는 시험 때문이다. 이 판정이 틀리면 간호사가 "다 됐다"고
|
||||||
|
* 믿고 방광을 비우는데 데이터는 못 쓰는 상황이 되므로, 실기기 없이도 고정해 둬야 한다.
|
||||||
|
*/
|
||||||
|
internal fun progressFrom(manifests: List<String>): Map<Pair<String, Int>, RunProgress> {
|
||||||
|
// (자세, 충만도) → 계획을 채운 조합들. 조합 식별자는 "주파수옵션/cycle" 이다.
|
||||||
|
val filled = HashMap<Pair<String, Int>, MutableSet<String>>()
|
||||||
|
// 매니페스트는 있는데 채운 조합이 하나도 없는 경우도 PARTIAL 로 남겨야 한다.
|
||||||
|
val seen = HashSet<Pair<String, Int>>()
|
||||||
|
manifests.forEach { text ->
|
||||||
runCatching {
|
runCatching {
|
||||||
val o = JSONObject(f.readText(Charsets.UTF_8))
|
val o = JSONObject(text)
|
||||||
val key = o.getString("posture") to o.getInt("fill_pct")
|
val key = o.getString("posture") to o.getInt("fill_pct")
|
||||||
|
seen.add(key)
|
||||||
val combos = o.getJSONArray("combinations")
|
val combos = o.getJSONArray("combinations")
|
||||||
// 6조합이 다 있고, 각 조합이 계획한 횟수를 채웠을 때만 DONE.
|
|
||||||
var done = combos.length() == EXPECTED_COMBINATIONS
|
|
||||||
for (i in 0 until combos.length()) {
|
for (i in 0 until combos.length()) {
|
||||||
val c = combos.getJSONObject(i)
|
val c = combos.getJSONObject(i)
|
||||||
if (c.optInt("saved") < c.optInt("planned", Int.MAX_VALUE)) done = false
|
// 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<Pair<String, Int>, RunProgress>()
|
||||||
|
for (key in seen) {
|
||||||
|
val n = filled[key]?.size ?: 0
|
||||||
|
out[key] = if (n >= EXPECTED_COMBINATIONS) RunProgress.DONE else RunProgress.PARTIAL
|
||||||
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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])
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user