feat(clinical): 정렬 파형을 간호사 판단으로 labdb 에 보낸다

파형이 이상해 보일 때 그 자리에서 개발자에게 보내는 통로가 없었다. 임상이 끝난 뒤
파일을 꺼내 보내면 회신이 하루 뒤인데, 그때는 이미 그 환자로 다시 잴 수 없다.

정렬 전용 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 <noreply@anthropic.com>
This commit is contained in:
2026-09-08 13:57:11 +09:00
parent 8bf7f54b72
commit 3d8168a0f5
4 changed files with 407 additions and 0 deletions
@@ -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<Int, MutableMap<Int, IntArray>>()
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
}
}
@@ -118,6 +118,29 @@ object HospitalLabdbUploader {
return false to msg return false to msg
} }
/**
* 정렬 세션을 올린다 — **간호사가 버튼을 눌렀을 때만**.
*
* 임상 측정과 달리 자동이 아니다. 목적이 "파형이 이상해 보이니 봐 달라"는 개발자
* 피드백 요청이라, 정상인 것까지 올리면 정작 봐야 할 것이 묻힌다.
*
* 마커도 남기지 않는다. 같은 정렬을 다시 올릴 수 있어야 한다 — 처음에 못 적은
* 증상을 memo 에 적어 다시 보내는 흐름이 실제로 생긴다. labdb 는 같은 testId 면
* 갱신하므로 중복 세션이 쌓이지 않는다.
*/
suspend fun uploadAlign(alignDir: File, memo: String = ""): Pair<Boolean, String> {
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 를 순서대로 올린다. 이미 돌고 있으면 아무것도 하지 않는다. * 폴더의 미업로드 CSV 를 순서대로 올린다. 이미 돌고 있으면 아무것도 하지 않는다.
* *
@@ -55,6 +55,8 @@ import com.medithings.vesiscan.managers.DetachWatcher
import com.medithings.vesiscan.managers.LateralGuide import com.medithings.vesiscan.managers.LateralGuide
import com.medithings.vesiscan.models.HospitalFixedParams import com.medithings.vesiscan.models.HospitalFixedParams
import com.medithings.vesiscan.services.HospitalRunStore 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.JSONArray
import org.json.JSONObject import org.json.JSONObject
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
@@ -64,6 +66,7 @@ import com.medithings.vesiscan.models.ProbeCycle
import com.medithings.vesiscan.models.ProbeFrequency import com.medithings.vesiscan.models.ProbeFrequency
import com.medithings.vesiscan.ui.theme.* import com.medithings.vesiscan.ui.theme.*
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull import kotlinx.coroutines.withTimeoutOrNull
/** 정렬 측정 조건 — 병원 모드의 조합 순회와 무관하게 고정. */ /** 정렬 측정 조건 — 병원 모드의 조합 순회와 무관하게 고정. */
@@ -104,6 +107,17 @@ fun AnchorAlignView(appState: AppState) {
var liveChannels by remember { var liveChannels by remember {
mutableStateOf<List<com.medithings.vesiscan.ble.PiezoChannelData>>(emptyList()) mutableStateOf<List<com.medithings.vesiscan.ble.PiezoChannelData>>(emptyList())
} }
// ── labdb 즉시 업로드 ────────────────────────────────────────────────────
// 간호사가 파형을 보고 이상하다고 판단하면 그 자리에서 올린다. 개발자가 바로
// 보고 답을 주는 흐름이라, 임상이 끝난 뒤가 아니라 **지금** 올라가야 한다.
val uploadScope = rememberCoroutineScope()
var uploadBusy by remember { mutableStateOf(false) }
var uploadMsg by remember { mutableStateOf<String?>(null) }
var uploadMemo by remember { mutableStateOf("") }
var showUpload by remember { mutableStateOf(false) }
/** 위치를 한 번이라도 저장했는가 — 올릴 것이 있는지 판단. */
var hasAlignData by remember { mutableStateOf(false) }
// ── 채널 접촉 ──────────────────────────────────────────────────────────── // ── 채널 접촉 ────────────────────────────────────────────────────────────
// 정렬은 "어디에 붙일지"를 정하는 단계라, 떠 있는 채널이 있으면 그 위치의 지표 // 정렬은 "어디에 붙일지"를 정하는 단계라, 떠 있는 채널이 있으면 그 위치의 지표
// (nch·ch3·cap_frac) 자체가 의미를 잃는다. 위치를 고르기 **전에** 알아야 한다. // (nch·ch3·cap_frac) 자체가 의미를 잃는다. 위치를 고르기 **전에** 알아야 한다.
@@ -213,6 +227,7 @@ fun AnchorAlignView(appState: AppState) {
// 급할 때 이름 없이 재고 나면 20 cycle × 위치 수를 통째로 잃는다. 임상에서 // 급할 때 이름 없이 재고 나면 20 cycle × 위치 수를 통째로 잃는다. 임상에서
// 제일 아까운 것이 이미 받은 데이터라, 이름은 나중에 폴더명으로 맞추면 된다. // 제일 아까운 것이 이미 받은 데이터라, 이름은 나중에 폴더명으로 맞추면 된다.
val dir = HospitalRunStore.alignDir(saveName, runStartedAt) val dir = HospitalRunStore.alignDir(saveName, runStartedAt)
hasAlignData = true
if (!HospitalRunStore.writeAlignCycles(dir, cm, cycles)) { if (!HospitalRunStore.writeAlignCycles(dir, cm, cycles)) {
error = "정렬 원시 데이터 저장 실패 (${cm}cm) — 저장 공간을 확인하세요." 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( Column(
Modifier.fillMaxSize().background(MlBackground).verticalScroll(rememberScrollState()) Modifier.fillMaxSize().background(MlBackground).verticalScroll(rememberScrollState())
) { ) {
@@ -454,6 +516,27 @@ fun AnchorAlignView(appState: AppState) {
Spacer(Modifier.height(16.dp)) 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()) { if (liveChannels.isNotEmpty()) {
Text("채널 파형", fontSize = 13.sp, fontWeight = FontWeight.SemiBold, Text("채널 파형", fontSize = 13.sp, fontWeight = FontWeight.SemiBold,
color = MlSecondaryText) color = MlSecondaryText)
@@ -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"))
}
}