diff --git a/app/src/main/java/com/medithings/vesiscan/ui/views/clinical/ImuSidecar.kt b/app/src/main/java/com/medithings/vesiscan/ui/views/clinical/ImuSidecar.kt index 6ee6ce5..d9b8385 100644 --- a/app/src/main/java/com/medithings/vesiscan/ui/views/clinical/ImuSidecar.kt +++ b/app/src/main/java/com/medithings/vesiscan/ui/views/clinical/ImuSidecar.kt @@ -19,16 +19,24 @@ * 측정을 실패로 돌리면 기존에 되던 일이 안 되게 된다. [await] 는 null 을 돌려주고 * 호출부는 IMU 없이 저장한다. * - * ## 2026-09-28 진단 로그 · 방어적 restore - * A34 실기에서 0cm 정렬 20회가 `rim:` 을 전부 받았는데(BLE 로그 · parseRim 로그 확인) - * `align_0cm_imu.csv` 가 안 생겼다. 콜백 사슬 어디서 끊기는지 보려고 단계마다 로그를 - * 남긴다(태그 `ImuSidecar`). 아울러 [restore] 는 **지금 걸린 콜백이 자기 것일 때만** - * 되돌린다 — 옛 인스턴스의 restore 가 새 인스턴스의 install 을 덮는 순서가 가능하다. + * ## 겹쳐 걸리는 순서 (2026-09-28) + * 한 화면에 사이드카가 둘이다 — 쉬는 동안의 기울기 폴링([pollLiveTilt])과 측정 루프. + * 폴링 효과가 취소되고 루프가 시작될 때 Compose 는 **취소된 쪽의 restore 를 새 쪽의 + * install 뒤에** 돌리기도 한다(A34 실기 로그로 확인). 단순 "되돌리기"면 새 사이드카를 + * 옛 콜백으로 덮어 루프가 IMU 를 못 받는다. + * + * 그래서 콜백을 **사슬**로 다룬다. install 은 지금 것 위에 올라타고([Hook] 이 주인을 + * 안다), restore 는 자기가 맨 위면 내려오고, 아니면 위쪽 사슬에서 **자기만 빼고** 자기 + * 이전을 물려준다. 어느 순서로 겹쳐도 맨 위는 살아 있는 쪽이고, 마지막이 내려올 때 + * 사이드카 이전의 원래 콜백이 되돌아간다. + * + * 단계마다 로그를 남긴다(태그 `ImuSidecar`) — 콜백 사슬 문제는 로그 없이는 못 본다. */ package com.medithings.vesiscan.ui.views.clinical import android.util.Log import com.medithings.vesiscan.ble.BleManager +import com.medithings.vesiscan.ble.ImuPacketCollector import com.medithings.vesiscan.ble.ImuSample import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.withTimeoutOrNull @@ -36,31 +44,37 @@ import kotlinx.coroutines.withTimeoutOrNull /** piezo 를 받은 뒤 IMU 를 기다리는 시간. 같은 응답의 뒷부분이라 길 필요가 없다. */ const val IMU_GRACE_MS = 700L -class ImuSidecar(private val ble: BleManager) { +class ImuSidecar(private val collector: ImuPacketCollector) { + + constructor(ble: BleManager) : this(ble.imuCollector) + + /** + * 우리가 건 콜백. 람다가 아니라 클래스인 이유: 다른 사이드카가 "이건 사이드카 것이고 + * 주인은 누구"를 알아봐야 사슬을 고칠 수 있다. + */ + private class Hook(val owner: ImuSidecar) : (List) -> Unit { + override fun invoke(samples: List) = owner.receive(samples) + } private val inbox = Channel>(Channel.CONFLATED) + private val mine: Hook = Hook(this) + /** install 당시 걸려 있던 콜백. 위쪽 사이드카가 restore 로 나를 빼면 여기가 바뀐다. */ private var previous: ((List) -> Unit)? = null private var installed = false private val id = Integer.toHexString(System.identityHashCode(this)) - /** 우리가 건 콜백. restore 때 "아직 우리 것인가"를 참조로 비교한다. */ - private val mine: (List) -> Unit = { samples -> + private fun receive(samples: List) { val r = inbox.trySend(samples) Log.d(TAG, "[$id] onComplete <- ${samples.size} samples · trySend ok=${r.isSuccess} closed=${r.isClosed}") } - /** 콜백을 가로챈다. 원래 콜백은 [restore] 에서 되돌린다. */ + /** 지금 걸린 콜백 위에 올라탄다. 원래 콜백은 [restore] 에서 되돌린다. */ fun install() { if (installed) return - previous = ble.imuCollector.onComplete - ble.imuCollector.onComplete = mine + previous = collector.onComplete + collector.onComplete = mine installed = true - Log.d( - TAG, - "[$id] install · previous=${previous?.javaClass?.name ?: "null"}" + - " · ble=${Integer.toHexString(System.identityHashCode(ble))}" + - " · collector=${Integer.toHexString(System.identityHashCode(ble.imuCollector))}", - ) + Log.d(TAG, "[$id] install · previous=${describe(previous)}") } /** 직전 회차에서 늦게 도착한 것을 버린다. piezo 쪽 비우기와 같은 자리에서 부른다. */ @@ -70,29 +84,54 @@ class ImuSidecar(private val ble: BleManager) { if (n > 0) Log.d(TAG, "[$id] drain · dropped=$n") } - /** piezo 를 받은 뒤 부른다. 시간 안에 안 오면 null — 호출부는 IMU 없이 진행한다. */ + /** + * piezo 를 받은 뒤 부른다. 시간 안에 안 오면 null — 호출부는 IMU 없이 진행한다. + * [restore] 뒤에 불려도 null 이다(닫힌 채널을 던지게 두지 않는다). + */ suspend fun await(graceMs: Long = IMU_GRACE_MS): List? { - val hookIsMine = ble.imuCollector.onComplete === mine - val got = withTimeoutOrNull(graceMs) { inbox.receive() } + val hookIsMine = collector.onComplete === mine + val got = withTimeoutOrNull(graceMs) { inbox.receiveCatching().getOrNull() } Log.d(TAG, "[$id] await -> ${got?.size?.toString() ?: "TIMEOUT"} · hookIsMine=$hookIsMine") return got } + /** + * 사슬에서 빠진다. 맨 위면 내려오고, 위에 다른 사이드카가 있으면 그쪽의 `previous` + * 에서 나를 건너뛰게 한다. 어느 쪽도 아니면(사이드카 아닌 것이 그 사이에 덮어썼다) + * 건드리지 않는다 — 그 사람 것을 되돌리면 안 된다. + */ fun restore() { if (!installed) return - val current = ble.imuCollector.onComplete - if (current === mine) { - ble.imuCollector.onComplete = previous - Log.d(TAG, "[$id] restore · ok") + val top = collector.onComplete + if (top === mine) { + collector.onComplete = previous + Log.d(TAG, "[$id] restore · popped · now=${describe(previous)}") } else { - // 우리 뒤에 누군가 다시 걸었다 — 그 사람 것을 되돌리면 안 된다. - Log.w(TAG, "[$id] restore · SKIPPED — hook is not mine (${current?.javaClass?.name ?: "null"})") + var h = top + var spliced = false + while (h is Hook) { + val above = h.owner + if (above.previous === mine) { + above.previous = previous + spliced = true + break + } + h = above.previous + } + if (spliced) Log.d(TAG, "[$id] restore · spliced out under ${describe(top)}") + else Log.w(TAG, "[$id] restore · SKIPPED — not in chain (top=${describe(top)})") } previous = null installed = false inbox.close() } + private fun describe(hook: ((List) -> Unit)?): String = when (hook) { + null -> "null" + is Hook -> "sidecar[${hook.owner.id}]" + else -> hook.javaClass.name + } + private companion object { const val TAG = "ImuSidecar" } diff --git a/app/src/test/java/com/medithings/vesiscan/ui/views/clinical/ImuSidecarTest.kt b/app/src/test/java/com/medithings/vesiscan/ui/views/clinical/ImuSidecarTest.kt new file mode 100644 index 0000000..fec88ca --- /dev/null +++ b/app/src/test/java/com/medithings/vesiscan/ui/views/clinical/ImuSidecarTest.kt @@ -0,0 +1,98 @@ +/* + * ImuSidecarTest — 사이드카가 겹쳐 걸려도 콜백 사슬이 맞는지 잠근다. + * + * 실기(A34, 2026-09-28)에서 확인된 순서: 쉬는 동안의 폴링 사이드카가 취소되기 **전에** + * 측정 루프의 사이드카가 install 됐다. 옛 코드는 이때 restore 를 건너뛰어(SKIPPED) + * 원래 콜백이 영영 안 돌아왔다. 여기서는 어느 순서로 겹쳐도 + * 1. 맨 위(살아 있는 쪽)가 샘플을 받고, + * 2. 마지막 사이드카가 내려올 때 원래 콜백이 되돌아오는지 + * 본다. + */ +package com.medithings.vesiscan.ui.views.clinical + +import com.medithings.vesiscan.ble.ImuPacketCollector +import com.medithings.vesiscan.ble.ImuSample +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test + +class ImuSidecarTest { + + private val collector = ImuPacketCollector() + private val sample = listOf(ImuSample(0f, 0f, 1f, 0f, 0f, 0f)) + + /** 사이드카 이전에 걸려 있던 "원래" 콜백 — 일반 측정 화면의 자세 판정 같은 것. */ + private val receivedByOriginal = mutableListOf>() + private val original: (List) -> Unit = { receivedByOriginal += it } + + @Test + fun `순서대로 걸고 풀면 원래 콜백이 돌아온다`() { + collector.onComplete = original + val a = ImuSidecar(collector) + a.install() + a.restore() + assertSame(original, collector.onComplete) + } + + @Test + fun `새 사이드카가 먼저 올라탄 뒤 옛것이 내려와도 원래 콜백이 돌아온다`() = runBlocking { + collector.onComplete = original + val old = ImuSidecar(collector) + old.install() + val new = ImuSidecar(collector) + new.install() // Compose 가 취소된 효과의 finally 보다 먼저 돌린 새 효과 + + old.restore() // 맨 위가 아니다 — 사슬에서 자기만 빠져야 한다 + collector.onComplete!!.invoke(sample) + assertEquals(sample, new.await(50)) // 살아 있는 쪽이 여전히 받는다 + assertTrue(receivedByOriginal.isEmpty()) + + new.restore() + assertSame(original, collector.onComplete) + } + + @Test + fun `셋이 겹쳐도 가운데가 먼저 빠지고 끝까지 풀면 원래 콜백이 돌아온다`() { + collector.onComplete = original + val a = ImuSidecar(collector); a.install() + val b = ImuSidecar(collector); b.install() + val c = ImuSidecar(collector); c.install() + + b.restore() // 가운데 + a.restore() // 맨 아래 + c.restore() // 맨 위 + assertSame(original, collector.onComplete) + } + + @Test + fun `사이드카 아닌 것이 위를 덮어썼으면 건드리지 않는다`() { + collector.onComplete = original + val a = ImuSidecar(collector) + a.install() + val someoneElse: (List) -> Unit = { } + collector.onComplete = someoneElse // 일반 화면이 들어와 자기 콜백을 걸었다 + a.restore() + assertSame(someoneElse, collector.onComplete) + } + + @Test + fun `원래 콜백이 없던 자리는 null 로 돌아간다`() { + val a = ImuSidecar(collector) + a.install() + a.restore() + assertNull(collector.onComplete) + } + + @Test + fun `풀린 뒤에는 샘플을 받지 않는다`() = runBlocking { + val a = ImuSidecar(collector) + a.install() + val hook = collector.onComplete!! + a.restore() + hook.invoke(sample) // 늦게 도착한 콜백 — 닫힌 채널에 버려진다 + assertNull(a.await(20)) + } +}