From af165412214b238e817a7f912f25687af29bbcd8 Mon Sep 17 00:00:00 2001 From: jjangddu Date: Thu, 10 Sep 2026 10:53:35 +0900 Subject: [PATCH] =?UTF-8?q?feat(clinical):=20=EC=A0=95=EB=A0=AC=EC=97=90?= =?UTF-8?q?=EC=84=9C=200~4cm=20=EB=A5=BC=20=EC=A0=84=EB=B6=80=20=EC=9E=AC?= =?UTF-8?q?=EA=B3=A0,=20=ED=8C=90=EC=A0=95=EC=9D=80=20=EB=A0=88=ED=8D=BC?= =?UTF-8?q?=EB=9F=B0=EC=8A=A4=20=EA=B7=B8=EB=8C=80=EB=A1=9C=20=EB=91=94?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 병원 임상은 위치를 고르는 것만이 목적이 아니라 **위치별 원신호를 모으는 것**도 목적이다. 그런데 조기 종료하면 2~3 위치만 남는다 — 2026-09-09 실측이 0·1cm 두 자리로 끝났고, 재분석할 거리가 없었다. 이제 종료 조건이 걸려도 SEARCH_MAX_CM(4cm)까지 계속 올리며 잰다. 수집이 끝나면 부착 위치로 내려가 확인 측정 → 확정. 중간에 [여기까지만 재고 부착 위치로] 로 끊을 수 있다. ## 판정은 바뀌지 않는다 — 이게 이 커밋의 핵심 selectBest 는 **넘긴 레코드 전부**를 후보로 본다. 후보가 늘면 답이 뒤집힐 수 있다: 0cm eligible nch=2 · 1cm ch3 소실 · 2cm eligible nch=4 조기 종료 후보 {0,1} → best 0cm 전부 넣으면 → best 2cm ← 다른 답 앱이 후자를 내면 레퍼런스(Python AnchorGuide)와 갈리고 검증의 근거가 무너진다. 안전한 이유는 AnchorGuide 가 `searching` 이 꺾이는 **그 순간 한 번만** bestCm 을 계산하고 그 뒤 step() 은 recs 에만 쌓기 때문이다. 화면이 그 성질에 의존하므로 AnchorSweepTest 가 고정한다 — 누가 step() 에서 매번 다시 고르게 바꾸면 그 파일이 먼저 깨진다. ## 경계를 기록에 남긴다 안 남기면 재분석하는 쪽이 전 위치를 후보로 넣어 다시 판정하고, 다른 best 가 나와 **앱이 틀린 것으로 읽는다.** align_result.json decided_at_cm · swept_to_cm · search_max_cm positions[] in_decision: true/false 화면 수집 전용 줄은 흐리게 + "수집" 꼬리표 + 경계 안내 한 줄 ## 화면 문구 수집 단계에서는 판정이 MOVE_DOWN 이라고 해도 "1cm 올리고 측정"이라고 말한다. 대신 이미 답이 나왔다는 사실을 같이 적는다 — 안 그러면 "왜 계속 재라고 하지?"가 되고 중단 버튼을 못 찾는다: ↑ 1cm 올리고 측정 부착 위치는 이미 정해졌습니다(1cm 에서 판정). 4cm 까지는 데이터 수집용으로 잽니다 — 여기서 멈춰도 됩니다. ## 테스트 자원의 한계를 적어 뒀다 실측 둘로는 "수집 때문에 답이 뒤집히는" 장면을 AnchorGuide 수준에서 만들 수 없다. 수집 위치는 항상 cm 이 크고 동률은 낮은 cm 이 이기므로, 뒤집으려면 수집 위치의 nch 가 더 높거나 cap 이 더 낮아야 하는데 자원이 하나뿐이라 같은 값이다. 그래서 **위험은 선택 함수 수준에서 합성 레코드로**, **안정성은 실측으로** 본다. traceWin=20 으로 만든 이유도 적었다 (기본 10 이면 align_cm1 의 CH3 검출률이 0.77 로 임계 0.80 에 미달해 이 시나리오가 성립하지 않는다). 테스트 139개 통과(신규 4). Co-Authored-By: Claude Opus 5 --- .../ui/views/clinical/AnchorAlignView.kt | 135 +++++++++++++-- .../vesiscan/managers/AnchorSweepTest.kt | 156 ++++++++++++++++++ 2 files changed, 275 insertions(+), 16 deletions(-) create mode 100644 app/src/test/java/com/medithings/vesiscan/managers/AnchorSweepTest.kt diff --git a/app/src/main/java/com/medithings/vesiscan/ui/views/clinical/AnchorAlignView.kt b/app/src/main/java/com/medithings/vesiscan/ui/views/clinical/AnchorAlignView.kt index 1c9dcbf..68577d5 100644 --- a/app/src/main/java/com/medithings/vesiscan/ui/views/clinical/AnchorAlignView.kt +++ b/app/src/main/java/com/medithings/vesiscan/ui/views/clinical/AnchorAlignView.kt @@ -111,6 +111,15 @@ fun AnchorAlignView(appState: AppState) { var records by remember { mutableStateOf>(emptyList()) } // 위치를 다 썼는데 종료 조건이 안 걸리면 호출부가 강제 확정한다(AnchorGuide.finish). var forceFinish by remember { mutableStateOf(false) } + /** + * 알고리즘이 **판정을 내린** 위치(cm). null = 아직 탐색 중. + * + * 이 위치까지가 판정의 근거다. 그 뒤 위치는 데이터 수집용으로만 잰다 — 기록에 + * 그 경계를 남겨야 나중에 Python 으로 다시 판정할 때 같은 답이 나온다. + */ + var decidedAtCm by remember { mutableStateOf(null) } + /** 수집 단계를 사람이 중단했는가. 중단하면 바로 부착 위치 안내로 넘어간다. */ + var sweepStopped by remember { mutableStateOf(false) } /** * 방금 받은 6채널 원본. 아래쪽 파형이 이걸 그린다. * @@ -322,18 +331,34 @@ fun AnchorAlignView(appState: AppState) { val step = guide.step(cycles, cm) last = step records = guide.records + // 판정이 내려진 순간을 한 번만 붙잡는다. 그 뒤 위치는 수집용이다. + if (decidedAtCm == null && !step.searching) decidedAtCm = cm // 결과 요약도 매 위치마다 갱신한다. 중간에 앱이 죽어도 여기까지는 남는다. HospitalRunStore.writeAlignSummary( dir, buildAlignSummary(saveName, appState.hospitalPatient, deviceName, - bleManager.firmwareVersion.value, guide.records, step), + bleManager.firmwareVersion.value, guide.records, step, + decidedAtCm = decidedAtCm), ) - // 안내대로 다음 측정 위치를 미리 맞춰 둔다. 사용자는 프로브만 옮기면 된다. - when (step.action) { - AnchorAction.MOVE_UP -> cm += 1 - AnchorAction.MOVE_DOWN -> step.bestCm?.let { cm = it } - else -> Unit + // ── 다음 위치 ────────────────────────────────────────────────── + // **수집 단계에서는 판정과 무관하게 1cm 씩 올린다.** 병원 임상은 위치를 + // 고르는 것만이 목적이 아니라 위치별 원신호를 모으는 것도 목적이다 + // (2026-09-10 결정). 조기 종료하면 2~3 위치만 남아 재분석할 거리가 없다. + // + // **판정은 이것 때문에 바뀌지 않는다.** AnchorGuide 는 `searching` 이 false + // 로 넘어가는 순간 bestCm 을 한 번만 계산하고, 그 뒤 step() 은 recs 에만 + // 쌓는다 — 후보 집합이 늘어도 best 를 다시 고르지 않는다. 그 성질이 깨지면 + // 전부 측정한 세션의 답이 레퍼런스와 달라지므로 `AnchorSweepTest` 가 고정한다. + val swept = guide.records.maxOfOrNull { it.cm } ?: cm + if (!sweepStopped && swept < AnchorConfig.SEARCH_MAX_CM) { + cm = swept + 1 + } else { + when (step.action) { + AnchorAction.MOVE_UP -> step.bestCm?.let { cm = it } ?: run { cm += 1 } + AnchorAction.MOVE_DOWN -> step.bestCm?.let { cm = it } + else -> Unit + } } } finally { bleManager.piezoCollector.onMultiChannelComplete = prev @@ -550,7 +575,13 @@ fun AnchorAlignView(appState: AppState) { // 붙여야 하는지 매번 헷갈렸다(2026-09-09 현장 피드백). 실제로 필요한 것은 // **지금 할 동작 하나**다 — 1cm 올려서 재라, 2cm 내려서 재라, 1cm 올리고 // 좌우로 넘어가라. 절대 위치는 기록의 몫이고 사람이 셀 일이 아니다. - val instr = instructionOf(last, cm, AnchorConfig.FINAL_OFFSET_CM) + // 수집 단계인가 — 판정이 났어도 SEARCH_MAX_CM 까지는 계속 올린다. + val sweptTo = records.maxOfOrNull { it.cm } ?: -1 + val sweeping = !sweepStopped && sweptTo < AnchorConfig.SEARCH_MAX_CM + val instr = instructionOf( + last, cm, AnchorConfig.FINAL_OFFSET_CM, + sweeping = sweeping, decidedAtCm = decidedAtCm, + ) Box( Modifier.fillMaxWidth() .background( @@ -638,14 +669,26 @@ fun AnchorAlignView(appState: AppState) { // 준다 — 카드대로 움직였으면 버튼의 숫자가 지금 자리와 맞는다. ) { Text("${cm}cm 측정하기", fontSize = 16.sp, fontWeight = FontWeight.Bold) } - // 탐색 상한(4cm)까지 갔는데도 종료가 안 걸릴 수 있다 — 그때 쓰는 문이다. - if (guide.records.isNotEmpty() && !guide.settled) { + // 수집을 중단하는 문. 두 경우를 한 버튼으로 덮는다: + // · 아직 판정이 안 났다 → forceFinish 로 강제 확정 + // · 판정은 났고 수집 중 → 수집만 멈추고 부착 위치 안내로 + if (records.isNotEmpty() && sweeping) { Spacer(Modifier.height(8.dp)) TextButton( - onClick = { forceFinish = true }, + onClick = { + sweepStopped = true + if (!guide.settled) forceFinish = true + else last?.bestCm?.let { cm = it } + }, enabled = !measuring, modifier = Modifier.fillMaxWidth(), - ) { Text("여기까지로 결정하기", color = MlSecondaryText) } + ) { + Text( + if (guide.settled) "여기까지만 재고 부착 위치로" + else "여기까지로 결정하기", + color = MlSecondaryText, + ) + } } } else { // ── 2단계: 좌우 정렬 ─────────────────────────────────────── @@ -684,6 +727,7 @@ fun AnchorAlignView(appState: AppState) { saveName, appState.hospitalPatient, deviceName, bleManager.firmwareVersion.value, guide.records, s, lrCommit, lateralConfirmed = true, + decidedAtCm = decidedAtCm, ), ) } @@ -720,7 +764,7 @@ fun AnchorAlignView(appState: AppState) { buildAlignSummary( saveName, appState.hospitalPatient, deviceName, bleManager.firmwareVersion.value, guide.records, s, - lrCommit, lrConfirmed, basis, proceedCm, + lrCommit, lrConfirmed, basis, proceedCm, decidedAtCm, ), ) } @@ -830,7 +874,22 @@ fun AnchorAlignView(appState: AppState) { Text("측정한 위치", fontSize = 13.sp, fontWeight = FontWeight.Bold, color = MlSecondaryText) Spacer(Modifier.height(6.dp)) - records.forEach { r -> RecordRow(r, isBest = r.cm == last?.bestCm) } + records.forEach { r -> + RecordRow( + r, isBest = r.cm == last?.bestCm, + // 판정 뒤에 잰 위치는 **후보가 아니다.** 표시하지 않으면 나중에 + // Python 으로 전 위치를 다시 판정했을 때 답이 달라 보여, + // 앱이 틀린 것으로 읽힌다. + dataOnly = decidedAtCm?.let { r.cm > it } == true, + ) + } + Text( + decidedAtCm?.let { + "판정 근거는 ${it}cm 까지입니다. 그 뒤는 데이터 수집용으로만 쟀습니다(수집)." + } ?: "아직 판정 전입니다 — 잰 위치 전부가 후보입니다.", + fontSize = 11.sp, color = MlSecondaryText, + modifier = Modifier.padding(top = 4.dp), + ) } Spacer(Modifier.height(24.dp)) } @@ -990,11 +1049,22 @@ private fun StatusChip(connected: Boolean, name: String?) { /** 한 위치의 지표. 판정 근거를 그대로 보여준다 — 나중에 왜 그 위치였는지 물어보게 된다. */ @Composable -private fun RecordRow(r: AnchorPosRecord, isBest: Boolean) { +private fun RecordRow( + r: AnchorPosRecord, + isBest: Boolean, + /** 판정 뒤에 수집만 한 위치인가. 후보가 아니었다는 사실을 줄에 남긴다. */ + dataOnly: Boolean = false, +) { Row( Modifier.fillMaxWidth() .background( - if (isBest) MlSuccess.copy(alpha = 0.12f) else Color.White, + when { + isBest -> MlSuccess.copy(alpha = 0.12f) + // 수집 전용 줄은 흐리게. 여기서도 best 를 고른 것으로 읽히면 + // 나중에 재분석한 사람과 답이 갈리는 이유를 못 찾는다. + dataOnly -> MlSecondaryText.copy(alpha = 0.06f) + else -> Color.White + }, RoundedCornerShape(8.dp), ) .padding(horizontal = 12.dp, vertical = 8.dp), @@ -1007,7 +1077,8 @@ private fun RecordRow(r: AnchorPosRecord, isBest: Boolean) { "채널 ${r.nch}/4 · CH3 ${if (r.ch3Mean) "O" else "X"} " + "${Math.round(r.ch3Rate * 100)}% · cap " + (if (r.capFrac.isFinite()) "%.3f".format(r.capFrac) else "-") + - if (r.eligible) "" else " (CH3 미달)", + (if (r.eligible) "" else " (CH3 미달)") + + (if (dataOnly) " · 수집" else ""), fontSize = 13.sp, color = MlSecondaryText, ) } @@ -1043,6 +1114,10 @@ internal fun instructionOf( step: AnchorStep?, nextCm: Int, offsetCm: Int, + /** 수집 단계인가 — 판정이 났어도 SEARCH_MAX_CM 까지 올린다. */ + sweeping: Boolean = false, + /** 판정이 내려진 위치. 수집 단계에서 "이미 답은 나왔다"를 알려 주는 데 쓴다. */ + decidedAtCm: Int? = null, ): AlignInstruction = when { step == null -> AlignInstruction( "●", "치골 바로 위에 붙이고 측정", @@ -1053,6 +1128,20 @@ internal fun instructionOf( "치골 쪽으로 내려 다시 부착한 뒤 처음부터 측정합니다. " + (step.stopReason ?: "CH3 가 잡히는 위치가 없었습니다.")) + // 수집 단계에서는 판정이 뭐라 하든 계속 올린다. 단 **이미 답이 나왔다는 사실은 + // 말해 준다** — 안 그러면 "왜 계속 재라고 하지?"가 되고, 중단 버튼을 못 찾는다. + sweeping && step.searching -> AlignInstruction( + "↑", "1cm 올리고 측정", + "${AnchorConfig.SEARCH_MAX_CM}cm 까지 전부 잽니다.") + + sweeping -> AlignInstruction( + "↑", "1cm 올리고 측정", + decidedAtCm?.let { + "부착 위치는 이미 정해졌습니다(${it}cm 에서 판정). " + + "${AnchorConfig.SEARCH_MAX_CM}cm 까지는 데이터 수집용으로 잽니다 — " + + "여기서 멈춰도 됩니다." + } ?: "${AnchorConfig.SEARCH_MAX_CM}cm 까지 전부 잽니다.") + step.action == AnchorAction.STOP -> AlignInstruction( "↑", "${offsetCm}cm 올리세요", "여기서는 측정하지 않습니다. 올린 뒤 좌우 맞추기로 넘어갑니다.", @@ -1117,6 +1206,14 @@ private fun buildAlignSummary( basis: AnchorBasis? = null, /** 실제로 임상에 넘긴 cm. [AnchorStep.anchorCm] 이 null 인 채 진행할 수 있다. */ proceedCm: Int? = null, + /** + * 알고리즘이 판정을 내린 위치. **이 cm 까지가 판정의 후보다.** + * + * 병원 임상은 조기 종료 뒤에도 SEARCH_MAX_CM 까지 전부 잰다(데이터 수집). 그 경계를 + * 안 남기면 재분석하는 쪽이 전 위치를 후보로 넣어 다시 판정하고, 다른 best 가 나와 + * 앱이 틀린 것으로 읽는다. + */ + decidedAtCm: Int? = null, ): JSONObject = JSONObject().apply { put("patient", patient) put("save_name", saveName) @@ -1137,6 +1234,10 @@ private fun buildAlignSummary( put("best_cm", step.bestCm ?: JSONObject.NULL) put("anchor_cm", step.anchorCm ?: JSONObject.NULL) put("action", step.action.name) + // 판정 경계. positions[].in_decision 과 같은 사실을 세션 수준에서도 적는다. + put("decided_at_cm", decidedAtCm ?: JSONObject.NULL) + put("swept_to_cm", records.maxOfOrNull { it.cm } ?: JSONObject.NULL) + put("search_max_cm", AnchorConfig.SEARCH_MAX_CM) put("stop_reason", step.stopReason ?: JSONObject.NULL) put("max_nch", step.maxNch) // 앱 검출이 Python 레퍼런스와 불일치인 상태로 낸 값이라는 사실을 데이터와 함께 남긴다. @@ -1174,6 +1275,8 @@ private fun buildAlignSummary( put("ch3_rate", r.ch3Rate) put("cap_frac", if (r.capFrac.isFinite()) r.capFrac else JSONObject.NULL) put("eligible", r.eligible) + // 이 위치가 **판정의 후보였는가.** false = 판정 뒤에 수집만 한 위치. + put("in_decision", decidedAtCm?.let { r.cm <= it } ?: true) }) } }) diff --git a/app/src/test/java/com/medithings/vesiscan/managers/AnchorSweepTest.kt b/app/src/test/java/com/medithings/vesiscan/managers/AnchorSweepTest.kt new file mode 100644 index 0000000..4de958c --- /dev/null +++ b/app/src/test/java/com/medithings/vesiscan/managers/AnchorSweepTest.kt @@ -0,0 +1,156 @@ +package com.medithings.vesiscan.managers + +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import java.io.File + +/** + * 조기 종료 뒤에도 **끝까지 재는** 것이 판정을 바꾸지 않아야 한다. + * + * ## 왜 이 파일이 필요한가 + * 병원 임상은 위치를 고르는 것만이 목적이 아니라 **위치별 원신호를 모으는 것**도 목적이다 + * (2026-09-10 결정). 그래서 화면이 조기 종료 뒤에도 [AnchorConfig.SEARCH_MAX_CM] 까지 + * 계속 [AnchorGuide.step] 을 부른다. + * + * 위험은 명확하다. [AnchorSelection.selectBest] 는 **넘긴 레코드 전부**를 후보로 본다 — + * 후보가 늘면 답이 달라질 수 있다(아래 첫 테스트가 0cm → 2cm 로 뒤집히는 것을 보여준다). + * 앱이 그 답을 내면 레퍼런스(Python `AnchorGuide`)와 갈리고, 검증의 근거가 무너진다. + * + * 안전한 이유는 [AnchorGuide] 가 `searching` 이 꺾이는 **그 순간 한 번만** `bestCm` 을 + * 계산하고, 그 뒤 `step()` 은 `recs` 에만 쌓기 때문이다. **화면이 그 성질에 의존한다.** + * 누가 `step()` 에서 매번 다시 고르게 바꾸면 이 파일이 먼저 깨져야 한다. + * + * ## 자원의 한계를 적어 둔다 + * 받아 둔 실측 둘로는 "수집 때문에 답이 뒤집히는" 장면을 [AnchorGuide] 수준에서 만들 수 + * 없다. 수집 위치는 항상 cm 이 더 크고 동률은 **낮은 cm** 이 이기므로, 뒤집으려면 수집 + * 위치가 nch 가 더 높거나 cap 이 더 낮아야 하는데 자원이 하나뿐이라 같은 값이 나온다. + * 그래서 **위험은 선택 함수 수준에서 합성 레코드로**, **안정성은 실측으로** 본다. + */ +class AnchorSweepTest { + + private val saved = PiezoHW.activePreset + + @Before fun pin() { PiezoHW.activePreset = PiezoHW.DevicePreset.V1 } + + @After fun restore() { PiezoHW.activePreset = saved } + + // ── ① 위험: 후보가 늘면 답이 바뀐다 ───────────────────────────────────── + + private fun rec(cm: Int, nch: Int, ch3: Boolean, rate: Double, cap: Double) = + AnchorPosRecord( + cm = cm, nTrace = 11, nch = nch, ch3Mean = ch3, ch3Rate = rate, capFrac = cap, + ) + + @Test fun `후보가 늘면 best 가 뒤집힌다 — 이것이 막아야 할 것이다`() { + // 0cm eligible nch=2 · 1cm ch3 소실 · 2cm eligible nch=4 + val p0 = rec(0, nch = 2, ch3 = true, rate = 1.0, cap = 0.60) + val p1 = rec(1, nch = 2, ch3 = false, rate = 0.0, cap = 0.70) + val p2 = rec(2, nch = 4, ch3 = true, rate = 1.0, cap = 0.50) + + // 조기 종료 시점의 후보 집합 — 1cm 은 자격 미달이라 0cm 만 남는다. + assertEquals(0, AnchorSelection.selectBest(listOf(p0, p1)).bestCm) + // 수집한 위치까지 넣어 다시 고르면 규칙 A(nch 최대)로 2cm 이 이긴다. + assertEquals(2, AnchorSelection.selectBest(listOf(p0, p1, p2)).bestCm) + + assertNotEquals( + "이 둘이 같아지면 이 파일의 전제가 사라진다 — 선택 규칙이 바뀐 것이다", + AnchorSelection.selectBest(listOf(p0, p1)).bestCm, + AnchorSelection.selectBest(listOf(p0, p1, p2)).bestCm, + ) + } + + // ── ② 안정성: 종료 뒤 step() 이 판정을 건드리지 않는다 ────────────────── + + /** 실측 한 파일 → cycle 목록 (cycle, 6ch, samples). */ + private fun cyclesOf(name: String): List> { + val f = File("src/test/resources/$name") + assertTrue("실측 자원이 없다: ${f.absolutePath}", f.exists()) + val byCycle = linkedMapOf>() + f.forEachLine { line -> + val p = line.trim().split(',') + if (p.size < 3) return@forEachLine + val ci = p[0].toIntOrNull() ?: return@forEachLine + val ch = p[1].toIntOrNull() ?: return@forEachLine + byCycle.getOrPut(ci) { linkedMapOf() }[ch] = + DoubleArray(p.size - 2) { p[it + 2].toDoubleOrNull() ?: 0.0 } + } + val full = byCycle.values.filter { it.size == 6 }.map { m -> (0..5).map { m.getValue(it) } } + assertTrue("cycle 이 하나도 안 읽혔다", full.isNotEmpty()) + return full + } + + /** CH3 를 평탄화해 그 위치의 CH3 검출을 떨어뜨린다 — "ch3 소실" 종료를 만든다. */ + private fun killCh3(cycles: List>) = + cycles.map { ch -> ch.mapIndexed { i, a -> if (i == 3) DoubleArray(a.size) { 1000.0 } else a } } + + /** + * `traceWin = 20` 으로 만든다. + * + * 기본값(10)이면 `align_cm1` 의 CH3 검출률이 0.77 로 임계 0.80 에 **아슬아슬하게 + * 미달**해 eligible 이 아니다. 그러면 "ch3 소실" 종료가 아예 발동하지 않아(앞에 + * eligible 한 위치가 있어야 걸린다) 이 시나리오를 못 만든다. 20 이면 trace 가 하나, + * 즉 mean-scan 그대로라 검출률이 1.0 이 된다. + */ + private fun guide() = AnchorGuide(traceWin = 20) + + @Test fun `종료 뒤에 더 재도 판정이 그대로다`() { + val ok = cyclesOf("align_cm1.csv") // eligible · nch 4 + val dead = killCh3(ok) // ch3 미검출 → 자격 미달 + + val g = guide() + assertTrue("0cm 은 탐색이 계속돼야 한다", g.step(ok, 0).searching) + + val terminated = g.step(dead, 1) + assertFalse("1cm 에서 ch3 소실로 종료돼야 한다", terminated.searching) + val decidedBest = g.bestCm + val decidedReason = g.stopReason + assertNotNull("종료 시점에 best 가 정해져야 한다", decidedBest) + assertEquals("후보가 0cm 하나뿐이다", 0, decidedBest) + assertEquals("ch3 소실 0%<80%", decidedReason) + + // 여기서부터 수집 단계 — 화면이 SEARCH_MAX_CM 까지 계속 부른다. + g.step(ok, 2) + g.step(ok, 3) + g.step(ok, 4) + + assertEquals("수집이 best 를 바꿨다 — 레퍼런스와 답이 갈린다", decidedBest, g.bestCm) + assertEquals("수집이 종료 사유를 덮었다", decidedReason, g.stopReason) + assertFalse("한 번 꺾인 searching 이 되살아나면 안 된다", g.searching) + assertEquals("기록은 전부 쌓여야 한다 — 데이터 수집이 목적이다", 5, g.records.size) + } + + @Test fun `판정 경계로 후보와 수집을 가를 수 있다`() { + // 화면·요약이 `decided_at_cm` 으로 둘을 가른다. 그 경계는 종료가 걸린 위치다. + val ok = cyclesOf("align_cm1.csv") + val g = guide() + g.step(ok, 0) + val terminated = g.step(killCh3(ok), 1) + assertFalse(terminated.searching) + val decidedAt = terminated.record!!.cm + assertEquals(1, decidedAt) + + g.step(ok, 2) + assertEquals(listOf(0, 1), g.records.filter { it.cm <= decidedAt }.map { it.cm }) + assertEquals(listOf(2), g.records.filter { it.cm > decidedAt }.map { it.cm }) + } + + @Test fun `아무 조건도 안 걸리면 탐색 상한에서 끝난다`() { + // 화면이 4cm 까지 올리는 경우 — 상한 종료가 받아 준다. 이 경로가 막히면 + // 수집 단계가 영원히 안 끝난다. + val ok = cyclesOf("align_cm1.csv") + val g = guide() + (0 until AnchorConfig.SEARCH_MAX_CM).forEach { cm -> + assertTrue("${cm}cm 에서 끝나면 안 된다", g.step(ok, cm).searching) + } + val last = g.step(ok, AnchorConfig.SEARCH_MAX_CM) + assertFalse(last.searching) + assertEquals("탐색 상한 ${AnchorConfig.SEARCH_MAX_CM}cm", g.stopReason) + assertEquals(AnchorConfig.SEARCH_MAX_CM + 1, g.records.size) + } +}