From 3d8168a0f5b475fedbc171fce03e5f1e95ab44ae Mon Sep 17 00:00:00 2001 From: jjangddu Date: Tue, 8 Sep 2026 13:57:11 +0900 Subject: [PATCH] =?UTF-8?q?feat(clinical):=20=EC=A0=95=EB=A0=AC=20?= =?UTF-8?q?=ED=8C=8C=ED=98=95=EC=9D=84=20=EA=B0=84=ED=98=B8=EC=82=AC=20?= =?UTF-8?q?=ED=8C=90=EB=8B=A8=EC=9C=BC=EB=A1=9C=20labdb=20=EC=97=90=20?= =?UTF-8?q?=EB=B3=B4=EB=82=B8=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 파형이 이상해 보일 때 그 자리에서 개발자에게 보내는 통로가 없었다. 임상이 끝난 뒤 파일을 꺼내 보내면 회신이 하루 뒤인데, 그때는 이미 그 환자로 다시 잴 수 없다. 정렬 전용 dataType 902 를 새로 쓴다. 임상 측정(901)과 구조가 다르다 — 저쪽은 한 조건에서 20 반복이고 정렬은 여러 위치 × 20 cycle 이다. 같은 코드로 올리면 params 만으로는 구분이 안 되고 콘솔이 두 종류를 한 차트로 그린다. labdb 는 dataType 마다 records[] 스키마가 완전히 달라도 되고(labdb.md §2-3), 900~999 가 사용자 정의 구간이다. record = 한 위치의 한 cycle rowIndex : 위치를 가로질러 0..N 연속 (labdb 규약) align_cm : 그 cycle 을 잰 위치 cycle_idx : 그 위치 안에서의 순번 channels : 6 × {ch, peak, peakIdx, data} **판정 근거(align_result.json)를 params 에 통째로 넣는다.** 개발자가 답해야 할 질문이 "왜 이 위치를 골랐나"라서 파형만 오면 되묻게 된다 — 지표(nch·ch3·cap_frac)와 선택 결과, 좌우 결과가 같이 가야 한 번에 답이 나온다. **자동이 아니다.** 간호사가 버튼을 눌러야 올라간다. 목적이 "이상하니 봐 달라"는 요청이라 정상인 것까지 올리면 정작 봐야 할 것이 묻힌다. 무엇이 이상해 보였는지 한 줄을 받아 memo 로 보낸다 — 그게 없으면 "이게 왜 올라왔지"부터 시작한다. params.upload_reason 에 사람이 올린 것임을 남겨 자동 업로드분과 섞이지 않게 했다. 마커는 남기지 않는다. 같은 정렬을 다시 보낼 수 있어야 한다 — 처음에 못 적은 증상을 memo 에 적어 다시 보내는 흐름이 실제로 생긴다. labdb 는 같은 testId 면 갱신한다. 판정이 끝나기 전에도 보낼 수 있다. 첫 위치만 재고 이상하다 싶은 때가 오히려 물어볼 상황이다 — 요약이 없으면 파형만 보낸다. Co-Authored-By: Claude Opus 5 --- .../services/labdb/AlignLabdbPayload.kt | 164 ++++++++++++++++++ .../services/labdb/HospitalLabdbUploader.kt | 23 +++ .../ui/views/clinical/AnchorAlignView.kt | 83 +++++++++ .../services/labdb/AlignLabdbPayloadTest.kt | 137 +++++++++++++++ 4 files changed, 407 insertions(+) create mode 100644 app/src/main/java/com/medithings/vesiscan/services/labdb/AlignLabdbPayload.kt create mode 100644 app/src/test/java/com/medithings/vesiscan/services/labdb/AlignLabdbPayloadTest.kt diff --git a/app/src/main/java/com/medithings/vesiscan/services/labdb/AlignLabdbPayload.kt b/app/src/main/java/com/medithings/vesiscan/services/labdb/AlignLabdbPayload.kt new file mode 100644 index 0000000..cdea850 --- /dev/null +++ b/app/src/main/java/com/medithings/vesiscan/services/labdb/AlignLabdbPayload.kt @@ -0,0 +1,164 @@ +/* + * AlignLabdbPayload — 부착 위치 정렬 원시 데이터 → 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 + +/** + * 정렬 한 세션(위치 여러 개)을 labdb 세션 하나로 만든다. + * + * ## 왜 새 dataType 인가 + * 임상 측정(`901`)과 구조가 다르다. 저쪽은 **한 조건에서 20 반복**이고, 정렬은 + * **여러 위치 × 20 cycle** 이다. 같은 dataType 에 넣으면 `params` 만으로는 구분이 + * 안 되고, 관리자 콘솔이 두 종류를 한 차트로 그리게 된다. + * + * labdb 는 dataType 마다 `records[]` 스키마가 완전히 달라도 된다(`labdb.md` §2-3). + * 900~999 가 사용자 정의 구간이라 여기에 [DATA_TYPE] 을 쓴다. + * + * ## 언제 올리나 — 자동이 아니다 + * 간호사가 파형을 보고 **이상하다고 판단했을 때** 버튼으로만 올린다. 목적이 개발자 + * 피드백이라, 정상인 것까지 다 올리면 정작 봐야 할 것이 묻힌다. + * + * ## 무엇을 담나 + * 위치별 raw cycle 전부 + 판정 근거(`align_result.json`)를 함께 넣는다. 개발자가 + * 봐야 할 질문이 "왜 이 위치를 골랐나"라서, 파형만 있으면 답이 안 나온다. + * + * ``` + * record = 한 위치의 한 cycle + * rowIndex : 0..N 연속 (labdb 규약) + * align_cm : 그 cycle 을 잰 위치 + * cycle_idx : 그 위치 안에서의 순번 + * channels : 6 × {ch, peak, peakIdx, data[~100]} + * ``` + */ +object AlignLabdbPayload { + + /** + * 정렬 전용 코드. 사용자 정의 구간(900~999). + * + * 임상 측정은 `901`([HospitalLabdbPayload.DATA_TYPE]). 관리자가 100~899 를 + * 배정해 주면 그 값으로 바꾼다 — 그때 이 KDoc 의 스키마를 그대로 등록 요청에 쓴다. + */ + const val DATA_TYPE = "902" + + private const val PROTOCOL = "hospital_align_2026" + + /** `align_3cm.csv` 에서 3 을 뽑는다. */ + private val NAME_RE = Regex("""^align_(-?\d+)cm\.csv$""", RegexOption.IGNORE_CASE) + + private val isoOut: SimpleDateFormat + get() = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSXXX", Locale.US) + + /** + * @param alignDir `HospitalRunStore.alignDir` 가 만든 폴더. `align_*cm.csv` 와 + * `align_result.json` 이 들어 있다. + * @param memo 간호사가 남기는 한 줄. 무엇이 이상해 보였는지가 개발자에게 제일 중요하다. + * @return 올릴 페이로드. 읽을 cycle 이 없으면 null. + */ + fun build(alignDir: File, memo: String = "", dataType: String = DATA_TYPE): JSONObject? { + val files = alignDir.listFiles() + ?.filter { it.isFile && NAME_RE.matches(it.name) } + ?.sortedBy { NAME_RE.find(it.name)!!.groupValues[1].toInt() } + ?: return null + if (files.isEmpty()) return null + + val summary = File(alignDir, "align_result.json") + .takeIf { it.exists() } + ?.let { runCatching { JSONObject(it.readText()) }.getOrNull() } + + val records = JSONArray() + var row = 0 + for (f in files) { + val cm = NAME_RE.find(f.name)!!.groupValues[1].toInt() + // `cycleIdx,ch,s0,s1,...` — 헤더 없음(HospitalRunStore.writeAlignCycles). + val byCycle = linkedMapOf>() + runCatching { f.readLines(Charsets.UTF_8) }.getOrNull()?.forEach { line -> + if (line.isBlank()) return@forEach + val p = line.split(',') + if (p.size < 3) return@forEach + val ci = p[0].trim().toIntOrNull() ?: return@forEach + val ch = p[1].trim().toIntOrNull() ?: return@forEach + val data = IntArray(p.size - 2) { p[it + 2].trim().toIntOrNull() ?: 0 } + if (data.isEmpty()) return@forEach + byCycle.getOrPut(ci) { linkedMapOf() }[ch] = data + } + for (ci in byCycle.keys.sorted()) { + val chans = JSONArray() + for (ch in byCycle.getValue(ci).keys.sorted()) { + val d = byCycle.getValue(ci).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", row++) + put("align_cm", cm) + put("cycle_idx", ci) + put("commandType", "MTB") + put("channels", chans) + }) + } + } + if (records.length() == 0) return null + + val subject = summary?.optString("patient").orEmpty() + val saveName = summary?.optString("save_name").orEmpty() + .ifBlank { alignDir.parentFile?.name.orEmpty() } + val base = (saveName.ifBlank { "align" }) + "_align" + + val params = JSONObject().apply { + put("protocol", PROTOCOL) + put("subject", subject) + put("save_name", saveName) + put("positions_measured", files.size) + put("cycles_total", records.length()) + // 판정 근거를 통째로 넣는다. 개발자가 답해야 할 질문이 "왜 이 위치인가"라, + // 지표(nch·ch3·cap_frac)와 선택 결과가 파형과 같이 있어야 한다. + summary?.let { s -> + for (k in listOf( + "device", "firmware_version", "hw_preset", "freq_mhz", "freq_option", + "cycles_per_position", "probe_cycles", "avg", "delay_us", "samples", + "ch3_hit_min", "final_offset_cm", "best_cm", "anchor_cm", "action", + "stop_reason", "max_nch", "positions", "lateral", + )) if (s.has(k)) put(k, s.get(k)) + } + // 자동이 아니라 사람이 올린 것임을 데이터에 남긴다 — 정상 세션과 섞이면 + // "왜 이것만 올라와 있나"를 나중에 설명할 수 없다. + put("upload_reason", "nurse_review") + } + + return JSONObject().apply { + put("testId", testId(base)) + put("dataType", dataType) + put("sessionName", base) + if (memo.isNotBlank()) put("memo", memo) + put("savedAt", isoOut.format(Date())) + put("params", params) + put("recordCount", records.length()) + put("records", records) + } + } + + /** `^[A-Za-z0-9_-]{1,40}$`. 넘치면 앞 31자 + SHA-1 앞 8자 — 규칙은 임상 쪽과 같다. */ + fun testId(base: String): String { + val safe = base.replace(Regex("[^A-Za-z0-9_-]"), "_") + if (safe.length <= 40) return safe.ifEmpty { "align" } + val sha = MessageDigest.getInstance("SHA-1") + .digest(base.toByteArray(Charsets.UTF_8)) + .joinToString("") { "%02x".format(it) } + .take(8) + return safe.take(31) + "_" + sha + } +} 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 index 4f4af73..f787924 100644 --- a/app/src/main/java/com/medithings/vesiscan/services/labdb/HospitalLabdbUploader.kt +++ b/app/src/main/java/com/medithings/vesiscan/services/labdb/HospitalLabdbUploader.kt @@ -118,6 +118,29 @@ object HospitalLabdbUploader { return false to msg } + /** + * 정렬 세션을 올린다 — **간호사가 버튼을 눌렀을 때만**. + * + * 임상 측정과 달리 자동이 아니다. 목적이 "파형이 이상해 보이니 봐 달라"는 개발자 + * 피드백 요청이라, 정상인 것까지 올리면 정작 봐야 할 것이 묻힌다. + * + * 마커도 남기지 않는다. 같은 정렬을 다시 올릴 수 있어야 한다 — 처음에 못 적은 + * 증상을 memo 에 적어 다시 보내는 흐름이 실제로 생긴다. labdb 는 같은 testId 면 + * 갱신하므로 중복 세션이 쌓이지 않는다. + */ + suspend fun uploadAlign(alignDir: File, memo: String = ""): Pair { + if (!LabdbCredentials.isRegistered) return false to "labdb 미등록 — 설정에서 등록하세요." + val payload = AlignLabdbPayload.build(alignDir, memo) + ?: return false to "정렬 원시 데이터가 없습니다." + return try { + val r = LabdbClient.uploadJson(payload) + true to "업로드 ${r.inserted}/${r.totalRecords} (중복 ${r.duplicates})" + } catch (e: Exception) { + Log.w(TAG, "align upload failed", e) + false to (e.message ?: e.javaClass.simpleName) + } + } + /** * 폴더의 미업로드 CSV 를 순서대로 올린다. 이미 돌고 있으면 아무것도 하지 않는다. * 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 fd1a49f..77c7cfa 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 @@ -55,6 +55,8 @@ import com.medithings.vesiscan.managers.DetachWatcher import com.medithings.vesiscan.managers.LateralGuide import com.medithings.vesiscan.models.HospitalFixedParams import com.medithings.vesiscan.services.HospitalRunStore +import com.medithings.vesiscan.services.labdb.HospitalLabdbUploader +import com.medithings.vesiscan.services.labdb.LabdbCredentials import org.json.JSONArray import org.json.JSONObject import java.text.SimpleDateFormat @@ -64,6 +66,7 @@ import com.medithings.vesiscan.models.ProbeCycle import com.medithings.vesiscan.models.ProbeFrequency import com.medithings.vesiscan.ui.theme.* import kotlinx.coroutines.delay +import kotlinx.coroutines.launch import kotlinx.coroutines.withTimeoutOrNull /** 정렬 측정 조건 — 병원 모드의 조합 순회와 무관하게 고정. */ @@ -104,6 +107,17 @@ fun AnchorAlignView(appState: AppState) { var liveChannels by remember { mutableStateOf>(emptyList()) } + // ── labdb 즉시 업로드 ──────────────────────────────────────────────────── + // 간호사가 파형을 보고 이상하다고 판단하면 그 자리에서 올린다. 개발자가 바로 + // 보고 답을 주는 흐름이라, 임상이 끝난 뒤가 아니라 **지금** 올라가야 한다. + val uploadScope = rememberCoroutineScope() + var uploadBusy by remember { mutableStateOf(false) } + var uploadMsg by remember { mutableStateOf(null) } + var uploadMemo by remember { mutableStateOf("") } + var showUpload by remember { mutableStateOf(false) } + /** 위치를 한 번이라도 저장했는가 — 올릴 것이 있는지 판단. */ + var hasAlignData by remember { mutableStateOf(false) } + // ── 채널 접촉 ──────────────────────────────────────────────────────────── // 정렬은 "어디에 붙일지"를 정하는 단계라, 떠 있는 채널이 있으면 그 위치의 지표 // (nch·ch3·cap_frac) 자체가 의미를 잃는다. 위치를 고르기 **전에** 알아야 한다. @@ -213,6 +227,7 @@ fun AnchorAlignView(appState: AppState) { // 급할 때 이름 없이 재고 나면 20 cycle × 위치 수를 통째로 잃는다. 임상에서 // 제일 아까운 것이 이미 받은 데이터라, 이름은 나중에 폴더명으로 맞추면 된다. val dir = HospitalRunStore.alignDir(saveName, runStartedAt) + hasAlignData = true if (!HospitalRunStore.writeAlignCycles(dir, cm, cycles)) { error = "정렬 원시 데이터 저장 실패 (${cm}cm) — 저장 공간을 확인하세요." } @@ -310,6 +325,53 @@ fun AnchorAlignView(appState: AppState) { } } + // 무엇이 이상해 보였는지 한 줄 받는다. 개발자에게 제일 중요한 정보인데, + // 파형만 오면 "이게 왜 올라왔지"부터 되묻게 된다. + if (showUpload) { + AlertDialog( + onDismissRequest = { if (!uploadBusy) showUpload = false }, + title = { Text("정렬 데이터 보내기", fontWeight = FontWeight.Bold) }, + text = { + Column { + Text( + "지금까지 잰 위치의 원시 파형과 판정 근거를 labdb 로 보냅니다. " + + "개발자가 보고 회신합니다.", + fontSize = 13.sp, color = MlSecondaryText, + ) + Spacer(Modifier.height(12.dp)) + OutlinedTextField( + value = uploadMemo, + onValueChange = { uploadMemo = it }, + label = { Text("무엇이 이상해 보이나요?") }, + placeholder = { Text("예: CH2 만 진폭이 절반") }, + enabled = !uploadBusy, + modifier = Modifier.fillMaxWidth(), + ) + } + }, + confirmButton = { + Button( + onClick = { + uploadBusy = true + uploadScope.launch { + val dir = HospitalRunStore.alignDir(saveName, runStartedAt) + val (ok, msg) = HospitalLabdbUploader.uploadAlign(dir, uploadMemo) + uploadMsg = if (ok) "보냈습니다 — $msg" else "실패 — $msg" + uploadBusy = false + if (ok) { showUpload = false; uploadMemo = "" } + } + }, + enabled = !uploadBusy, + ) { Text(if (uploadBusy) "보내는 중…" else "보내기") } + }, + dismissButton = { + TextButton(onClick = { showUpload = false }, enabled = !uploadBusy) { + Text("취소") + } + }, + ) + } + Column( Modifier.fillMaxSize().background(MlBackground).verticalScroll(rememberScrollState()) ) { @@ -454,6 +516,27 @@ fun AnchorAlignView(appState: AppState) { Spacer(Modifier.height(16.dp)) } + // ── 파형이 이상할 때 ─────────────────────────────────────────── + // 판단은 사람이 한다. 이 화면에서 파형을 보는 사람이 유일하게 환자와 프로브를 + // 같이 보고 있는 사람이라, 앱이 지표로 거르는 것보다 정확하다. + if (hasAlignData && LabdbCredentials.isRegistered) { + TextButton( + onClick = { showUpload = true }, + enabled = !measuring && !lrRunning && !uploadBusy, + modifier = Modifier.fillMaxWidth(), + ) { + Text( + if (uploadBusy) "업로드 중…" else "파형이 이상합니다 — labdb 로 보내기", + color = MlPrimary, fontWeight = FontWeight.SemiBold, + ) + } + uploadMsg?.let { + Text(it, fontSize = 12.sp, color = MlSecondaryText, + modifier = Modifier.fillMaxWidth()) + } + Spacer(Modifier.height(12.dp)) + } + if (liveChannels.isNotEmpty()) { Text("채널 파형", fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = MlSecondaryText) diff --git a/app/src/test/java/com/medithings/vesiscan/services/labdb/AlignLabdbPayloadTest.kt b/app/src/test/java/com/medithings/vesiscan/services/labdb/AlignLabdbPayloadTest.kt new file mode 100644 index 0000000..2400471 --- /dev/null +++ b/app/src/test/java/com/medithings/vesiscan/services/labdb/AlignLabdbPayloadTest.kt @@ -0,0 +1,137 @@ +package com.medithings.vesiscan.services.labdb + +import org.json.JSONObject +import org.junit.Assert.assertEquals +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 + +/** + * 정렬 원시 데이터 → labdb 페이로드 (dataType 902). + * + * 이 세션은 **사람이 "이상하다"고 판단해서** 올린 것이라, 개발자가 받았을 때 + * "왜 올라왔는지"와 "왜 이 위치를 골랐는지"가 데이터 안에서 답이 나와야 한다. + * 파형만 있으면 되묻게 되고, 그 왕복이 임상 현장에서는 하루씩 걸린다. + */ +class AlignLabdbPayloadTest { + + @get:Rule val tmp = TemporaryFolder() + + /** `cycleIdx,ch,s0,s1,...` — HospitalRunStore.writeAlignCycles 형식(헤더 없음). */ + private fun writeCycles(dir: File, cm: Int, cycles: Int, chans: Int = 6, len: Int = 100) { + val sb = StringBuilder() + for (ci in 0 until cycles) for (ch in 0 until chans) { + sb.append(ci).append(',').append(ch) + for (i in 0 until len) sb.append(',').append(if (i == 30 + ch) 3500 else 900) + sb.append('\n') + } + File(dir, "align_${cm}cm.csv").writeText(sb.toString(), Charsets.UTF_8) + } + + private fun summary(): JSONObject = JSONObject().apply { + put("patient", "P001") + put("save_name", "260908_P001") + put("device", "VBT26080001") + put("firmware_version", "0.1.21") + put("best_cm", 2) + put("anchor_cm", 3) + put("action", "STOP") + put("max_nch", 4) + put("positions", org.json.JSONArray().put(JSONObject().apply { + put("align_cm", 0); put("nch", 3) + })) + put("lateral", JSONObject().apply { put("done", true); put("imbalance", 5) }) + } + + @Test fun `위치별 cycle 이 record 가 된다`() { + val d = tmp.newFolder("align") + writeCycles(d, 0, cycles = 2) + writeCycles(d, 1, cycles = 3) + val p = AlignLabdbPayload.build(d)!! + + assertEquals(5, p.getInt("recordCount")) + val recs = p.getJSONArray("records") + // rowIndex 는 위치를 가로질러 연속이어야 한다(labdb 규약: 0..N). + assertEquals((0 until 5).toList(), (0 until 5).map { recs.getJSONObject(it).getInt("rowIndex") }) + // 위치는 낮은 cm 부터. 파일명 정렬이 아니라 숫자 정렬이어야 10cm 가 2cm 뒤로 간다. + assertEquals(listOf(0, 0, 1, 1, 1), (0 until 5).map { recs.getJSONObject(it).getInt("align_cm") }) + assertEquals(listOf(0, 1, 0, 1, 2), (0 until 5).map { recs.getJSONObject(it).getInt("cycle_idx") }) + } + + @Test fun `채널 6개와 peak 이 담긴다`() { + val d = tmp.newFolder("align") + writeCycles(d, 0, cycles = 1) + val rec = AlignLabdbPayload.build(d)!!.getJSONArray("records").getJSONObject(0) + val chans = rec.getJSONArray("channels") + assertEquals(6, chans.length()) + for (i in 0 until chans.length()) { + val c = chans.getJSONObject(i) + assertEquals(3500, c.getInt("peak")) + assertEquals(30 + c.getInt("ch"), c.getInt("peakIdx")) + assertEquals(100, c.getJSONArray("data").length()) + } + } + + @Test fun `판정 근거가 params 에 함께 간다`() { + // 개발자가 답해야 할 질문이 "왜 이 위치인가"다. 지표와 선택 결과가 파형과 같이 + // 있어야 답할 수 있다. + val d = tmp.newFolder("align") + writeCycles(d, 0, cycles = 1) + File(d, "align_result.json").writeText(summary().toString(), Charsets.UTF_8) + + val params = AlignLabdbPayload.build(d)!!.getJSONObject("params") + assertEquals("hospital_align_2026", params.getString("protocol")) + assertEquals("P001", params.getString("subject")) + assertEquals(2, params.getInt("best_cm")) + assertEquals(3, params.getInt("anchor_cm")) + assertEquals("STOP", params.getString("action")) + assertTrue(params.has("positions")) + assertTrue(params.has("lateral")) + // 사람이 올린 것임을 남긴다 — 자동 업로드분과 섞이면 나중에 설명이 안 된다. + assertEquals("nurse_review", params.getString("upload_reason")) + } + + @Test fun `메모가 들어간다`() { + val d = tmp.newFolder("align") + writeCycles(d, 0, cycles = 1) + val p = AlignLabdbPayload.build(d, memo = "CH2 만 진폭 절반")!! + assertEquals("CH2 만 진폭 절반", p.getString("memo")) + // 비우면 필드를 넣지 않는다 — 빈 문자열이 들어가면 "적었는데 비었다"로 읽힌다. + assertTrue(!AlignLabdbPayload.build(d)!!.has("memo")) + } + + @Test fun `임상과 다른 dataType 을 쓴다`() { + // 구조가 다른 데이터를 같은 코드로 올리면 콘솔이 한 차트로 그린다. + val d = tmp.newFolder("align") + writeCycles(d, 0, cycles = 1) + assertEquals("902", AlignLabdbPayload.build(d)!!.getString("dataType")) + assertTrue(AlignLabdbPayload.DATA_TYPE != HospitalLabdbPayload.DATA_TYPE) + } + + @Test fun `요약이 없어도 파형은 올라간다`() { + // 판정 전에 앱이 죽었거나 첫 위치만 잰 상태에서도 보낼 수 있어야 한다 — + // 오히려 그런 때가 개발자에게 물어볼 상황이다. + val d = tmp.newFolder("align") + writeCycles(d, 0, cycles = 1) + val p = AlignLabdbPayload.build(d)!! + assertEquals(1, p.getInt("recordCount")) + assertEquals("", p.getJSONObject("params").getString("subject")) + } + + @Test fun `올릴 것이 없으면 null`() { + assertNull(AlignLabdbPayload.build(tmp.newFolder("empty"))) + val d = tmp.newFolder("only-summary") + File(d, "align_result.json").writeText(summary().toString(), Charsets.UTF_8) + assertNull(AlignLabdbPayload.build(d)) + } + + @Test fun `testId 는 40자 제한을 지킨다`() { + val long = AlignLabdbPayload.testId("260908_P001_아주긴환자폴더이름_align_extra_long_name") + assertTrue(long.length <= 40) + assertTrue(Regex("^[A-Za-z0-9_-]{1,40}$").matches(long)) + assertEquals("260908_P001_align", AlignLabdbPayload.testId("260908_P001_align")) + } +}