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:
2026-09-07 15:50:29 +09:00
parent 33e5946895
commit c7cddbf9c9
3 changed files with 146 additions and 17 deletions
@@ -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<Pair<String, Int>, RunProgress> {
val dir = runDirIfExists(patient, day) ?: return emptyMap()
val out = HashMap<Pair<String, Int>, 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<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 {
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<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
}
@@ -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])
}
}