From 01c06c78486b7a74502800bb32a8c5d5e8147db8 Mon Sep 17 00:00:00 2001 From: jjangddu Date: Wed, 9 Sep 2026 18:05:46 +0900 Subject: [PATCH] =?UTF-8?q?fix(clinical):=20=EC=A0=95=EB=A0=AC=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=EC=97=90=EC=84=9C=20=EC=9C=84=EC=B9=98=20=EC=9D=B4?= =?UTF-8?q?=EB=A6=84=EC=9D=84=20=EC=97=86=EC=95=A0=EA=B3=A0=20=EC=A7=80?= =?UTF-8?q?=EC=8B=9C=EB=AC=B8=20=ED=95=98=EB=82=98=EB=A7=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 시험하는 분이 "최적 위치"와 "부착 위치"를 계속 헷갈렸다(2026-09-09 현장). 당연하다 — 둘 다 "위치"라는 이름의 숫자인데 화면이 단계마다 다른 쪽을 크게 보여 줬다. 측정할 위치 2 cm ← 탐색 중 부착 위치 확정 3 cm ← 확정 뒤 여기로 옮기고 (최적 2cm + 1cm) 고정하세요 숫자 셋(2·3·+1)이 한 화면에 있었다. 프로브를 잡은 사람에게 필요한 건 **지금 할 동작 하나**다. 절대 cm 은 기록의 몫이고 사람이 셀 일이 아니다. 치골 바로 위에 붙이고 측정 ↑ 1cm 올리고 측정 ↓ 2cm 내리고 측정 ● 여기서 한 번 더 측정 (옮겨 온 자리를 확인하는 마지막 측정입니다) ↑ 1cm 올리세요 (여기서는 측정하지 않습니다. 올린 뒤 좌우로.) ← 초록 ↓ 더 아래에 다시 붙이세요 (재부착) 화살표 + 한 줄 지시 + 이유. 전부 **상대 이동**이라 사람이 cm 을 누적해 셀 필요가 없다. 측정 버튼도 "2cm 측정"→"여기서 측정" 으로 바꿨다 — 카드가 "1cm 올리고 측정"이라고 말하는데 버튼이 다른 숫자를 달고 있으면 둘을 맞춰 보려다 또 헷갈린다. "여기서 한 번 더 측정"에 이유를 붙인 것은 이 화면에서 제일 많이 나오는 질문이 "왜 방금 잰 자리를 또 재냐"이기 때문이다. 확정 판정의 입력이 그 측정이다. 판정 근거(cm · 채널 n/4 · CH3 · cap)는 카드 아래 작은 글씨로 남긴다. 왜 그 지시가 나왔는지 물어보게 되므로 숨기지 않는다. 기록에 남는 cm 도 진행 버튼 아래 한 줄로 남긴다. 좌우 카드 안내도 고쳤다: "부착 위치 3cm 로 옮기세요" → "앞에서 1cm 올린 자리 그대로 둡니다. 아직 안 올렸으면 지금 올리세요." 테스트 129개 통과(신규 8). AlignInstructionTest 가 지시문에 "최적"·"부착 위치" 가 들어가지 않는지까지 본다 — 여기서는 헷갈림 자체가 고치려던 결함이라 문구가 요구사항이다. FINAL_OFFSET_CM 을 바꾸면 문구도 따라가는지도 고정했다(박아 두면 거짓이 된다). Co-Authored-By: Claude Opus 5 --- .../ui/views/clinical/AnchorAlignView.kt | 190 +++++++++++------- .../ui/views/clinical/AlignInstructionTest.kt | 138 +++++++++++++ 2 files changed, 257 insertions(+), 71 deletions(-) create mode 100644 app/src/test/java/com/medithings/vesiscan/ui/views/clinical/AlignInstructionTest.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 7f521f5..29af70d 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 @@ -13,12 +13,18 @@ * cycle 수가 곧 trace 수를 정하는데(20 → 11 trace), trace 가 줄면 CH3 검출률의 해상도가 * 거칠어져 0.8 임계가 불안정해진다. * - * ## 화면 흐름 - * [0cm 에서 측정] → 안내(1cm 올리세요) → [1cm 에서 측정] → ... → - * 정렬 완료: "최적 2cm · 여기서 1cm 더 올려 3cm 에 부착하세요" + * ## 화면은 지시문 하나만 보여준다 + * 위치 이름(최적·부착)을 쓰지 않는다. 시험하는 사람이 두 숫자를 계속 헷갈렸다 + * (2026-09-09 현장) — 둘 다 "위치"라는 이름의 숫자이고 단계마다 다른 쪽이 커졌기 + * 때문이다. 프로브를 잡은 사람에게 필요한 것은 **지금 할 동작 하나**다([instructionOf]). * - * 오프셋 위치(+1cm)는 **측정하지 않는다**. 설계상 그 자리는 지표가 나쁘므로, 재확인하면 - * 반드시 "미달"이 떠서 anchor 에 도달하기 전에 멈추게 된다. + * 치골 바로 위에 붙이고 측정 → 1cm 올리고 측정 → ... → 2cm 내리고 측정 → + * 여기서 한 번 더 측정 → **1cm 올리세요 (재지 말고 좌우로)** + * + * 절대 cm 은 기록의 몫이다 — 진행 버튼 아래 작은 한 줄과 파일에만 남는다. + * + * 마지막 +1cm 자리는 **측정하지 않는다**. 설계상 그 자리는 지표가 나쁘므로, 재확인하면 + * 반드시 "미달"이 떠서 도달 전에 멈추게 된다. */ package com.medithings.vesiscan.ui.views.clinical @@ -471,65 +477,46 @@ fun AnchorAlignView(appState: AppState) { } Spacer(Modifier.height(16.dp)) - // 현재 위치 — 크게. 간호사가 프로브를 잡은 채 멀리서 봐야 한다. + // 지금 무엇을 할지 — 크게. **위치 이름을 쓰지 않는다.** // - // **확정된 순간에는 카드를 통째로 초록으로 칠한다.** 탐색 중에는 흰 카드에 - // 숫자만 바뀌므로, 확정도 흰 카드에 글자색만 다르면 "또 한 번 바뀐 숫자"로 - // 읽힌다. 여기서 프로브를 옮기고 더 이상 재지 않는다는 것이 이 화면에서 - // 제일 중요한 전환점이라, 색이 면으로 바뀌어야 곁눈으로도 걸린다. - val fixed = last?.action == AnchorAction.STOP && last?.anchorCm != null + // 종전에는 "지금 측정할 위치 2cm" 과 "부착 위치 3cm" 을 번갈아 보여 줬다. + // 두 숫자가 다 "위치"라는 이름을 달고 나오니, 시험하는 사람이 어느 자리에 + // 붙여야 하는지 매번 헷갈렸다(2026-09-09 현장 피드백). 실제로 필요한 것은 + // **지금 할 동작 하나**다 — 1cm 올려서 재라, 2cm 내려서 재라, 1cm 올리고 + // 좌우로 넘어가라. 절대 위치는 기록의 몫이고 사람이 셀 일이 아니다. + val instr = instructionOf(last, cm, AnchorConfig.FINAL_OFFSET_CM) Box( Modifier.fillMaxWidth() - .background(if (fixed) MlSuccess else Color.White, RoundedCornerShape(14.dp)) + .background( + if (instr.done) MlSuccess else Color.White, RoundedCornerShape(14.dp)) .padding(vertical = 20.dp, horizontal = 16.dp), contentAlignment = Alignment.Center, ) { + val fg = if (instr.done) Color.White else MlPrimary Column(horizontalAlignment = Alignment.CenterHorizontally) { - if (fixed) { - // 판정이 아니라 **지시**로 읽히게 쓴다. - Text("부착 위치 확정", fontSize = 13.sp, color = Color.White, - fontWeight = FontWeight.Bold) - Spacer(Modifier.height(4.dp)) - Text("${last?.anchorCm} cm", fontSize = 44.sp, - fontWeight = FontWeight.Bold, color = Color.White) + Text(instr.arrow, fontSize = 40.sp, fontWeight = FontWeight.Bold, color = fg) + Spacer(Modifier.height(2.dp)) + Text(instr.headline, fontSize = 24.sp, fontWeight = FontWeight.Bold, + color = fg, textAlign = TextAlign.Center) + instr.sub?.let { Spacer(Modifier.height(6.dp)) - // 간호사가 제일 많이 틀리는 지점이다 — 최적 위치(best)와 부착 - // 위치(best+1)를 헷갈려 측정했던 자리에 그대로 붙인다. - Text( - "여기로 옮기고 " + (last?.bestCm?.let { "(최적 ${it}cm + 1cm) " } ?: "") + - "고정하세요. 이 자리는 측정하지 않습니다.", - fontSize = 12.sp, color = Color.White.copy(alpha = 0.92f), - textAlign = TextAlign.Center, - ) - } else { - Text(if (done) "부착 위치" else "지금 측정할 위치", - fontSize = 13.sp, color = MlSecondaryText) - Spacer(Modifier.height(4.dp)) - Text( - if (done) "${last?.anchorCm ?: "-"} cm" else "$cm cm", - fontSize = 40.sp, fontWeight = FontWeight.Bold, - color = if (done) MlSuccess else MlPrimary, - ) + Text(it, fontSize = 13.sp, textAlign = TextAlign.Center, + color = if (instr.done) Color.White.copy(alpha = 0.92f) + else MlSecondaryText) } } } - Spacer(Modifier.height(12.dp)) + Spacer(Modifier.height(8.dp)) - last?.let { s -> - val tone = when (s.action) { - AnchorAction.STOP -> MlSuccess - AnchorAction.REATTACH -> MlCritical - else -> MlPrimary - } - // 확정 카드가 이미 "여기로 옮기고 고정하세요"를 말했다. 같은 말을 두 번 - // 쓰면 둘 다 안 읽힌다 — 여기서는 근거(nch·cap)만 남긴다. - val body = if (fixed) s.message.lineSequence().first() else s.message - Box( - Modifier.fillMaxWidth() - .border(1.5.dp, tone, RoundedCornerShape(12.dp)) - .padding(12.dp) - ) { Text(body, fontSize = 14.sp, color = tone) } - Spacer(Modifier.height(12.dp)) + // 판정 근거는 작게. 왜 그 지시가 나왔는지 물어보게 되므로 숨기지는 않는다. + last?.record?.let { r -> + Text( + "${r.cm}cm · 채널 ${r.nch}/4 · CH3 ${if (r.ch3Mean) "O" else "X"} " + + "${(r.ch3Rate * 100).toInt()}% · cap " + + (if (r.capFrac.isFinite()) "%.3f".format(r.capFrac) else "-"), + fontSize = 11.sp, color = MlSecondaryText, + ) + Spacer(Modifier.height(10.dp)) } error?.let { @@ -610,7 +597,9 @@ fun AnchorAlignView(appState: AppState) { enabled = isConnected && !measuring, modifier = Modifier.fillMaxWidth().height(52.dp), shape = RoundedCornerShape(14.dp), - ) { Text("${cm}cm 측정", fontSize = 16.sp, fontWeight = FontWeight.Bold) } + // 버튼에도 절대 cm 을 쓰지 않는다. 큰 카드가 "1cm 올리고 측정" + // 이라고 말하는데 버튼이 "2cm 측정"이면 둘을 맞춰 보려다 헷갈린다. + ) { Text("여기서 측정", fontSize = 16.sp, fontWeight = FontWeight.Bold) } // 탐색 상한(4cm)까지 갔는데도 종료가 안 걸릴 수 있다 — 그때 쓰는 문이다. if (guide.records.isNotEmpty() && !guide.settled) { @@ -712,11 +701,14 @@ fun AnchorAlignView(appState: AppState) { containerColor = if (basis.isAlgorithm) MlSuccess else MlWarning, ), ) { - Text("이 위치(${proceedCm}cm)로 임상 측정 진행", - fontSize = 16.sp, fontWeight = FontWeight.Bold) + Text("이대로 임상 측정 진행", fontSize = 16.sp, fontWeight = FontWeight.Bold) } + // 기록에 남는 cm 은 작게 한 줄. 사람이 셀 숫자가 아니라 기록의 몫이다. + Spacer(Modifier.height(4.dp)) + Text("기록에 남는 부착 위치 ${proceedCm}cm · ${basis.label}", + fontSize = 11.sp, color = MlSecondaryText) proceedNote(basis, lrConfirmed)?.let { note -> - Spacer(Modifier.height(6.dp)) + Spacer(Modifier.height(4.dp)) Text(note, fontSize = 12.sp, color = MlSecondaryText) } @@ -791,24 +783,21 @@ private fun LateralSection( // 위다(오프셋 자리는 측정하지 않는다). 좌우 균형은 그 높이의 단면에서 정해지는 // 값이라 높이를 바꾸면 u4·u5 가 달라진다 — 옮기기 **전에** 맞추면 헛일이 된다. // 화면이 이걸 말해 주지 않으면 간호사마다 순서가 갈린다. - anchorCm?.let { anchor -> - Box( - Modifier.fillMaxWidth() - .background(MlPrimary.copy(alpha = 0.10f), RoundedCornerShape(10.dp)) - .padding(10.dp) - ) { - Text( - "먼저 프로브를 부착 위치 ${anchor}cm 로 옮기세요. " + - "좌우는 그 높이에서 맞춥니다.", - fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = MlPrimary, - ) - } - Spacer(Modifier.height(10.dp)) + Box( + Modifier.fillMaxWidth() + .background(MlPrimary.copy(alpha = 0.10f), RoundedCornerShape(10.dp)) + .padding(10.dp) + ) { + Text( + "앞에서 1cm 올린 자리 그대로 둡니다. 아직 안 올렸으면 지금 올리세요.", + fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = MlPrimary, + ) } + Spacer(Modifier.height(10.dp)) Text( - "높이는 그대로 두고 프로브를 좌우로만 옮기며 맞춥니다. " + - "|ch4−ch5| ≤ ${AlignmentConstants.LAT_TOL} 이면 완료입니다.", + "높이는 건드리지 말고 좌우로만 조금씩 움직입니다. " + + "화살표가 ■ 로 바뀌면 맞은 것입니다 (|ch4−ch5| ≤ ${AlignmentConstants.LAT_TOL}).", fontSize = 12.sp, color = MlSecondaryText, ) Spacer(Modifier.height(12.dp)) @@ -926,6 +915,65 @@ private fun RecordRow(r: AnchorPosRecord, isBest: Boolean) { Spacer(Modifier.height(4.dp)) } +/** + * 화면에 띄울 **지시문 하나**. 위치 이름을 쓰지 않는다. + * + * 시험하는 사람이 "최적 위치"와 "부착 위치"를 계속 헷갈렸다(2026-09-09 현장). 당연하다 — + * 둘 다 "위치"라는 이름의 숫자이고, 화면이 단계마다 다른 쪽을 크게 보여 줬다. 그런데 + * 프로브를 잡은 사람에게 필요한 것은 **지금 할 동작 하나**다. 절대 cm 은 기록의 몫이다. + * + * | 상태 | 지시 | + * |---|---| + * | 첫 측정 전 | 치골 바로 위에 붙이고 측정 | + * | 탐색 중 | N cm 올리고 측정 | + * | best 를 지나침 | N cm 내리고 측정 | + * | 확정 | 1cm 올리고 **재지 말고** 좌우로 | + * | 재부착 | 더 아래(치골 쪽)에 다시 붙이기 | + * + * @param nextCm 화면이 다음에 재려는 위치. 호출부가 판정 뒤 이미 갱신해 둔다. + */ +internal data class AlignInstruction( + val arrow: String, + val headline: String, + val sub: String?, + /** 확정 상태 — 카드를 통째로 초록으로 칠한다. */ + val done: Boolean = false, +) + +internal fun instructionOf( + step: AnchorStep?, + nextCm: Int, + offsetCm: Int, +): AlignInstruction = when { + step == null -> AlignInstruction( + "●", "치골 바로 위에 붙이고 측정", + "젤을 바르고, 프로브를 치골 바로 위에 밀착시킵니다.") + + step.action == AnchorAction.REATTACH -> AlignInstruction( + "↓", "더 아래에 다시 붙이세요", + "치골 쪽으로 내려 다시 부착한 뒤 처음부터 측정합니다. " + + (step.stopReason ?: "CH3 가 잡히는 위치가 없었습니다.")) + + step.action == AnchorAction.STOP -> AlignInstruction( + "↑", "${offsetCm}cm 올리세요", + "여기서는 측정하지 않습니다. 올린 뒤 좌우 맞추기로 넘어갑니다.", + done = true) + + else -> { + // 지금 있는 자리는 방금 잰 위치다. 다음 자리는 호출부가 이미 정해 두었다. + val from = step.record?.cm ?: nextCm + val delta = nextCm - from + when { + delta > 0 -> AlignInstruction("↑", "${delta}cm 올리고 측정", null) + delta < 0 -> AlignInstruction("↓", "${-delta}cm 내리고 측정", null) + // 이미 그 자리다 — 확인 측정 한 번이 남았다. 이 화면에서 제일 많이 나오는 + // 질문이 "왜 방금 잰 자리를 또 재냐"라, 이유를 같이 적는다. + else -> AlignInstruction("●", "여기서 한 번 더 측정", + "옮겨 온 자리의 상태를 확인하는 마지막 측정입니다.") + } + } +} + /** * 진행 버튼 아래 한 줄 - **무엇을 모르고 넘어가는지** 말한다. * diff --git a/app/src/test/java/com/medithings/vesiscan/ui/views/clinical/AlignInstructionTest.kt b/app/src/test/java/com/medithings/vesiscan/ui/views/clinical/AlignInstructionTest.kt new file mode 100644 index 0000000..9433cbb --- /dev/null +++ b/app/src/test/java/com/medithings/vesiscan/ui/views/clinical/AlignInstructionTest.kt @@ -0,0 +1,138 @@ +package com.medithings.vesiscan.ui.views.clinical + +import com.medithings.vesiscan.managers.AnchorAction +import com.medithings.vesiscan.managers.AnchorPosRecord +import com.medithings.vesiscan.managers.AnchorStep +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * 정렬 화면 지시문 — **위치 이름이 화면에 나오면 안 된다.** + * + * 시험하는 사람이 "최적 위치"와 "부착 위치"를 계속 헷갈렸다(2026-09-09 현장). 둘 다 + * "위치"라는 이름의 숫자이고, 화면이 단계마다 다른 쪽을 크게 보여 줬기 때문이다. + * 프로브를 잡은 사람에게 필요한 것은 지금 할 동작 하나다. + * + * 그래서 이 파일은 문구를 고정한다. 화면 문구 시험은 보통 과하지만, 여기서는 **헷갈림 + * 자체가 고치려던 결함**이라 문구가 요구사항이다. + */ +class AlignInstructionTest { + + private fun rec(cm: Int) = AnchorPosRecord( + cm = cm, nTrace = 11, nch = 2, ch3Mean = true, ch3Rate = 1.0, + capFrac = 0.64, nchTrace = 2.0, nchSd = 0.0, + ) + + private fun step( + action: AnchorAction, + atCm: Int, + bestCm: Int? = null, + searching: Boolean = true, + stopReason: String? = null, + ) = AnchorStep( + action = action, message = "", record = rec(atCm), bestCm = bestCm, + anchorCm = bestCm?.let { it + 1 }, maxNch = 2.0, searching = searching, + stopReason = stopReason, + ) + + /** 화면에 절대 나오면 안 되는 말. */ + private val banned = listOf("최적", "부착 위치", "anchor") + + private fun assertNoPositionWords(i: AlignInstruction) { + val all = listOf(i.headline, i.sub ?: "").joinToString(" ") + banned.forEach { w -> + assertFalse("\"$w\" 가 지시문에 있으면 안 된다 — $all", all.contains(w)) + } + } + + @Test fun `첫 측정 전에는 붙일 자리를 말한다`() { + val i = instructionOf(null, nextCm = 0, offsetCm = 1) + assertEquals("●", i.arrow) + assertTrue(i.headline.contains("치골")) + assertFalse(i.done) + assertNoPositionWords(i) + } + + @Test fun `탐색 중에는 올리고 측정하라고만 한다`() { + // 0cm 을 재고 다음은 1cm. 절대 숫자가 아니라 **차이**를 말해야 한다. + val i = instructionOf(step(AnchorAction.MOVE_UP, atCm = 0), nextCm = 1, offsetCm = 1) + assertEquals("↑", i.arrow) + assertEquals("1cm 올리고 측정", i.headline) + assertFalse(i.done) + assertNoPositionWords(i) + } + + @Test fun `지나쳤으면 내리고 측정하라고 한다`() { + // 3cm 에서 종료가 걸리고 best=1 이면 2cm 내려야 한다. + val i = instructionOf( + step(AnchorAction.MOVE_DOWN, atCm = 3, bestCm = 1, searching = false), + nextCm = 1, offsetCm = 1, + ) + assertEquals("↓", i.arrow) + assertEquals("2cm 내리고 측정", i.headline) + assertNoPositionWords(i) + } + + @Test fun `같은 자리면 한 번 더 재라고 하고 이유를 댄다`() { + // 이 화면에서 제일 많이 나오는 질문이 "왜 방금 잰 자리를 또 재냐"다. + val i = instructionOf( + step(AnchorAction.MOVE_DOWN, atCm = 2, bestCm = 2, searching = false), + nextCm = 2, offsetCm = 1, + ) + assertEquals("●", i.arrow) + assertTrue(i.headline.contains("한 번 더")) + assertNotNull("이유가 없으면 또 물어본다", i.sub) + assertNoPositionWords(i) + } + + @Test fun `확정되면 올리고 재지 말라고 한다`() { + // 여기가 제일 중요하다. 올린 자리를 측정하면 설계상 "미달"이 떠서 되돌아간다. + val i = instructionOf( + step(AnchorAction.STOP, atCm = 2, bestCm = 2, searching = false), + nextCm = 2, offsetCm = 1, + ) + assertEquals("↑", i.arrow) + assertEquals("1cm 올리세요", i.headline) + assertTrue("측정하지 말라는 말이 빠지면 안 된다", + i.sub!!.contains("측정하지 않습니다")) + assertTrue("다음 단계를 말해야 멈추지 않는다", i.sub!!.contains("좌우")) + assertTrue("확정은 초록이다", i.done) + assertNoPositionWords(i) + } + + @Test fun `오프셋이 바뀌면 문구도 따라간다`() { + // FINAL_OFFSET_CM 을 2 로 바꾸면 화면도 2cm 이라고 말해야 한다. 박아 두면 거짓이 된다. + val i = instructionOf( + step(AnchorAction.STOP, atCm = 1, bestCm = 1, searching = false), + nextCm = 1, offsetCm = 2, + ) + assertEquals("2cm 올리세요", i.headline) + } + + @Test fun `재부착은 아래로 내리라고 하고 사유를 붙인다`() { + val i = instructionOf( + step(AnchorAction.REATTACH, atCm = 3, searching = false, + stopReason = "탐색 상한 4cm"), + nextCm = 3, offsetCm = 1, + ) + assertEquals("↓", i.arrow) + assertTrue(i.headline.contains("아래")) + assertTrue("사유가 없으면 같은 실수를 반복한다", i.sub!!.contains("탐색 상한")) + assertFalse(i.done) + assertNoPositionWords(i) + } + + @Test fun `모든 동작에 지시문이 있다`() { + // action 이 늘어났는데 여기를 안 고치면 조용히 "올리고 측정"으로 떨어진다. + assertEquals(4, AnchorAction.entries.size) + AnchorAction.entries.forEach { a -> + val i = instructionOf(step(a, atCm = 1, bestCm = 1, searching = false), + nextCm = 2, offsetCm = 1) + assertTrue("$a 에 문구가 없다", i.headline.isNotBlank()) + assertNoPositionWords(i) + } + } +}