fix(clinical): 정렬 화면에서 위치 이름을 없애고 지시문 하나만

시험하는 분이 "최적 위치"와 "부착 위치"를 계속 헷갈렸다(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 <noreply@anthropic.com>
This commit is contained in:
2026-09-09 18:05:46 +09:00
parent 1c979fc26b
commit 01c06c7848
2 changed files with 257 additions and 71 deletions
@@ -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("●", "여기서 한 번 더 측정",
"옮겨 온 자리의 상태를 확인하는 마지막 측정입니다.")
}
}
}
/**
* 진행 버튼 아래 한 줄 - **무엇을 모르고 넘어가는지** 말한다.
*
@@ -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)
}
}
}