diff --git a/app/src/main/java/com/medithings/vesiscan/services/labdb/HospitalLabdbPayload.kt b/app/src/main/java/com/medithings/vesiscan/services/labdb/HospitalLabdbPayload.kt new file mode 100644 index 0000000..3ca43a3 --- /dev/null +++ b/app/src/main/java/com/medithings/vesiscan/services/labdb/HospitalLabdbPayload.kt @@ -0,0 +1,183 @@ +/* + * HospitalLabdbPayload — 병원 임상 CSV → labdb `/upload/json` 페이로드. + */ +package com.medithings.vesiscan.services.labdb + +import org.json.JSONArray +import org.json.JSONObject +import java.io.File +import java.security.MessageDigest +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import java.util.TimeZone + +/** + * 병원 임상 모드의 조합 CSV 하나를 labdb 세션 하나로 바꾼다. + * + * ## 왜 [LabdbUploader] 를 못 쓰나 + * 그쪽은 `VesiScan_Sessions` 의 `measurement_.json`(cycles 배열)을 읽는다. + * 병원 모드는 형식이 완전히 다르다 — CSV 한 행이 **1 반복 × 1 채널**이고, 조건별로 + * 파일이 갈리며, 매니페스트가 따로 있다. 그래서 기존 업로더로는 한 건도 올라가지 않는다. + * + * ## 매핑 — 2026-09-05 수동 업로드(279건)와 **같은 규칙** + * 그때 파이썬 변환기로 올린 것과 모양이 달라지면 labdb 에서 같은 프로토콜 데이터가 + * 두 형태로 쌓인다. 분석하는 쪽이 두 벌을 만들어야 하므로 규칙을 그대로 옮긴다. + * + * ``` + * CSV 파일 1개 = labdb 세션 1개 (자세·충만도·주파수·cycle 이 고정된 20 반복) + * CSV 6행(CH0~5) = labdb record 1개 → channels[0..5].data = s0..s99 + * ``` + * + * 환자 폴더 전체를 세션 하나로 묶지 않는다 — 1,440 record 짜리 덩어리가 되어 조건별 + * 비교가 불가능해진다. + * + * ## 없는 값을 지어내지 않는다 + * 병원 CSV 에는 IMU·배터리·온도 열이 없다. `sensor` 는 빈 객체로 두어 labdb 표준 + * 자리는 유지하되 0 이나 null 로 채우지 않는다 — 나중에 "IMU 가 0 이었다"로 읽힌다. + */ +object HospitalLabdbPayload { + + /** + * labdb 는 100~899 를 관리자가 배정한다. 병원 임상 프로토콜용 코드는 **아직 없어서** + * 사용자 정의 구간(900~999)을 쓴다. 배정받으면 이 값만 바꾸면 된다 — 2026-09-05 + * 수동 업로드분도 같은 값으로 올라가 있다. + */ + const val DATA_TYPE = "901" + + private const val PROTOCOL = "hospital_clinical_2026" + private const val SAMPLES_PER_CHANNEL = 100 + + private val isoOut: SimpleDateFormat + get() = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSXXX", Locale.US) + + /** CSV `timestamp` 열 포맷. HospitalRunStore.stampFmt 와 같아야 한다. */ + private val stampIn: SimpleDateFormat + get() = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US) + + /** + * @param csv 조합 CSV. [HospitalRunStore.combinationFile] 가 만든 것. + * @param manifestParams 매니페스트에만 있는 값(avg·delay_us·samples 등). 없으면 생략한다. + * @return 업로드 가능한 페이로드. 유효 record 가 없으면 null. + */ + fun build( + csv: File, + manifestParams: JSONObject? = null, + dataType: String = DATA_TYPE, + ): JSONObject? { + val lines = runCatching { csv.readLines(Charsets.UTF_8) }.getOrNull() ?: return null + if (lines.size < 2) return null + + val header = splitCsv(lines[0].removePrefix("")) + val idx = header.withIndex().associate { (i, name) -> name.trim() to i } + fun col(row: List, name: String): String = + idx[name]?.let { row.getOrNull(it) }?.trim().orEmpty() + + // 반복(repeat_idx) 단위로 묶는다. 한 반복 = 6채널 = record 1개. + val byRepeat = linkedMapOf>() + val times = HashMap() + var head: List? = null + + for (i in 1 until lines.size) { + val row = splitCsv(lines[i]) + if (row.size < header.size - SAMPLES_PER_CHANNEL) continue + if (head == null) head = row + val rep = col(row, "repeat_idx").toIntOrNull() ?: continue + val ch = col(row, "channel").removePrefix("CH").toIntOrNull() ?: continue + + val data = ArrayList(SAMPLES_PER_CHANNEL) + for (s in 0 until SAMPLES_PER_CHANNEL) { + val v = idx["s$s"]?.let { row.getOrNull(it) }?.trim() + if (v.isNullOrEmpty()) break // 짧은 버퍼 — 거기까지만 쓴다 + val n = v.toDoubleOrNull()?.toInt() ?: break + data.add(n) + } + if (data.isEmpty()) continue + byRepeat.getOrPut(rep) { linkedMapOf() }[ch] = data.toIntArray() + // 한 반복의 6채널은 같은 캡처라 시각이 사실상 같다. 첫 채널 것을 쓴다. + times.getOrPut(rep) { col(row, "timestamp") } + } + val h = head ?: return null + if (byRepeat.isEmpty()) return null + + val records = JSONArray() + for (rep in byRepeat.keys.sorted()) { + val chans = JSONArray() + for (ch in byRepeat.getValue(rep).keys.sorted()) { + val d = byRepeat.getValue(rep).getValue(ch) + var peak = d[0]; var peakIdx = 0 + for (k in d.indices) if (d[k] > peak) { peak = d[k]; peakIdx = k } // 최초 최대값 + chans.put(JSONObject().apply { + put("ch", ch) + put("peak", peak) + put("peakIdx", peakIdx) + put("data", JSONArray(d.toList())) + }) + } + records.put(JSONObject().apply { + put("rowIndex", rep) + put("datetime", iso(times[rep])) + put("commandType", "MTB") + put("sensor", JSONObject()) // 병원 CSV 에 IMU·배터리·온도 열이 없다 + put("channels", chans) + }) + } + + val subject = col(h, "patient") + val base = csv.nameWithoutExtension + val params = JSONObject().apply { + put("protocol", PROTOCOL) + put("subject", subject) + put("posture", col(h, "posture")) + num(col(h, "fill_pct"))?.let { put("fill_pct", it) } + num(col(h, "freq_mhz"))?.let { put("freq_mhz", it) } + num(col(h, "freq_option"))?.let { put("freq_option", it) } + num(col(h, "cycles"))?.let { put("cycles", it) } + put("device", col(h, "device")) + put("firmware_version", col(h, "firmware_version")) + put("repeats_saved", records.length()) + put("source_file", csv.name) + manifestParams?.let { m -> m.keys().forEach { k -> put(k, m.get(k)) } } + } + + return JSONObject().apply { + put("testId", compactTestId(subject, csv.name)) + put("dataType", dataType) + put("sessionName", base) + put("params", params) + put("recordCount", records.length()) + put("records", records) + times.keys.maxOrNull()?.let { last -> iso(times[last])?.let { put("savedAt", it) } } + } + } + + /** + * `^[A-Za-z0-9_-]{1,40}$` 제한에 맞춘 testId. + * + * 파일명이 그대로 들어가면 40자를 넘는 경우가 대부분이라(날짜·환자·자세·충만도· + * 주파수·cycle 이 다 붙는다), 넘치면 **앞 31자 + `_` + 파일명 SHA-1 앞 8자**로 줄인다. + * 해시를 붙이는 이유는 조합이 달라도 앞부분이 같아 충돌하기 때문이다 — 충돌하면 + * labdb 가 다른 조합을 같은 세션으로 합친다. + */ + fun compactTestId(subject: String, fileName: String): String { + val raw = fileName.substringBeforeLast('.') + val safe = raw.replace(Regex("[^A-Za-z0-9_-]"), "_") + if (safe.length <= 40) return safe.ifEmpty { "session" } + val sha = MessageDigest.getInstance("SHA-1") + .digest(fileName.toByteArray(Charsets.UTF_8)) + .joinToString("") { "%02x".format(it) } + .take(8) + return safe.take(31) + "_" + sha + } + + private fun iso(stamp: String?): String? { + if (stamp.isNullOrBlank()) return null + val d = runCatching { stampIn.parse(stamp) }.getOrNull() ?: return null + return isoOut.apply { timeZone = TimeZone.getDefault() }.format(d) + } + + private fun num(s: String): Double? = s.takeIf { it.isNotBlank() }?.toDoubleOrNull() + + /** 값에 콤마가 없는 단순 CSV — 이 파일이 만드는 형식이 그렇다. */ + private fun splitCsv(line: String): List = line.split(',') +} diff --git a/app/src/main/java/com/medithings/vesiscan/services/labdb/HospitalLabdbUploader.kt b/app/src/main/java/com/medithings/vesiscan/services/labdb/HospitalLabdbUploader.kt new file mode 100644 index 0000000..4f4af73 --- /dev/null +++ b/app/src/main/java/com/medithings/vesiscan/services/labdb/HospitalLabdbUploader.kt @@ -0,0 +1,154 @@ +/* + * HospitalLabdbUploader — 병원 임상 조합 CSV 를 labdb 로 올린다. + */ +package com.medithings.vesiscan.services.labdb + +import android.util.Log +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import kotlinx.coroutines.delay +import org.json.JSONObject +import java.io.File + +/** + * 조합 하나(=CSV 하나)를 세션 하나로 올린다. + * + * ## 왜 조합 단위인가 + * labdb 는 요청 1건 = 세션 1개 = record N개다. 한 조합이 20 반복이므로 **조합이 끝날 + * 때마다 1건**이면 자연스럽게 맞고, 6조합 한 바퀴가 6건이라 분당 10건 제한에도 여유가 + * 있다. 측정 1회마다 올리면 한 바퀴에 120건이 되어 제한에 걸린다. + * + * ## 마커로 상태를 남긴다 + * 성공하면 `.labdb.json`, 실패하면 `.labdb_error.json` 을 CSV 옆에 + * 둔다. 앱이 죽어도 디스크만 보면 무엇이 남았는지 알 수 있다 — 인터넷이 끊긴 병원에서 + * 측정을 마치고 나중에 올리는 것이 이 기능의 요점이라, 상태를 메모리에 두면 안 된다. + * + * 실패 마커는 다음 시도에서 지운다. 남겨 두면 "실패한 적 있음"과 "지금 실패 상태"를 + * 구분할 수 없다. + */ +object HospitalLabdbUploader { + + private const val TAG = "HospitalLabdb" + + /** 성공 마커. 이게 있으면 다시 올리지 않는다. */ + private const val OK_SUFFIX = ".labdb.json" + private const val ERR_SUFFIX = ".labdb_error.json" + + /** + * 연속 업로드 사이 간격. + * + * labdb 는 분당 10건 제한이다. 밀린 것을 한꺼번에 올릴 때 그 제한에 걸리면 남은 + * 것이 전부 실패로 기록되므로, 처음부터 여유 있게 띄운다(분당 ~8건). + */ + private const val GAP_MS = 7_500L + + // ── 화면이 관찰하는 상태 ── + /** 이 실행에서 아직 못 올린 건수. */ + val pendingCount = mutableIntStateOf(0) + /** 지금 올리는 중인 파일 이름. null = idle. */ + val uploadingName = mutableStateOf(null) + /** 마지막 배치 결과 한 줄. */ + val lastMessage = mutableStateOf(null) + + @Volatile private var running = false + + fun isUploaded(csv: File): Boolean = markerOk(csv).exists() + fun lastError(csv: File): String? = markerErr(csv).takeIf { it.exists() } + ?.let { runCatching { JSONObject(it.readText()).optString("message") }.getOrNull() } + + private fun markerOk(csv: File) = File(csv.parentFile, csv.name + OK_SUFFIX) + private fun markerErr(csv: File) = File(csv.parentFile, csv.name + ERR_SUFFIX) + + /** + * 폴더에서 아직 안 올라간 조합 CSV 를 찾는다. + * + * `align/` 밑의 정렬 원시 데이터는 대상이 아니다 — 형식이 다르고(위치별 cycle), + * 올릴지 여부도 별도 판단이다. + */ + fun pending(runDir: File): List = + runDir.listFiles() + ?.filter { it.isFile && it.extension.equals("csv", true) && !isUploaded(it) } + ?.sortedBy { it.name } + ?: emptyList() + + /** + * CSV 하나를 올린다. 성공/실패 모두 마커를 남긴다. + * + * @return 성공 여부와 사람이 읽을 메시지. + */ + suspend fun upload(csv: File, manifestParams: JSONObject? = null): Pair { + if (!LabdbCredentials.isRegistered) return false to "labdb 미등록 — 설정에서 등록하세요." + if (isUploaded(csv)) return true to "이미 업로드됨" + + val payload = HospitalLabdbPayload.build(csv, manifestParams) + ?: return fail(csv, "변환 실패 — 유효한 반복이 없습니다.") + + return try { + val r = LabdbClient.uploadJson(payload) + markerErr(csv).delete() + markerOk(csv).writeText( + JSONObject().apply { + put("sessionId", r.sessionId) + put("created", r.created) + put("totalRecords", r.totalRecords) + put("inserted", r.inserted) + put("duplicates", r.duplicates) + put("errors", r.errors) + put("uploadedAt", System.currentTimeMillis()) + }.toString(2), + Charsets.UTF_8, + ) + true to "업로드 ${r.inserted}/${r.totalRecords} (중복 ${r.duplicates})" + } catch (e: Exception) { + fail(csv, e.message ?: e.javaClass.simpleName) + } + } + + private fun fail(csv: File, msg: String): Pair { + runCatching { + markerErr(csv).writeText( + JSONObject().apply { + put("message", msg) + put("failedAt", System.currentTimeMillis()) + }.toString(2), + Charsets.UTF_8, + ) + } + Log.w(TAG, "upload failed: ${csv.name} — $msg") + return false to msg + } + + /** + * 폴더의 미업로드 CSV 를 순서대로 올린다. 이미 돌고 있으면 아무것도 하지 않는다. + * + * 한 건이 실패해도 멈추지 않는다 — 인터넷이 잠깐 끊긴 것과 그 파일이 잘못된 것을 + * 구분할 수 없고, 뒤의 멀쩡한 것까지 막으면 손해가 크다. 실패한 것은 마커가 남아 + * 다음 호출에서 다시 시도된다. + */ + suspend fun uploadPending(runDir: File, manifestParams: JSONObject? = null): String { + if (running) return "이미 업로드 중입니다." + running = true + try { + val targets = pending(runDir) + pendingCount.intValue = targets.size + if (targets.isEmpty()) { + lastMessage.value = "올릴 것이 없습니다." + return lastMessage.value!! + } + var ok = 0 + var failed = 0 + targets.forEachIndexed { i, csv -> + uploadingName.value = csv.name + val (success, _) = upload(csv, manifestParams) + if (success) ok++ else failed++ + pendingCount.intValue = targets.size - (i + 1) + if (i < targets.lastIndex) delay(GAP_MS) // 분당 제한 회피 + } + lastMessage.value = "업로드 완료 $ok · 실패 $failed" + return lastMessage.value!! + } finally { + uploadingName.value = null + running = false + } + } +} 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 44d9140..a000bdc 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 @@ -45,6 +45,8 @@ import com.medithings.vesiscan.ble.BleManager import com.medithings.vesiscan.models.* import com.medithings.vesiscan.services.AdcCsvLogger import com.medithings.vesiscan.services.HospitalRunStore +import com.medithings.vesiscan.services.labdb.HospitalLabdbUploader +import com.medithings.vesiscan.services.labdb.LabdbCredentials import com.medithings.vesiscan.ui.theme.* import androidx.compose.foundation.border import kotlinx.coroutines.Dispatchers @@ -115,6 +117,16 @@ fun HospitalModeView(appState: AppState) { // 채널 접촉 상태 — 파형 옆에 글자로 같이 띄운다. 파형만으로 "이 채널이 떴다"를 // 읽으려면 눈이 익어야 하고, 20회를 다 돌린 뒤에 알면 그 단계는 다시 못 잰다 // (방광을 비웠거나 자세가 바뀐다). + // 업로드 버튼은 화면 수명에 묶인 스코프에서 돈다 — 측정 루프(LaunchedEffect)와 + // 달리 화면을 벗어나도 계속 돌 이유가 없다. + val uploadScope = rememberCoroutineScope() + + // 방금 끝난 실행 폴더 — 업로드 재시도가 이 폴더를 다시 훑는다. + var lastRunDir by remember { mutableStateOf(null) } + var pendingUploads by remember { mutableIntStateOf(0) } + var uploadBusy by remember { mutableStateOf(false) } + var uploadMsg by remember { mutableStateOf(null) } + val detachWatcher = remember { com.medithings.vesiscan.managers.DetachWatcher() } var detachSnapshot by remember { mutableStateOf(null) @@ -149,6 +161,7 @@ fun HospitalModeView(appState: AppState) { if (!running) return@LaunchedEffect val startedAt = Date() val dir = HospitalRunStore.runDir(patient, startedAt) + lastRunDir = dir val fw = bleManager.firmwareVersion.value val results = JSONArray() okCount = 0; failCount = 0; configFailed = null; restoreNote = null @@ -244,6 +257,16 @@ fun HospitalModeView(appState: AppState) { } delay(REPEAT_GAP_MS) } + // 조합이 끝나는 즉시 올린다. 한 바퀴(6조합)를 다 돌 때까지 기다리면 + // 중간에 앱이 죽거나 자리를 옮겼을 때 그때까지 잰 것이 통째로 남는다. + // 실패해도 루프를 세우지 않는다 — 마커가 남아 나중에 버튼으로 올린다. + if (LabdbCredentials.isRegistered) { + launch { + val (_, m) = HospitalLabdbUploader.upload(file) + uploadMsg = "${file.name} — $m" + lastRunDir?.let { d -> pendingUploads = HospitalLabdbUploader.pending(d).size } + } + } results.put(JSONObject().apply { put("freq_mhz", freq.label) put("freq_option", freq.freqOption) @@ -282,6 +305,7 @@ fun HospitalModeView(appState: AppState) { put("anchor_cm", appState.anchorCm ?: JSONObject.NULL) put("combinations", results) }, startedAt) + pendingUploads = HospitalLabdbUploader.pending(dir).size running = false } } @@ -498,6 +522,63 @@ fun HospitalModeView(appState: AppState) { if (!isConnected) Hint("위 [기기 연결] 을 눌러 프로브를 연결해 주세요.") else if (patient.isBlank()) Hint("환자명을 입력해 주세요.") + // ── labdb 업로드 ──────────────────────────────────────────── + // 조합이 끝날 때마다 자동으로 올라간다. 인터넷이 끊겨 있었으면 여기 + // 남아 있다가 이 버튼으로 한꺼번에 올린다 — 병원 무선망이 불안정한 + // 곳이 많아 "나중에 올리기"가 예외가 아니라 정상 경로다. + if (LabdbCredentials.isRegistered && lastRunDir != null) { + Spacer(Modifier.height(12.dp)) + Box( + Modifier.fillMaxWidth() + .background( + if (pendingUploads > 0) Color(0xFFFFF3E0) else MlPrimary.copy(alpha = 0.07f), + RoundedCornerShape(12.dp), + ) + .padding(12.dp) + ) { + Column { + Text( + if (pendingUploads > 0) "labdb 미업로드 ${pendingUploads}건" + else "labdb 업로드 완료", + fontSize = 14.sp, fontWeight = FontWeight.Bold, + color = if (pendingUploads > 0) Color(0xFFE65100) else MlSuccess, + ) + (HospitalLabdbUploader.uploadingName.value ?: uploadMsg)?.let { + Spacer(Modifier.height(4.dp)) + Text(it, fontSize = 12.sp, color = MlSecondaryText) + } + if (pendingUploads > 0) { + Spacer(Modifier.height(8.dp)) + Button( + onClick = { + val d = lastRunDir ?: return@Button + uploadBusy = true + uploadScope.launch { + uploadMsg = HospitalLabdbUploader.uploadPending(d) + pendingUploads = HospitalLabdbUploader.pending(d).size + uploadBusy = false + } + }, + enabled = !uploadBusy, + modifier = Modifier.fillMaxWidth().height(46.dp), + shape = RoundedCornerShape(12.dp), + ) { + Text( + if (uploadBusy) "업로드 중…" else "지금 업로드", + fontWeight = FontWeight.Bold, + ) + } + Spacer(Modifier.height(4.dp)) + Text( + "요청 사이를 띄워 보냅니다 — ${pendingUploads}건이면 약 " + + "${(pendingUploads - 1) * 8 / 60 + 1}분 걸립니다.", + fontSize = 11.sp, color = MlSecondaryText, + ) + } + } + } + } + if (okCount > 0 || failCount > 0) { Spacer(Modifier.height(12.dp)) Text("직전 실행 — 저장 $okCount · 실패 $failCount", diff --git a/app/src/test/java/com/medithings/vesiscan/services/labdb/HospitalLabdbPayloadTest.kt b/app/src/test/java/com/medithings/vesiscan/services/labdb/HospitalLabdbPayloadTest.kt new file mode 100644 index 0000000..4487358 --- /dev/null +++ b/app/src/test/java/com/medithings/vesiscan/services/labdb/HospitalLabdbPayloadTest.kt @@ -0,0 +1,145 @@ +package com.medithings.vesiscan.services.labdb + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File + +/** + * 병원 임상 CSV → labdb 페이로드 변환. + * + * 2026-09-05 에 파이썬 변환기로 279건을 이미 올렸다. 앱이 만드는 모양이 그것과 달라지면 + * labdb 에 같은 프로토콜 데이터가 **두 형태로** 쌓이고, 분석하는 쪽이 두 벌을 만들게 된다. + * 그래서 그때의 규칙을 여기에 고정한다: + * + * ``` + * CSV 파일 1개 = 세션 1개 + * CSV 6행(CH0~5) = record 1개 → channels[0..5].data = s0..s99 + * rowIndex = repeat_idx + * ``` + */ +class HospitalLabdbPayloadTest { + + @get:Rule val tmp = TemporaryFolder() + + private val header: String = + "scan_id,timestamp,patient,posture,fill_pct,freq_mhz,freq_option,cycles,repeat_idx," + + "device,firmware_version,channel" + (0 until 100).joinToString("") { ",s$it" } + + /** 한 행 = 1 반복 × 1 채널. [peakAt] 위치에 최대값을 심어 peakIdx 를 검증한다. */ + private fun row(rep: Int, ch: Int, peakAt: Int, ts: String = "2026-09-08 10:00:0$rep"): String { + val samples = (0 until 100).map { if (it == peakAt) 4000 else 1000 } + return listOf( + "1", ts, "P001", "Supine", "40", "2.3", "0", "3", rep.toString(), + "VBT26080001", "0.1.21", "CH$ch", + ).joinToString(",") + "," + samples.joinToString(",") + } + + private fun csvOf(vararg rows: String): File { + val f = File(tmp.root, "260908_P001_Supine_f040_23f_c3.csv") + f.writeText((listOf(header) + rows).joinToString("\n"), Charsets.UTF_8) + return f + } + + private fun fullRepeat(rep: Int) = (0..5).map { ch -> row(rep, ch, peakAt = 20 + ch) } + + @Test fun `6채널 한 묶음이 record 하나가 된다`() { + val p = HospitalLabdbPayload.build(csvOf(*fullRepeat(0).toTypedArray()))!! + assertEquals(1, p.getInt("recordCount")) + val rec = p.getJSONArray("records").getJSONObject(0) + assertEquals(0, rec.getInt("rowIndex")) + assertEquals("MTB", rec.getString("commandType")) + assertEquals(6, rec.getJSONArray("channels").length()) + assertEquals(100, rec.getJSONArray("channels").getJSONObject(0) + .getJSONArray("data").length()) + } + + @Test fun `반복마다 record 가 하나씩 늘어난다`() { + val rows = (0 until 3).flatMap { fullRepeat(it) } + val p = HospitalLabdbPayload.build(csvOf(*rows.toTypedArray()))!! + assertEquals(3, p.getInt("recordCount")) + val recs = p.getJSONArray("records") + // rowIndex 는 repeat_idx 그대로다 — 순서를 다시 매기면 원본 CSV 와 대조가 안 된다. + assertEquals(listOf(0, 1, 2), (0 until 3).map { recs.getJSONObject(it).getInt("rowIndex") }) + } + + @Test fun `peak 과 peakIdx 는 최초 최대값 위치다`() { + val p = HospitalLabdbPayload.build(csvOf(*fullRepeat(0).toTypedArray()))!! + val chans = p.getJSONArray("records").getJSONObject(0).getJSONArray("channels") + for (i in 0 until chans.length()) { + val c = chans.getJSONObject(i) + assertEquals(4000, c.getInt("peak")) + assertEquals(20 + c.getInt("ch"), c.getInt("peakIdx")) + } + } + + @Test fun `IMU 가 없는 자리는 지어내지 않는다`() { + // 병원 CSV 에는 IMU·배터리·온도 열이 없다. 0 으로 채우면 나중에 "IMU 가 0 이었다"로 읽힌다. + val p = HospitalLabdbPayload.build(csvOf(*fullRepeat(0).toTypedArray()))!! + val sensor = p.getJSONArray("records").getJSONObject(0).getJSONObject("sensor") + assertEquals(0, sensor.length()) + } + + @Test fun `조건이 params 에 들어간다`() { + val p = HospitalLabdbPayload.build(csvOf(*fullRepeat(0).toTypedArray()))!! + val params = p.getJSONObject("params") + assertEquals("hospital_clinical_2026", params.getString("protocol")) + assertEquals("P001", params.getString("subject")) + assertEquals("Supine", params.getString("posture")) + assertEquals(40.0, params.getDouble("fill_pct"), 1e-9) + assertEquals(2.3, params.getDouble("freq_mhz"), 1e-9) + assertEquals(3.0, params.getDouble("cycles"), 1e-9) + assertEquals(1, params.getInt("repeats_saved")) + assertEquals(HospitalLabdbPayload.DATA_TYPE, p.getString("dataType")) + } + + @Test fun `매니페스트 값이 params 에 합쳐진다`() { + val extra = org.json.JSONObject().apply { put("avg", 8); put("delay_us", 12) } + val p = HospitalLabdbPayload.build(csvOf(*fullRepeat(0).toTypedArray()), extra)!! + assertEquals(8, p.getJSONObject("params").getInt("avg")) + assertEquals(12, p.getJSONObject("params").getInt("delay_us")) + } + + @Test fun `빈 파일과 헤더만 있는 파일은 null`() { + val empty = File(tmp.root, "a.csv").apply { writeText("") } + assertNull(HospitalLabdbPayload.build(empty)) + val headerOnly = File(tmp.root, "b.csv").apply { writeText(header) } + assertNull(HospitalLabdbPayload.build(headerOnly)) + } + + @Test fun `짧은 버퍼는 있는 데까지만 담는다`() { + // 연결이 끊겨 채널 버퍼가 짧게 온 회차가 실제로 생긴다. 0 으로 채우면 + // 신호가 있었던 것처럼 보인다. + val short = listOf( + "1", "2026-09-08 10:00:00", "P001", "Supine", "40", "2.3", "0", "3", "0", + "VBT26080001", "0.1.21", "CH0", + ).joinToString(",") + "," + (0 until 40).joinToString(",") { "1000" } + + (0 until 60).joinToString("") { "," } + val p = HospitalLabdbPayload.build(csvOf(short))!! + val data = p.getJSONArray("records").getJSONObject(0) + .getJSONArray("channels").getJSONObject(0).getJSONArray("data") + assertEquals(40, data.length()) + } + + @Test fun `testId 는 40자 제한을 지키고 충돌하지 않는다`() { + // 파일명이 그대로 들어가면 대부분 40자를 넘는다. 앞부분만 자르면 조합이 달라도 + // 같아져 labdb 가 다른 조합을 한 세션으로 합친다 — 해시를 붙여 가른다. + val a = HospitalLabdbPayload.compactTestId( + "P001", "260908_P001_Supine_fill040_freq23_cycle3_extra_long.csv") + val b = HospitalLabdbPayload.compactTestId( + "P001", "260908_P001_Supine_fill040_freq23_cycle5_extra_long.csv") + assertTrue(a.length <= 40) + assertTrue(b.length <= 40) + assertFalse("서로 다른 조합이 같은 testId 가 되면 안 된다", a == b) + assertTrue(Regex("^[A-Za-z0-9_-]{1,40}$").matches(a)) + } + + @Test fun `짧은 파일명은 그대로 쓴다`() { + val id = HospitalLabdbPayload.compactTestId("P001", "260908_P001_Sup.csv") + assertEquals("260908_P001_Sup", id) + } +}