diff --git a/app/src/main/java/com/medithings/vesiscan/services/ManualCheckStore.kt b/app/src/main/java/com/medithings/vesiscan/services/ManualCheckStore.kt new file mode 100644 index 0000000..d7e9f77 --- /dev/null +++ b/app/src/main/java/com/medithings/vesiscan/services/ManualCheckStore.kt @@ -0,0 +1,246 @@ +/* + * ManualCheckStore — 수동 BV 확인 기록. 폰에만 남긴다(labdb 업로드 없음). + */ +package com.medithings.vesiscan.services + +import com.medithings.vesiscan.ble.PiezoChannelData +import com.medithings.vesiscan.managers.AnchorBasis +import com.medithings.vesiscan.managers.ClinicalBv +import com.medithings.vesiscan.models.BladderFill +import com.medithings.vesiscan.models.ClinicalPosture +import com.medithings.vesiscan.models.HospitalFixedParams +import com.medithings.vesiscan.models.ProbeCycle +import com.medithings.vesiscan.models.ProbeFrequency +import org.json.JSONArray +import org.json.JSONObject +import java.io.File +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import kotlin.math.sqrt + +/** + * 병원 임상 화면 아래쪽 **수동 확인**(1회/연속 측정)의 기록. + * + * ## 왜 프로토콜 데이터와 섞지 않는가 + * 600(프로토콜)은 정확도 검증의 **모집단**이다. 수동 확인은 조작자가 궁금해서 아무 + * 자리에서 아무 때나 누르는 것이라, 같은 곳에 쌓이면 "정해진 조건에서 받은 데이터"라는 + * 전제가 깨진다. 그래서 같은 환자·날짜 폴더 **밑의 `manual/`** 에 따로 둔다. + * + * 하위 폴더인 것이 중요하다. [labdb.HospitalLabdbUploader.pending] 과 + * [HospitalRunStore.scanProgress] 는 둘 다 폴더 **최상위만** 훑으므로, 여기 둔 CSV 는 + * 600 으로 올라가지도 않고 진행 판정에 끼지도 않는다. + * + * ## 파일이 둘인 이유 + * **CSV 는 분석용**이다 — 하루치를 한 파일에 이어 붙여, USB 로 빼서 엑셀로 열면 행끼리 + * 바로 비교된다. 검증 항목 ③(위치 민감도)이 "부착 위치에서 상·하·좌·우 1~2cm 옮겨 + * 비교"라 위치 = 행이어야 한다. + * + * **JSON 은 보관용**이다 — 누를 때마다 하나씩, 6채널 원신호와 채널별 벽 위치까지. + * 숫자가 이상할 때 "그때 파형이 어땠나"로 돌아갈 수 있어야 한다. CSV 에 원신호까지 + * 넣으면 한 행이 600열을 넘어 사람이 못 읽는다. + * + * ## 위치를 자유 문구로 받지 않는다 + * [Offset] 으로 고정한다. 자유 입력이면 같은 자리가 "좌1"·"왼쪽 1cm"·"L1" 로 적혀 + * 나중에 묶을 수 없다 — 항목 ③ 은 위치끼리 값을 비교하는 일이라 표기가 흔들리면 + * 데이터가 아니라 메모가 된다. 메모는 그 옆에 따로 받는다. + */ +object ManualCheckStore { + + /** 부착 위치를 기준으로 한 오프셋. 항목 ③(위치 민감도)이 이 축으로 비교한다. */ + enum class Offset(val label: String, val wire: String) { + BASE("기준", "base"), + UP_1("위 1cm", "up_1"), + UP_2("위 2cm", "up_2"), + DOWN_1("아래 1cm", "down_1"), + DOWN_2("아래 2cm", "down_2"), + LEFT_1("좌 1cm", "left_1"), + LEFT_2("좌 2cm", "left_2"), + RIGHT_1("우 1cm", "right_1"), + RIGHT_2("우 2cm", "right_2"), + } + + data class Saved(val csv: File, val json: File) + + private val dayFmt = SimpleDateFormat("yyyy-MM-dd", Locale.US) + private val stampFmt = SimpleDateFormat("HHmmss", Locale.US) + private val isoFmt = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US) + + /** 같은 환자·날짜 폴더 밑 `manual/`. */ + fun dir(patient: String, at: Date): File = + File(HospitalRunStore.runDir(patient, at), "manual").apply { if (!exists()) mkdirs() } + + private val COLUMNS = listOf( + "timestamp", "patient", "posture", "fill_pct", + "anchor_cm", "anchor_basis", "offset", "memo", + "mode", "freq_mhz", "freq_option", "cycles", + "avg", "delay_us", "samples", "hw_preset", "preset_confident", "algo", + "n", "trimmed_ml", "mean_ml", "sd_ml", "cv_pct", "min_ml", "max_ml", + "detected_center", "failure", "snapshot_json", + ) + + /** + * 한 번의 확인을 남긴다. CSV 한 행 + JSON 한 개. + * + * @param volumes 이 확인의 BV 들. 연속이면 표시 창 그대로, 1회면 값 하나. + * @return 실패하면 null — 호출부가 화면에 알려야 한다. 조용히 넘기면 조작자는 + * 기록됐다고 믿고 다음 위치로 옮겨, 그 자리를 다시 잴 기회를 잃는다. + */ + fun save( + patient: String, + at: Date, + posture: ClinicalPosture, + fill: BladderFill, + anchorCm: Int?, + anchorBasis: AnchorBasis?, + offset: Offset, + memo: String, + /** `"spot"` 또는 `"continuous"`. */ + mode: String, + freq: ProbeFrequency, + cycle: ProbeCycle, + volumes: List, + outcome: ClinicalBv.Outcome?, + raw: List, + ): Saved? = runCatching { + val d = dir(patient, at) + val stamp = stampFmt.format(at) + val jsonName = "manual_${dayFmt.format(at)}_$stamp.json" + + val n = volumes.size + val mean = if (n > 0) volumes.average() else Double.NaN + val sd = if (n > 1) sqrt(volumes.sumOf { (it - mean) * (it - mean) } / n) else 0.0 + // 대표값은 화면과 **같은 식**이어야 한다. 화면은 절사평균을 보여주는데 파일에 + // 산술평균이 들어가면 둘이 다른 수가 되어, 보고서와 화면을 나란히 둘 수 없다. + val trimmed = if (n > 0) trimmedMean(volumes) else Double.NaN + + val row = listOf( + isoFmt.format(at), patient, posture.label, fill.percent.toString(), + anchorCm?.toString() ?: "", anchorBasis?.wire ?: "", offset.wire, memo, + mode, freq.label, freq.freqOption.toString(), cycle.cycles.toString(), + HospitalFixedParams.AVG.toString(), HospitalFixedParams.DELAY_US.toString(), + HospitalFixedParams.SAMPLES.toString(), + outcome?.preset ?: "", (outcome?.presetConfident ?: false).toString(), + outcome?.algoLabel ?: "", + n.toString(), fmt(trimmed), fmt(mean), fmt(sd), + fmt(if (mean > 0) sd / mean * 100 else 0.0), + fmt(volumes.minOrNull()), fmt(volumes.maxOrNull()), + outcome?.detectedCenter?.toString() ?: "", outcome?.failure ?: "", + jsonName, + ).map(::csvField) + + // 하루치를 한 파일에 이어 붙인다. 헤더는 처음 만들 때만. + val csv = File(d, "manual_${dayFmt.format(at)}.csv") + val fresh = !csv.exists() || csv.length() == 0L + csv.appendText( + (if (fresh) COLUMNS.joinToString(",") + "\n" else "") + row.joinToString(",") + "\n", + Charsets.UTF_8, + ) + + val json = File(d, jsonName) + json.writeText( + snapshot( + at, patient, posture, fill, anchorCm, anchorBasis, offset, memo, + mode, freq, cycle, volumes, trimmed, mean, sd, outcome, raw, + ).toString(2), + Charsets.UTF_8, + ) + Saved(csv, json) + }.getOrElse { + android.util.Log.w("ManualCheckStore", "수동 확인 저장 실패: ${it.message}") + null + } + + /** + * 최대·최소 하나씩 버린 평균. 화면(`BvMeasureSection.trimmedMean`)과 **같은 식**이어야 + * 한다 — 화면은 절사평균을 보여주는데 파일에 다른 수가 들어가면 보고서와 화면을 + * 나란히 둘 수 없다. `ManualCheckStoreTest` 가 두 함수를 직접 맞대어 고정한다. + */ + internal fun trimmedMean(values: List): Double { + if (values.size <= 2) return values.average() + val sorted = values.sorted() + return sorted.subList(1, sorted.size - 1).average() + } + + private fun snapshot( + at: Date, + patient: String, + posture: ClinicalPosture, + fill: BladderFill, + anchorCm: Int?, + anchorBasis: AnchorBasis?, + offset: Offset, + memo: String, + mode: String, + freq: ProbeFrequency, + cycle: ProbeCycle, + volumes: List, + trimmed: Double, + mean: Double, + sd: Double, + outcome: ClinicalBv.Outcome?, + raw: List, + ): JSONObject = JSONObject().apply { + put("kind", "manual_bv_check") + put("timestamp", isoFmt.format(at)) + put("patient", patient) + put("patient_unnamed", patient.isBlank()) + put("posture", posture.label) + put("fill_pct", fill.percent) + put("anchor_cm", anchorCm ?: JSONObject.NULL) + put("anchor_basis", anchorBasis?.wire ?: JSONObject.NULL) + put("offset", offset.wire) + put("offset_label", offset.label) + put("memo", memo) + put("mode", mode) + put("freq_mhz", freq.label) + put("freq_option", freq.freqOption) + put("cycles", cycle.cycles) + put("avg", HospitalFixedParams.AVG) + put("delay_us", HospitalFixedParams.DELAY_US) + put("samples", HospitalFixedParams.SAMPLES) + put("hw_preset", outcome?.preset ?: JSONObject.NULL) + put("preset_confident", outcome?.presetConfident ?: false) + put("algo", outcome?.algoLabel ?: JSONObject.NULL) + put("volumes_ml", JSONArray().apply { volumes.forEach { put(it) } }) + put("trimmed_ml", if (trimmed.isFinite()) trimmed else JSONObject.NULL) + put("mean_ml", if (mean.isFinite()) mean else JSONObject.NULL) + put("sd_ml", sd) + put("detected_center", outcome?.detectedCenter ?: JSONObject.NULL) + put("failure", outcome?.failure ?: JSONObject.NULL) + // 채널별 벽 — 숫자가 이상할 때 제일 먼저 보는 것이다. + put("walls", JSONArray().apply { + outcome?.walls?.forEach { w -> + put(if (w == null) JSONObject.NULL else JSONObject().apply { + put("ch", w.channel) + put("ant", w.ant); put("post", w.post) + put("ant_mm", w.antMm); put("post_mm", w.postMm) + put("diameter_mm", w.diameterMm ?: JSONObject.NULL) + put("used_in_bv", w.usedInBv) + }) + } + }) + put("channels", JSONArray().apply { + raw.sortedBy { it.channel }.forEach { c -> + put(JSONObject().apply { + put("ch", c.channel) + val buf = c.buffer + val peak = buf.maxOrNull() ?: 0 + put("peak", peak) + put("peakIdx", buf.indexOfFirst { it == peak }) + put("data", JSONArray().apply { buf.forEach { put(it) } }) + }) + } + }) + } + + private fun fmt(v: Double?): String = + if (v == null || !v.isFinite()) "" else "%.2f".format(v) + + /** 쉼표·따옴표·줄바꿈이 섞인 메모가 열을 밀지 않게 한다. */ + internal fun csvField(s: String): String = + if (s.any { it == ',' || it == '"' || it == '\n' || it == '\r' }) + "\"" + s.replace("\"", "\"\"").replace("\r\n", " ").replace('\n', ' ') + .replace('\r', ' ') + "\"" + else s +} diff --git a/app/src/main/java/com/medithings/vesiscan/ui/views/clinical/BvMeasureSection.kt b/app/src/main/java/com/medithings/vesiscan/ui/views/clinical/BvMeasureSection.kt index 4fc8fed..22915d8 100644 --- a/app/src/main/java/com/medithings/vesiscan/ui/views/clinical/BvMeasureSection.kt +++ b/app/src/main/java/com/medithings/vesiscan/ui/views/clinical/BvMeasureSection.kt @@ -4,6 +4,8 @@ package com.medithings.vesiscan.ui.views.clinical import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.* @@ -16,10 +18,14 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.medithings.vesiscan.ble.BleManager import com.medithings.vesiscan.ble.PiezoChannelData +import com.medithings.vesiscan.managers.AnchorBasis import com.medithings.vesiscan.managers.ClinicalBv +import com.medithings.vesiscan.models.BladderFill +import com.medithings.vesiscan.models.ClinicalPosture import com.medithings.vesiscan.models.HospitalFixedParams import com.medithings.vesiscan.models.ProbeCycle import com.medithings.vesiscan.models.ProbeFrequency +import com.medithings.vesiscan.services.ManualCheckStore import com.medithings.vesiscan.ui.components.BvPanel import com.medithings.vesiscan.ui.components.SixChannelWaveformGrid import com.medithings.vesiscan.ui.theme.* @@ -88,9 +94,19 @@ fun BvMeasureSection( bleManager: BleManager, isConnected: Boolean, modifier: Modifier = Modifier, - /** 자세가 누움이면 BV 파이프라인이 nch=2 clamp 와 F83 에서 그것을 본다. */ - supine: Boolean = false, + /** + * 기록에 남길 맥락. 화면에 쓰지 않는 값도 받는 이유는, 나중에 CSV 한 행만 보고 + * "어떤 상태에서 잰 값인가"에 답할 수 있어야 하기 때문이다 — 자세·충만도가 빠지면 + * 위치끼리 비교(검증 항목 ③)가 성립하지 않는다. + */ + patient: String = "", + posture: ClinicalPosture = ClinicalPosture.SUPINE, + fill: BladderFill = BladderFill.P0, + anchorCm: Int? = null, + anchorBasis: AnchorBasis? = null, ) { + // 자세가 누움이면 BV 파이프라인이 nch=2 clamp 와 F83 에서 그것을 본다. + val supine = posture == ClinicalPosture.SUPINE var mode by remember { mutableStateOf(null) } // null = 정지 var outcome by remember { mutableStateOf(null) } var live by remember { mutableStateOf>(emptyList()) } @@ -99,9 +115,19 @@ fun BvMeasureSection( var window by remember { mutableStateOf>(emptyList()) } var tick by remember { mutableIntStateOf(0) } + // ── 기록 ──────────────────────────────────────────────────────────────── + // 마지막으로 **시작한** 모드. mode 는 1회 측정이 끝나면 null 로 돌아가므로, 끝난 뒤 + // 기록할 때 "무엇으로 쟀나"를 답할 수 없다. + var lastMode by remember { mutableStateOf(null) } + var offset by remember { mutableStateOf(ManualCheckStore.Offset.BASE) } + var memo by remember { mutableStateOf("") } + var saveMsg by remember { mutableStateOf(null) } + var saveFailed by remember { mutableStateOf(false) } + LaunchedEffect(mode) { val m = mode ?: return@LaunchedEffect + lastMode = m error = null if (m == Mode.CONTINUOUS) { window = emptyList(); tick = 0 } val inbox = kotlinx.coroutines.channels.Channel>( @@ -191,14 +217,17 @@ fun BvMeasureSection( Row(Modifier.fillMaxWidth()) { Button( - onClick = { mode = if (mode == Mode.SPOT) null else Mode.SPOT }, + onClick = { saveMsg = null; mode = if (mode == Mode.SPOT) null else Mode.SPOT }, enabled = isConnected && mode != Mode.CONTINUOUS, modifier = Modifier.weight(1f).height(46.dp), shape = RoundedCornerShape(12.dp), ) { Text(if (mode == Mode.SPOT) "측정 중…" else "1회 측정", fontWeight = FontWeight.Bold) } Spacer(Modifier.width(8.dp)) Button( - onClick = { mode = if (mode == Mode.CONTINUOUS) null else Mode.CONTINUOUS }, + onClick = { + saveMsg = null + mode = if (mode == Mode.CONTINUOUS) null else Mode.CONTINUOUS + }, enabled = isConnected && mode != Mode.SPOT, modifier = Modifier.weight(1f).height(46.dp), shape = RoundedCornerShape(12.dp), @@ -268,6 +297,81 @@ fun BvMeasureSection( }, ) + // ── 이 확인을 남기기 ── + // 기본은 남기지 않는다. 연속 모드는 초당 두 번 값을 내므로 자동 저장하면 의미 + // 없는 행이 수백 개 쌓여, 정작 비교하려는 위치별 값이 묻힌다. 조작자가 "이 자리 + // 값은 남길 것"이라고 판단한 순간만 기록한다. + val saveVolumes = if (window.isNotEmpty()) window else listOfNotNull(outcome?.volumeMl) + if (outcome != null) { + Spacer(Modifier.height(14.dp)) + Column( + Modifier.fillMaxWidth() + .background(MlPrimary.copy(alpha = 0.05f), RoundedCornerShape(12.dp)) + .padding(12.dp) + ) { + Text("이 확인 기록하기", fontSize = 13.sp, fontWeight = FontWeight.Bold) + Text( + "폰에만 저장됩니다 — Downloads/VesiScan_Hospital/<날짜_환자>/manual/. " + + "프로토콜 데이터(labdb)와 섞이지 않습니다.", + fontSize = 11.sp, color = MlSecondaryText, + ) + Spacer(Modifier.height(8.dp)) + + // 위치는 자유 입력이 아니다. 같은 자리가 "좌1"·"왼쪽 1cm"·"L1" 로 적히면 + // 나중에 묶을 수 없어, 위치 민감도 비교가 숫자 모음으로 끝난다. + Text("부착 위치 기준", fontSize = 11.sp, color = MlSecondaryText) + Spacer(Modifier.height(4.dp)) + OffsetPicker(offset, enabled = true) { offset = it } + + Spacer(Modifier.height(8.dp)) + OutlinedTextField( + value = memo, + onValueChange = { memo = it.take(200) }, + label = { Text("메모 (선택)") }, + placeholder = { Text("예: 젤 다시 바름, 환자 호흡 큼") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(Modifier.height(8.dp)) + Button( + onClick = { + val saved = ManualCheckStore.save( + patient = patient, at = java.util.Date(), + posture = posture, fill = fill, + anchorCm = anchorCm, anchorBasis = anchorBasis, + offset = offset, memo = memo, + mode = if (lastMode == Mode.CONTINUOUS) "continuous" else "spot", + freq = BV_FREQ, cycle = BV_CYCLE, + volumes = saveVolumes, outcome = outcome, raw = live, + ) + saveFailed = saved == null + // 실패를 조용히 넘기면 조작자는 기록됐다고 믿고 다음 위치로 + // 옮겨, 그 자리를 다시 잴 기회를 잃는다. + saveMsg = if (saved == null) "저장 실패 — 저장 공간과 권한을 확인하세요." + else "저장됨 · ${offset.label} · ${saveVolumes.size}개 값 → ${saved.csv.name}" + }, + enabled = saveVolumes.isNotEmpty(), + modifier = Modifier.fillMaxWidth().height(44.dp), + shape = RoundedCornerShape(12.dp), + ) { + Text( + if (saveVolumes.size > 1) "이 확인 기록 (${saveVolumes.size}개 값)" + else "이 확인 기록", + fontWeight = FontWeight.Bold, + ) + } + if (saveVolumes.isEmpty()) { + Text("BV 가 나온 회차가 없어 남길 것이 없습니다.", + fontSize = 11.sp, color = MlSecondaryText) + } + saveMsg?.let { + Spacer(Modifier.height(6.dp)) + Text(it, fontSize = 11.sp, + color = if (saveFailed) MlCritical else MlSuccess) + } + } + } + // ── 파형 + 벽 마커 ── // BV 가 안 나올 때 원인을 여기서 본다. 초록·주황 세로선이 전벽·후벽이다. if (live.isNotEmpty()) { @@ -285,6 +389,53 @@ fun BvMeasureSection( } } +/** + * 부착 위치 기준 오프셋 고르기 — 3열로 방향이 눈에 보이게 둔다. + * + * 드롭다운으로 두지 않은 이유는 항목 ③ 이 같은 자리를 여러 번 오가며 재는 작업이라, + * 누를 때마다 목록을 펼치면 잘못 고른 것도 알아채기 어렵기 때문이다. + */ +@Composable +private fun OffsetPicker( + selected: ManualCheckStore.Offset, + enabled: Boolean, + onSelect: (ManualCheckStore.Offset) -> Unit, +) { + val rows = listOf( + listOf(ManualCheckStore.Offset.UP_2, ManualCheckStore.Offset.UP_1, + ManualCheckStore.Offset.BASE), + listOf(ManualCheckStore.Offset.DOWN_1, ManualCheckStore.Offset.DOWN_2, + ManualCheckStore.Offset.LEFT_1), + listOf(ManualCheckStore.Offset.LEFT_2, ManualCheckStore.Offset.RIGHT_1, + ManualCheckStore.Offset.RIGHT_2), + ) + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + rows.forEach { row -> + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(4.dp)) { + row.forEach { o -> + val on = o == selected + Box( + Modifier.weight(1f) + .background(if (on) MlPrimary else Color.White, RoundedCornerShape(8.dp)) + .then( + if (on) Modifier + else Modifier.border(1.dp, MlSecondaryText.copy(alpha = 0.3f), + RoundedCornerShape(8.dp)) + ) + .clickable(enabled = enabled) { onSelect(o) } + .padding(vertical = 8.dp), + contentAlignment = Alignment.Center, + ) { + Text(o.label, fontSize = 12.sp, + fontWeight = if (on) FontWeight.Bold else FontWeight.Normal, + color = if (on) Color.White else MlSecondaryText) + } + } + } + } + } +} + /** 최대·최소 하나씩 버린 평균. 메인 화면 `trimmedMean` 과 같은 식. */ internal fun trimmedMean(values: List): Double { if (values.size <= 2) return values.average() diff --git a/app/src/main/java/com/medithings/vesiscan/ui/views/clinical/HospitalModeView.kt b/app/src/main/java/com/medithings/vesiscan/ui/views/clinical/HospitalModeView.kt index ed036db..99e2c7a 100644 --- a/app/src/main/java/com/medithings/vesiscan/ui/views/clinical/HospitalModeView.kt +++ b/app/src/main/java/com/medithings/vesiscan/ui/views/clinical/HospitalModeView.kt @@ -645,7 +645,11 @@ fun HospitalModeView(appState: AppState) { BvMeasureSection( bleManager = bleManager, isConnected = isConnected && !running, - supine = posture == ClinicalPosture.SUPINE, + patient = patient, + posture = posture, + fill = fill, + anchorCm = appState.anchorCm, + anchorBasis = appState.anchorBasis, ) Spacer(Modifier.height(16.dp)) diff --git a/app/src/test/java/com/medithings/vesiscan/services/ManualCheckStoreTest.kt b/app/src/test/java/com/medithings/vesiscan/services/ManualCheckStoreTest.kt new file mode 100644 index 0000000..fb528cb --- /dev/null +++ b/app/src/test/java/com/medithings/vesiscan/services/ManualCheckStoreTest.kt @@ -0,0 +1,69 @@ +package com.medithings.vesiscan.services + +import com.medithings.vesiscan.ui.views.clinical.trimmedMean +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * 수동 확인 기록의 **사람이 틀릴 수 있는 부분**만 고정한다. + * + * 파일 쓰기 자체(`save`)는 `Environment.getExternalStoragePublicDirectory` 를 타므로 + * 단위 시험에서 못 돈다. 실기기에서 확인할 수밖에 없는 부분은 그쪽에 남기고, 여기서는 + * 기기 없이도 틀릴 수 있는 셋을 잡는다 — CSV 깨짐, 대표값 식, 기록 문자열. + */ +class ManualCheckStoreTest { + + @Test fun `대표값 식이 화면과 같다`() { + // 화면은 절사평균을 보여주는데 파일에 다른 수가 들어가면, 보고서를 쓰는 사람이 + // 화면을 믿어야 할지 파일을 믿어야 할지 알 수 없다. 두 함수를 직접 맞댄다. + listOf( + listOf(180.0, 182.0, 179.0, 181.0, 900.0), + listOf(1.0, 2.0, 3.0), + listOf(5.0, 10.0), + listOf(42.0), + listOf(3.0, 3.0, 3.0, 3.0), + ).forEach { v -> + assertEquals("입력 $v", trimmedMean(v), ManualCheckStore.trimmedMean(v), 1e-12) + } + } + + @Test fun `메모에 쉼표가 있어도 열이 밀리지 않는다`() { + // 메모는 자유 입력이다. 감싸지 않으면 쉼표 하나가 그 행의 모든 뒷열을 한 칸씩 + // 밀어, CSV 전체가 조용히 어긋난다. + assertEquals("\"젤 다시 바름, 호흡 큼\"", ManualCheckStore.csvField("젤 다시 바름, 호흡 큼")) + } + + @Test fun `따옴표는 두 번으로 바꾼다`() { + assertEquals("\"\"\"왼쪽\"\" 기준\"", ManualCheckStore.csvField("\"왼쪽\" 기준")) + } + + @Test fun `줄바꿈은 공백으로 바꾼다`() { + // 줄바꿈이 그대로 들어가면 한 행이 두 행으로 보여 행 수가 맞지 않는다. + val out = ManualCheckStore.csvField("첫 줄\n둘째 줄") + assertTrue(out.startsWith("\"") && out.endsWith("\"")) + assertTrue("줄바꿈이 남으면 안 된다", !out.contains("\n") && !out.contains("\r")) + assertEquals("\"첫 줄 둘째 줄\"", out) + } + + @Test fun `평범한 값은 감싸지 않는다`() { + // 불필요하게 감싸면 엑셀이 아닌 도구로 읽을 때 따옴표가 값에 섞인다. + assertEquals("Supine", ManualCheckStore.csvField("Supine")) + assertEquals("", ManualCheckStore.csvField("")) + assertEquals("up_1", ManualCheckStore.csvField("up_1")) + } + + @Test fun `오프셋 기록 문자열은 고정이다`() { + // 분석할 때 이 값으로 위치를 묶는다. 바뀌면 과거 기록과 대조가 끊긴다. + assertEquals( + listOf("base", "up_1", "up_2", "down_1", "down_2", + "left_1", "left_2", "right_1", "right_2"), + ManualCheckStore.Offset.entries.map { it.wire }, + ) + } + + @Test fun `오프셋 표시 문구는 서로 다르다`() { + val labels = ManualCheckStore.Offset.entries.map { it.label } + assertEquals(labels.size, labels.toSet().size) + } +}