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
}