From 1c76cdc70885151bda6fa99e0061dc7ac74e0772 Mon Sep 17 00:00:00 2001 From: seuuung Date: Fri, 23 Oct 2026 09:37:56 +0900 Subject: [PATCH] Improve session lifecycle, protocol handling and recording controls --- .../app/background/ProgramExitSessionTest.kt | 94 ++++ .../dialog/WifiApConnectionUiTest.kt | 125 ++++-- .../components/DebugValueResetUiTest.kt | 26 ++ .../components/RecordingRibbonButtonUiTest.kt | 87 ++++ .../components/WifiConnectionRibbonUiTest.kt | 102 +++++ .../main/java/com/dct/app/DctApplication.kt | 11 +- .../dct/app/background/DctSessionRuntime.kt | 19 + .../MeasurementForegroundService.kt | 5 +- .../data/communication/WifiSocketManager.kt | 16 +- .../app/data/protocol/DctProtocolRequests.kt | 7 +- .../data/protocol/SlidingRingBufferParser.kt | 95 +++- .../data/repository/PacketRepositoryImpl.kt | 176 ++++++-- .../protocol/DctMeasurementConfiguration.kt | 3 +- .../dialog/WifiApConnectionDialog.kt | 49 +- .../dct/app/presentation/main/MainActivity.kt | 35 +- .../app/presentation/main/MainViewModel.kt | 202 +++++++-- .../maintenance/MaintenanceDialog.kt | 2 +- .../monitor/DiagnosticDataMonitorScreen.kt | 15 +- .../components/RecordingRibbonLabel.kt | 71 +++ .../monitor/components/WorkspaceRibbonBar.kt | 92 +++- .../maintenance/HeartbeatAndOtaParityTest.kt | 102 +++-- .../dct/app/tests/DctProtocolRequestsTest.kt | 18 +- .../tests/FormulaTelemetryRegressionTest.kt | 85 +++- .../app/tests/HeartbeatIdleRegressionTest.kt | 137 ++++++ .../com/dct/app/tests/MainViewModelTest.kt | 383 +++++++++++++++- .../app/tests/PacketRepositoryResponseTest.kt | 419 +++++++++++++++++- .../dct/app/tests/RecordingElapsedTimeTest.kt | 22 + .../app/tests/SlidingRingBufferParserTest.kt | 193 ++++++++ .../app/tests/WifiConnectionGenerationTest.kt | 27 ++ 29 files changed, 2408 insertions(+), 210 deletions(-) create mode 100644 Android/app/src/androidTest/java/com/dct/app/background/ProgramExitSessionTest.kt create mode 100644 Android/app/src/androidTest/java/com/dct/app/presentation/monitor/components/DebugValueResetUiTest.kt create mode 100644 Android/app/src/androidTest/java/com/dct/app/presentation/monitor/components/WifiConnectionRibbonUiTest.kt create mode 100644 Android/app/src/main/java/com/dct/app/presentation/monitor/components/RecordingRibbonLabel.kt create mode 100644 Android/app/src/test/java/com/dct/app/tests/HeartbeatIdleRegressionTest.kt create mode 100644 Android/app/src/test/java/com/dct/app/tests/RecordingElapsedTimeTest.kt diff --git a/Android/app/src/androidTest/java/com/dct/app/background/ProgramExitSessionTest.kt b/Android/app/src/androidTest/java/com/dct/app/background/ProgramExitSessionTest.kt new file mode 100644 index 0000000..e976422 --- /dev/null +++ b/Android/app/src/androidTest/java/com/dct/app/background/ProgramExitSessionTest.kt @@ -0,0 +1,94 @@ +package com.dct.app.background + +import androidx.lifecycle.Lifecycle +import androidx.test.core.app.ActivityScenario +import androidx.test.core.app.ApplicationProvider +import androidx.test.platform.app.InstrumentationRegistry +import com.dct.app.DctApplication +import com.dct.app.data.repository.DpcProjectManager +import com.dct.app.domain.model.chart.AxisPosition +import com.dct.app.domain.model.project.DpcProject +import com.dct.app.presentation.main.MainActivity +import com.dct.app.presentation.mdi.MdiWindowType +import org.junit.Assert.* +import org.junit.Test +import java.io.File + +/** No equipment connection: keep the Application/process alive across an explicit exit. */ +class ProgramExitSessionTest { + private val instrumentation = InstrumentationRegistry.getInstrumentation() + private val app = ApplicationProvider.getApplicationContext() + + @Test fun explicitExitSavesProjectAndNextLaunchHasEmptyWorkspace() { + val name = "exit-session-${System.nanoTime()}.dpc" + val candidates = savedFiles(name) + val first = ActivityScenario.launch(MainActivity::class.java) + val previous = app.session + try { + first.onActivity { activity -> + val vm = previous.viewModel + vm.setCurrentProject(DpcProject(projectFileName = name, vehicleCode = "Exit test")) + vm.mdiViewModel.addWindow(MdiWindowType.ALPHANUMERICAL) + vm.chartRenderer.bindSignal("can_1", "Selected signal", AxisPosition.LEFT_0, 0L) + activity.exitProgram() + } + await { first.state == Lifecycle.State.DESTROYED && previous.isClosed } + val file = candidates.first { it.isFile } + val saved = file.readBytes() + ActivityScenario.launch(MainActivity::class.java).use { second -> + second.onActivity { + val fresh = app.session + assertNotSame(previous, fresh) + assertNotSame(previous.viewModel, fresh.viewModel) + assertNull(fresh.viewModel.currentProject.value) + assertNull(fresh.viewModel.uiState.value.projectFileName) + assertTrue(fresh.viewModel.mdiViewModel.uiState.value.windows.isEmpty()) + assertTrue(fresh.viewModel.chartRenderer.seriesListFlow.value.isEmpty()) + assertArrayEquals(saved, file.readBytes()) + } + } + } finally { + first.close() + candidates.forEach { it.delete() } // Only this test's uniquely named project copies. + } + } + + @Test fun recreationAndBackgroundReturnKeepCurrentWorkspace() { + val name = "retained-session-${System.nanoTime()}.dpc" + val candidates = savedFiles(name) + try { + ActivityScenario.launch(MainActivity::class.java).use { scenario -> + val retained = app.session + scenario.onActivity { + retained.viewModel.setCurrentProject(DpcProject(projectFileName = name)) + retained.viewModel.mdiViewModel.addWindow(MdiWindowType.ALPHANUMERICAL) + } + scenario.recreate() + scenario.moveToState(Lifecycle.State.CREATED) + scenario.moveToState(Lifecycle.State.RESUMED) + scenario.onActivity { + assertSame(retained, app.session) + assertEquals(name, retained.viewModel.currentProject.value?.projectFileName) + assertFalse(retained.viewModel.mdiViewModel.uiState.value.windows.isEmpty()) + } + } + } finally { + candidates.forEach { it.delete() } + } + } + + private fun savedFiles(name: String) = listOfNotNull( + File(DpcProjectManager.getDefaultConfigurationFolder(app), name), + app.getExternalFilesDir(null)?.let { File(it, "Documents/DCT Work/Configuration/$name") }, + File(app.filesDir, "Documents/DCT Work/Configuration/$name") + ) + + private fun await(condition: () -> Boolean) { + val end = android.os.SystemClock.elapsedRealtime() + 10_000 + while (!condition() && android.os.SystemClock.elapsedRealtime() < end) { + instrumentation.waitForIdleSync() + Thread.sleep(25) + } + assertTrue("Session lifecycle did not finish", condition()) + } +} diff --git a/Android/app/src/androidTest/java/com/dct/app/presentation/dialog/WifiApConnectionUiTest.kt b/Android/app/src/androidTest/java/com/dct/app/presentation/dialog/WifiApConnectionUiTest.kt index 15ee5c6..ba37114 100644 --- a/Android/app/src/androidTest/java/com/dct/app/presentation/dialog/WifiApConnectionUiTest.kt +++ b/Android/app/src/androidTest/java/com/dct/app/presentation/dialog/WifiApConnectionUiTest.kt @@ -22,7 +22,6 @@ class WifiApConnectionUiTest { private var cancelled = 0 private var dismissed = false private var disconnected = 0 - private var retried = 0 private var current = 0 private var scanned = 0 private var settingsAction: String? = null @@ -30,6 +29,7 @@ class WifiApConnectionUiTest { private val endpointEditable = mutableStateOf(true) private val communicationReady = mutableStateOf(false) private val socketConnected = mutableStateOf(false) + private val communicationFailed = mutableStateOf(false) private val connectedEndpoint = mutableStateOf(null) private var savedEndpoint: WifiApEndpoint? = null @Test fun installedAppHasPermissionToRequestTheEquipmentNetwork() { @@ -41,13 +41,28 @@ class WifiApConnectionUiTest { compose.setContent { MaterialTheme { WifiApConnectionContent(state.value, communicationReady.value, socketConnected.value, onScan = { scanned++ }, onConnect = { ap, password -> connected = ap to password }, - onCancel = { cancelled++ }, onDisconnect = { disconnected++ }, onRetry = { retried++ }, + onCancel = { cancelled++ }, onDisconnect = { disconnected++ }, onCurrent = { current++ }, onPermissions = {}, onSettings = { settingsAction = it }, onDismiss = { dismissed = true }, endpoint = endpoint.value, connectedEndpoint = connectedEndpoint.value, - canEditEndpoint = endpointEditable.value, + canEditEndpoint = endpointEditable.value, communicationFailed = communicationFailed.value, onEndpointChange = { savedEndpoint = it; endpoint.value = it; true }) } } } + + @Test fun handshakeFailureDoesNotRemainInitializingOrExposePopupProtocolActions() { + state.value = state.value.copy(phase = WifiApPhase.CONNECTED, selectedSsid = "DCT_DEVICE", + localAddress = "192.168.4.2", activeNetworkKey = "test", connectedNetworks = listOf( + ConnectedWifiNetwork("test", "DCT_DEVICE", "192.168.4.2") + )) + socketConnected.value = true + show() + compose.onNodeWithText("장비 초기화 중…").assertExists() + compose.runOnIdle { communicationFailed.value = true; socketConnected.value = false } + compose.onNodeWithText("장비 초기화 중…").assertDoesNotExist() + compose.onNodeWithText("통신 연결 실패").assertExists() + compose.onNodeWithTag("wifi:connected-connect").assertDoesNotExist() + compose.runOnIdle { assertEquals(0, current) } + } @Test fun fixedEndpointAndPasswordFlowReplaceP2pAndManualTcpInputs() { show() compose.onNodeWithTag("wifi:ap-endpoint").assertDoesNotExist() @@ -110,7 +125,7 @@ class WifiApConnectionUiTest { compose.onNodeWithTag("wifi:ap-endpoint").assertDoesNotExist() } - @Test fun connectedRowOpensDetailsForRetryAndDisconnect() { + @Test fun connectedRowOpensDetailsForWifiDisconnectOnly() { state.value = state.value.copy(phase = WifiApPhase.CONNECTED, selectedSsid = device.ssid, localAddress = "192.168.4.20") show() @@ -118,9 +133,8 @@ class WifiApConnectionUiTest { compose.onNodeWithText("연결 해제").assertDoesNotExist() compose.onNodeWithTag("wifi:ap-current").performClick() compose.onNodeWithText("IP 주소: 192.168.4.20").assertIsDisplayed() - compose.onNodeWithText("통신 재연결").performClick() - compose.runOnIdle { assertEquals(1, retried); assertEquals(0, disconnected) } - compose.onNodeWithTag("wifi:ap-current").performClick() + compose.onNodeWithText("통신 재연결").assertDoesNotExist() + compose.runOnIdle { assertEquals(0, disconnected) } compose.onNodeWithText("연결 해제").performClick() compose.runOnIdle { assertEquals(1, disconnected) } } @@ -138,7 +152,7 @@ class WifiApConnectionUiTest { compose.onNodeWithTag("wifi:ap-password").assertDoesNotExist() compose.onNodeWithText("연결 해제").assertDoesNotExist() compose.onNodeWithText("확인").performClick() - compose.runOnIdle { assertEquals(0, current); assertEquals(0, retried); assertNull(connected) } + compose.runOnIdle { assertEquals(0, current); assertNull(connected) } } @Test fun managedNetworkIsShownOnlyOnceAndOtherConnectedWifiRemainsVisible() { @@ -150,17 +164,17 @@ class WifiApConnectionUiTest { compose.onNodeWithTag("wifi:ap-current").assertIsDisplayed() compose.onNodeWithTag("wifi:connected:101").assertDoesNotExist() compose.onNodeWithTag("wifi:connected:102").assertIsDisplayed() - compose.onNodeWithText("통신 연결 대기").assertIsDisplayed() + compose.onAllNodesWithText("Wi-Fi 연결됨").assertCountEquals(2) compose.onNodeWithTag("wifi:ap-current").performClick() - compose.onNodeWithText("통신 재연결").assertIsDisplayed() + compose.onNodeWithText("통신 재연결").assertDoesNotExist() } - @Test fun listFooterStartsCommunicationAndDetailsNoLongerContainsTheConnectButton() { + @Test fun listAndDetailsDoNotExposeProtocolConnectButtons() { endpoint.value = WifiApEndpoint("192.168.1.100", 6000) state.value = state.value.copy(connectedNetworks = listOf( ConnectedWifiNetwork("101", device.ssid, "192.168.1.20"))) show() - compose.onNodeWithTag("wifi:connected-connect").assertIsDisplayed().assertIsEnabled() + compose.onNodeWithTag("wifi:connected-connect").assertDoesNotExist() compose.onNodeWithTag("wifi:connected:101").performClick() compose.onNode(hasText("접속 설정: 192.168.1.100:6000") and hasAnyAncestor(hasTestTag("wifi:connected-details"))).assertIsDisplayed() @@ -169,21 +183,20 @@ class WifiApConnectionUiTest { compose.onNodeWithTag("wifi:ap-password").assertDoesNotExist() compose.runOnIdle { assertEquals(0, current); assertNull(connected) } compose.onNodeWithText("확인").performClick() - compose.onNodeWithTag("wifi:connected-connect").assertIsDisplayed().assertIsEnabled().performClick() - compose.onNodeWithTag("wifi:connected-connect").assertIsDisplayed() + compose.onNodeWithTag("wifi:connected-connect").assertDoesNotExist() compose.runOnIdle { - assertEquals(1, current); assertEquals(0, retried); assertEquals(0, disconnected) + assertEquals(0, current); assertEquals(0, disconnected) assertFalse(dismissed) assertNull(connected); assertEquals(WifiApEndpoint("192.168.1.100", 6000), endpoint.value) } } - @Test fun communicationShortcutIsDisabledForUnavailableAmbiguousOrBusyConnections() { + @Test fun popupDoesNotOfferCommunicationEvenWhenNetworkStateChanges() { val network = ConnectedWifiNetwork("101", device.ssid, "192.168.1.20") val base = state.value.copy(connectedNetworks = listOf(network)) state.value = base show() - compose.onNodeWithTag("wifi:connected-connect").assertIsEnabled() + compose.onNodeWithTag("wifi:connected-connect").assertDoesNotExist() listOf(base.copy(permissionGranted = false), base.copy(enabled = false), base.copy(phase = WifiApPhase.CONNECTING), base.copy(connectedNetworks = emptyList()), @@ -191,18 +204,18 @@ class WifiApConnectionUiTest { base.copy(connectedNetworks = listOf(network, ConnectedWifiNetwork("102", "Other", "192.168.2.20")))) .forEach { unavailable -> compose.runOnIdle { state.value = unavailable } - compose.onNodeWithTag("wifi:connected-connect").assertIsNotEnabled() + compose.onNodeWithTag("wifi:connected-connect").assertDoesNotExist() } compose.runOnIdle { state.value = base; endpointEditable.value = false } - compose.onNodeWithTag("wifi:connected-connect").assertIsNotEnabled() + compose.onNodeWithTag("wifi:connected-connect").assertDoesNotExist() compose.runOnIdle { endpointEditable.value = true; communicationReady.value = true } - compose.onNodeWithTag("wifi:connected-connect").assertIsNotEnabled() + compose.onNodeWithTag("wifi:connected-connect").assertDoesNotExist() compose.runOnIdle { communicationReady.value = false; socketConnected.value = true } - compose.onNodeWithTag("wifi:connected-connect").assertIsNotEnabled() + compose.onNodeWithTag("wifi:connected-connect").assertDoesNotExist() compose.runOnIdle { socketConnected.value = false; endpoint.value = WifiApEndpoint("0.0.0.0", 6000) } - compose.onNodeWithTag("wifi:connected-connect").assertIsNotEnabled() + compose.onNodeWithTag("wifi:connected-connect").assertDoesNotExist() compose.runOnIdle { endpoint.value = WifiApEndpoint() } - compose.onNodeWithTag("wifi:connected-connect").assertIsEnabled() + compose.onNodeWithTag("wifi:connected-connect").assertDoesNotExist() compose.runOnIdle { assertEquals(0, current); assertNull(connected) } } @@ -274,16 +287,53 @@ class WifiApConnectionUiTest { show() compose.onNodeWithTag("wifi:ap-list").performScrollToIndex(31) compose.onNodeWithTag("wifi:ap:DCT_30").assertIsDisplayed() - compose.onNodeWithTag("wifi:connected-connect").assertIsDisplayed().assertIsEnabled() - val connectBounds = compose.onNodeWithTag("wifi:connected-connect").fetchSemanticsNode().boundsInRoot - val closeBounds = compose.onNodeWithText("닫기").fetchSemanticsNode().boundsInRoot - assertTrue(closeBounds.left >= connectBounds.right) - assertEquals(connectBounds.center.y, closeBounds.center.y, 1f) + compose.onNodeWithTag("wifi:connected-connect").assertDoesNotExist() compose.onNodeWithContentDescription("새로고침").assertIsDisplayed().performClick() compose.onNodeWithText("닫기").assertIsDisplayed().performClick() compose.runOnIdle { assertEquals(1, scanned); assertTrue(dismissed) } } + @Test fun equipmentConnectionScrollsItsMovedRowToTheTop() { + state.value = state.value.copy(devices = (1..40).map { device.copy(ssid = "DCT_$it") }) + show() + compose.onNodeWithTag("wifi:ap-list").performScrollToIndex(40) + compose.onNodeWithTag("wifi:ap:DCT_40").assertIsDisplayed() + compose.runOnIdle { + state.value = state.value.copy(phase = WifiApPhase.CONNECTING, selectedSsid = "DCT_40") + } + compose.runOnIdle { + state.value = state.value.copy(phase = WifiApPhase.CONNECTED, localAddress = "192.168.4.2", + activeNetworkKey = "equipment", connectedNetworks = listOf( + ConnectedWifiNetwork("equipment", "DCT_40", "192.168.4.2"))) + } + val row = compose.onNodeWithTag("wifi:ap-current").assertIsDisplayed().fetchSemanticsNode().boundsInRoot + val list = compose.onNodeWithTag("wifi:ap-list").fetchSemanticsNode().boundsInRoot + assertEquals(list.top, row.top, 1f) + compose.onNodeWithText("닫기").assertIsDisplayed() + compose.runOnIdle { assertEquals(0, current); assertEquals(0, disconnected) } + } + + @Test fun systemWifiConnectionScrollsToTopButScanUpdatesKeepManualScroll() { + state.value = state.value.copy(devices = (1..40).map { device.copy(ssid = "DCT_$it") }) + show() + compose.onNodeWithTag("wifi:ap-list").performScrollToIndex(40) + compose.onNodeWithTag("wifi:ap:DCT_40").assertIsDisplayed() + compose.runOnIdle { + state.value = state.value.copy(connectedNetworks = listOf( + ConnectedWifiNetwork("system", "DCT_40", "192.168.1.20"))) + } + val row = compose.onNodeWithTag("wifi:connected:system").assertIsDisplayed().fetchSemanticsNode().boundsInRoot + val list = compose.onNodeWithTag("wifi:ap-list").fetchSemanticsNode().boundsInRoot + assertEquals(list.top, row.top, 1f) + compose.onNodeWithTag("wifi:ap-list").performScrollToIndex(31) + compose.onNodeWithTag("wifi:ap:DCT_30").assertIsDisplayed() + compose.runOnIdle { + state.value = state.value.copy(devices = state.value.devices.map { it.copy(level = it.level - 1) }) + } + compose.onNodeWithTag("wifi:ap:DCT_30").assertIsDisplayed() + compose.runOnIdle { assertEquals(0, current); assertEquals(0, disconnected) } + } + @Test fun alreadyConnectedWifiActionRemainsAvailableInOverflow() { show() compose.onNodeWithText("현재 Wi-Fi로 연결").assertDoesNotExist() @@ -296,15 +346,18 @@ class WifiApConnectionUiTest { show() compose.onNodeWithContentDescription("더보기").performClick() compose.onNodeWithText("IP / 포트 설정").performClick() + compose.onNodeWithTag("wifi:tcp-guide").assertIsDisplayed().assertTextEquals( + "장치와 같은 Wi-Fi에 연결한 뒤 장치의 IPv4 주소와 포트를 입력하세요. 기본값은 192.168.4.1:5000입니다.") + compose.onNodeWithText("PC", substring = true).assertDoesNotExist() compose.onNodeWithTag("wifi:tcp-ip").performTextReplacement("192.168.1.100") compose.onNodeWithTag("wifi:tcp-port").performTextReplacement("65536") compose.onNodeWithTag("wifi:tcp-save").assertIsNotEnabled() - compose.onNodeWithTag("wifi:tcp-connect").assertIsNotEnabled() + compose.onNodeWithTag("wifi:tcp-connect").assertDoesNotExist() compose.onNodeWithTag("wifi:tcp-port").performTextReplacement("6000") compose.onNodeWithTag("wifi:tcp-save").performClick() compose.runOnIdle { assertEquals(WifiApEndpoint("192.168.1.100", 6000), savedEndpoint) - assertEquals(0, current); assertEquals(0, retried) + assertEquals(0, current) } compose.onNodeWithContentDescription("더보기").performClick() compose.onNodeWithText("연결 정보").performClick() @@ -315,26 +368,28 @@ class WifiApConnectionUiTest { compose.runOnIdle { assertEquals(WifiApEndpoint(), savedEndpoint) } } - @Test fun savingAndConnectingUsesNewTargetForCurrentWifiAndConnectedAp() { + @Test fun savingTargetNeverConnectsForCurrentWifiOrConnectedAp() { show() compose.onNodeWithContentDescription("더보기").performClick() compose.onNodeWithText("IP / 포트 설정").performClick() compose.onNodeWithTag("wifi:tcp-ip").performTextReplacement("192.168.1.100") compose.onNodeWithTag("wifi:tcp-port").performTextReplacement("6000") - compose.onNodeWithTag("wifi:tcp-connect").performClick() + compose.onNodeWithTag("wifi:tcp-connect").assertDoesNotExist() + compose.onNodeWithTag("wifi:tcp-save").performClick() compose.runOnIdle { assertEquals(WifiApEndpoint("192.168.1.100", 6000), savedEndpoint) - assertEquals(1, current) + assertEquals(0, current) state.value = state.value.copy(phase = WifiApPhase.CONNECTED, selectedSsid = "PC LAN", localAddress = "192.168.1.20") } compose.onNodeWithTag("wifi:ap-current").performClick() compose.onNodeWithText("IP / 포트 설정").performClick() compose.onNodeWithTag("wifi:tcp-port").performTextReplacement("7000") - compose.onNodeWithTag("wifi:tcp-connect").performClick() + compose.onNodeWithTag("wifi:tcp-connect").assertDoesNotExist() + compose.onNodeWithTag("wifi:tcp-save").performClick() compose.runOnIdle { assertEquals(7000, savedEndpoint!!.port) - assertEquals(1, retried); assertEquals(1, current) + assertEquals(0, current) } } diff --git a/Android/app/src/androidTest/java/com/dct/app/presentation/monitor/components/DebugValueResetUiTest.kt b/Android/app/src/androidTest/java/com/dct/app/presentation/monitor/components/DebugValueResetUiTest.kt new file mode 100644 index 0000000..40916a4 --- /dev/null +++ b/Android/app/src/androidTest/java/com/dct/app/presentation/monitor/components/DebugValueResetUiTest.kt @@ -0,0 +1,26 @@ +package com.dct.app.presentation.monitor.components + +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.test.* +import androidx.compose.ui.test.junit4.createComposeRule +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test + +class DebugValueResetUiTest { + @get:Rule val compose = createComposeRule() + + @Test fun resetActionIsVisibleOnlyInDebugAndInvokesItsOwnCallback() { + val debug = mutableStateOf(false) + var clicks = 0 + compose.setContent { MaterialTheme { + DebugValueResetButton(isDebugBuild = debug.value, onClick = { clicks++ }) + } } + compose.onNodeWithText("값 초기화").assertDoesNotExist() + compose.runOnIdle { debug.value = true } + compose.onNodeWithText("값 초기화").assertIsDisplayed().assertIsEnabled().performClick() + compose.runOnIdle { assertEquals(1, clicks); debug.value = false } + compose.onNodeWithText("값 초기화").assertDoesNotExist() + } +} diff --git a/Android/app/src/androidTest/java/com/dct/app/presentation/monitor/components/RecordingRibbonButtonUiTest.kt b/Android/app/src/androidTest/java/com/dct/app/presentation/monitor/components/RecordingRibbonButtonUiTest.kt index 7c0ee71..71de104 100644 --- a/Android/app/src/androidTest/java/com/dct/app/presentation/monitor/components/RecordingRibbonButtonUiTest.kt +++ b/Android/app/src/androidTest/java/com/dct/app/presentation/monitor/components/RecordingRibbonButtonUiTest.kt @@ -2,6 +2,7 @@ package com.dct.app.presentation.monitor.components import androidx.compose.foundation.background import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.height import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.mutableStateOf import androidx.compose.ui.Modifier @@ -9,17 +10,103 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.toPixelMap import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.compose.ui.test.* import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.dp +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.LifecycleRegistry +import com.dct.app.presentation.main.MainUiState +import com.dct.app.presentation.main.MeasurementRecordingState import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Rule import org.junit.Test +import java.util.concurrent.atomic.AtomicLong class RecordingRibbonButtonUiTest { @get:Rule val compose = createComposeRule() + @Test fun elapsedTimeOnlyAppearsForAnActuallyStartedRecording() { + val state = mutableStateOf(MainUiState()) + compose.setContent { + RecordingRibbonLabel(state.value.recordingState, state.value.recordingStartedAtMillis) { 6_000L } + } + compose.onNodeWithText("신호 기록").assertIsDisplayed() + for (phase in listOf(MeasurementRecordingState.IDLE, MeasurementRecordingState.STARTING, + MeasurementRecordingState.STOPPING, MeasurementRecordingState.ERROR)) { + compose.runOnIdle { state.value = MainUiState(recordingState = phase, recordingStartedAtMillis = 1_000L) } + compose.onNodeWithTag("RecordingElapsedTime").assertDoesNotExist() + } + compose.runOnIdle { state.value = MainUiState(recordingState = MeasurementRecordingState.RECORDING) } + compose.onNodeWithTag("RecordingElapsedTime").assertDoesNotExist() + compose.runOnIdle { state.value = state.value.copy(recordingStartedAtMillis = 1_000L) } + compose.onNodeWithTag("RecordingElapsedTime").assertTextEquals("00:00:05") + } + + @Test fun timerTicksWithoutPacketsAndRestartUsesNewRecordingStart() { + val state = mutableStateOf(MainUiState(recordingState = MeasurementRecordingState.RECORDING, + recordingStartedAtMillis = 1_000L)) + val clock = AtomicLong(6_000L) + compose.setContent { + RecordingRibbonLabel(state.value.recordingState, state.value.recordingStartedAtMillis, clock::get) + } + compose.onNodeWithTag("RecordingElapsedTime").assertTextEquals("00:00:05") + clock.set(8_000L) + compose.mainClock.advanceTimeBy(1_100) + compose.onNodeWithTag("RecordingElapsedTime").assertTextEquals("00:00:07") + compose.runOnIdle { state.value = MainUiState(recordingState = MeasurementRecordingState.IDLE) } + compose.onNodeWithTag("RecordingElapsedTime").assertDoesNotExist() + compose.runOnIdle { state.value = MainUiState(recordingState = MeasurementRecordingState.RECORDING, + recordingStartedAtMillis = 8_000L) } + compose.onNodeWithTag("RecordingElapsedTime").assertTextEquals("00:00:00") + } + + @Test fun largeFontKeepsTimeBesideLabelAndSeparateFromRecordingButtons() { + compose.setContent { + CompositionLocalProvider(LocalDensity provides Density(LocalDensity.current.density, 1.5f)) { + Row(Modifier.height(44.dp).background(Color.White)) { + RecordingRibbonLabel(MeasurementRecordingState.RECORDING, 1_000L) { 3_662_000L } + RecordingRibbonButton("신호 기록 시작", enabled = false, onClick = {}) + RecordingRibbonButton("신호 기록 중지", enabled = true, isStop = true, onClick = {}) + } + } + } + val label = compose.onNodeWithText("신호 기록").assertIsDisplayed().fetchSemanticsNode().boundsInRoot + val time = compose.onNodeWithTag("RecordingElapsedTime").assertIsDisplayed() + .assertTextEquals("01:01:01").fetchSemanticsNode().boundsInRoot + val start = compose.onNodeWithContentDescription("신호 기록 시작").assertIsNotEnabled() + .fetchSemanticsNode().boundsInRoot + compose.onNodeWithContentDescription("신호 기록 중지").assertIsEnabled() + assertTrue(label.right <= time.left) + assertTrue(time.right <= start.left) + assertEquals(label.center.y, time.center.y, 1f) + } + + @Test fun backgroundStopsUiTicksAndResumeRecomputesElapsedTime() { + class Owner : LifecycleOwner { + val registry = LifecycleRegistry(this) + override val lifecycle: Lifecycle get() = registry + } + lateinit var owner: Owner + compose.runOnUiThread { owner = Owner().also { it.registry.currentState = Lifecycle.State.RESUMED } } + val clock = AtomicLong(6_000L) + compose.setContent { + CompositionLocalProvider(LocalLifecycleOwner provides owner) { + RecordingRibbonLabel(MeasurementRecordingState.RECORDING, 1_000L, clock::get) + } + } + compose.onNodeWithTag("RecordingElapsedTime").assertTextEquals("00:00:05") + compose.runOnIdle { owner.registry.currentState = Lifecycle.State.CREATED } + clock.set(30_000L) + compose.mainClock.advanceTimeBy(1_100) + compose.onNodeWithTag("RecordingElapsedTime").assertTextEquals("00:00:05") + compose.runOnIdle { owner.registry.currentState = Lifecycle.State.RESUMED } + compose.onNodeWithTag("RecordingElapsedTime").assertTextEquals("00:00:29") + } + private fun redPixels(image: ImageBitmap): Int { val pixels = image.toPixelMap() return (0 until image.width).sumOf { x -> diff --git a/Android/app/src/androidTest/java/com/dct/app/presentation/monitor/components/WifiConnectionRibbonUiTest.kt b/Android/app/src/androidTest/java/com/dct/app/presentation/monitor/components/WifiConnectionRibbonUiTest.kt new file mode 100644 index 0000000..c3e74c6 --- /dev/null +++ b/Android/app/src/androidTest/java/com/dct/app/presentation/monitor/components/WifiConnectionRibbonUiTest.kt @@ -0,0 +1,102 @@ +package com.dct.app.presentation.monitor.components + +import androidx.compose.foundation.layout.Row +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.test.* +import androidx.compose.ui.test.junit4.createComposeRule +import com.dct.app.data.communication.WifiApPhase +import com.dct.app.data.communication.WifiApUiState +import com.dct.app.presentation.main.CommunicationType +import com.dct.app.presentation.main.MainUiState +import org.junit.Assert.* +import org.junit.Rule +import org.junit.Test + +class WifiConnectionRibbonUiTest { + @get:Rule val compose = createComposeRule() + + @Test fun wifiConnectDisconnectAreAdjacentAndActionsRemainSeparateDuringConnection() { + val ui = mutableStateOf(MainUiState()) + val canConnect = mutableStateOf(true) + var wifiClicks = 0 + var connectClicks = 0 + var disconnectClicks = 0 + var wifiDisconnectClicks = 0 + compose.setContent { MaterialTheme { Row { + WifiConnectionRibbonButtons(ui.value, WifiApUiState( + phase = WifiApPhase.CONNECTED, selectedSsid = "DCT", localAddress = "192.168.4.2"), + canConnect.value, { wifiClicks++ }, { connectClicks++ }, { disconnectClicks++ }, + { wifiDisconnectClicks++ }) + } } } + val wifi = compose.onNodeWithContentDescription("Wi-Fi AP 연결됨 (DCT)") + val connect = compose.onNodeWithText("Connect") + val disconnect = compose.onNodeWithText("Disconnect") + val wifiDisconnect = compose.onNodeWithContentDescription("Wi-Fi 연결 해제") + compose.onNodeWithText("Wi-Fi 해제").assertDoesNotExist() + val wifiBounds = wifi.fetchSemanticsNode().boundsInRoot + val connectBounds = connect.fetchSemanticsNode().boundsInRoot + val disconnectBounds = disconnect.fetchSemanticsNode().boundsInRoot + assertTrue(connectBounds.left >= wifiBounds.right) + assertTrue(disconnectBounds.left >= connectBounds.right) + assertEquals(wifiBounds.center.y, connectBounds.center.y, 1f) + assertEquals(connectBounds.center.y, disconnectBounds.center.y, 1f) + compose.onAllNodesWithContentDescription("통신 끊기 (Disconnect)").assertCountEquals(1) + wifiDisconnect.assertIsEnabled() + disconnect.assertIsNotEnabled() + wifi.performClick() + compose.runOnIdle { assertEquals(1, wifiClicks); assertEquals(0, connectClicks) } + connect.assertIsEnabled().performClick() + compose.runOnIdle { + assertEquals(1, connectClicks) + ui.value = MainUiState(isCommunicationConnecting = true) + canConnect.value = false + } + connect.assertIsNotEnabled() + disconnect.assertIsEnabled().performClick() + compose.runOnIdle { + assertEquals(1, disconnectClicks) + assertEquals(0, wifiDisconnectClicks) + ui.value = MainUiState(isConnected = true, communicationType = CommunicationType.WIFI) + } + connect.assertIsNotEnabled() + wifi.assertIsEnabled() + disconnect.assertIsEnabled() + disconnect.performClick() + compose.runOnIdle { + assertEquals(2, disconnectClicks) + assertEquals(1, connectClicks) + assertEquals(1, wifiClicks) + ui.value = MainUiState() + canConnect.value = true + } + connect.assertIsEnabled() + disconnect.assertIsNotEnabled() + wifi.assertIsEnabled() + wifiDisconnect.assertIsEnabled().performClick() + compose.runOnIdle { + assertEquals(1, wifiDisconnectClicks) + assertEquals(2, disconnectClicks) + } + } + + @Test fun wifiDisconnectIsAvailableDuringApConnectionButProtocolDisconnectIsNot() { + val ap = mutableStateOf(WifiApUiState(phase = WifiApPhase.CONNECTING)) + var wifiDisconnects = 0 + var protocolDisconnects = 0 + compose.setContent { MaterialTheme { Row { + WifiConnectionRibbonButtons(MainUiState(), ap.value, false, {}, {}, + { protocolDisconnects++ }, { wifiDisconnects++ }) + } } } + compose.onNodeWithText("Disconnect").assertIsNotEnabled() + compose.onNodeWithText("Wi-Fi 해제").assertDoesNotExist() + compose.onNodeWithContentDescription("Wi-Fi 연결 해제").assertIsEnabled().performClick() + compose.runOnIdle { + assertEquals(1, wifiDisconnects) + assertEquals(0, protocolDisconnects) + ap.value = WifiApUiState() + } + compose.onNodeWithContentDescription("Wi-Fi 연결 해제").assertIsNotEnabled() + compose.onNodeWithText("Disconnect").assertIsNotEnabled() + } +} diff --git a/Android/app/src/main/java/com/dct/app/DctApplication.kt b/Android/app/src/main/java/com/dct/app/DctApplication.kt index c47edc2..c13cbb6 100644 --- a/Android/app/src/main/java/com/dct/app/DctApplication.kt +++ b/Android/app/src/main/java/com/dct/app/DctApplication.kt @@ -5,7 +5,16 @@ import com.dct.app.background.DctSessionRuntime /** One process-wide session survives Activity recreation and a return from the launcher. */ class DctApplication : Application() { - val session: DctSessionRuntime by lazy { DctSessionRuntime(this) } + private var currentSession: DctSessionRuntime? = null + val session: DctSessionRuntime + get() = currentSession ?: DctSessionRuntime(this).also { currentSession = it } + + /** Only an explicit program exit retires the retained workspace. */ + fun releaseSession(expected: DctSessionRuntime) { + if (currentSession !== expected) return + expected.close() + currentSession = null + } override fun onCreate() { super.onCreate() diff --git a/Android/app/src/main/java/com/dct/app/background/DctSessionRuntime.kt b/Android/app/src/main/java/com/dct/app/background/DctSessionRuntime.kt index 6b3f502..c5bab75 100644 --- a/Android/app/src/main/java/com/dct/app/background/DctSessionRuntime.kt +++ b/Android/app/src/main/java/com/dct/app/background/DctSessionRuntime.kt @@ -22,6 +22,7 @@ import com.dct.app.presentation.signaltree.SignalTreeViewModel import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow @@ -49,6 +50,10 @@ class DctSessionRuntime(context: Context) : ViewModelStoreOwner { onFailure = { Log.e("PcOperationalLogger", "운영 로그 저장 실패 (통신·계측은 유지)", it) } ) private var observing = false + var isClosed = false + private set + var programExitRequested = false + private set private var uiVisible = false @Volatile private var serviceRequested = false private val _serviceRunning = MutableStateFlow(false) @@ -90,6 +95,7 @@ class DctSessionRuntime(context: Context) : ViewModelStoreOwner { /** Start while handling a visible user connection request, before P2P/TCP negotiation begins. */ @Synchronized fun ensureForegroundService(): Boolean { + if (isClosed) return false // A running service may already have queued stopSelf(). Deliver a fresh startId // for a new connection instead of relying on a stale "running" flag. if (serviceRequested && !serviceRunning.value) return true @@ -106,6 +112,7 @@ class DctSessionRuntime(context: Context) : ViewModelStoreOwner { } fun onUiStarted() { + if (isClosed) return uiVisible = true viewModel.setApplicationVisible(true) if (!observing) { @@ -123,4 +130,16 @@ class DctSessionRuntime(context: Context) : ViewModelStoreOwner { fun onUiStopped() { uiVisible = false; viewModel.setApplicationVisible(false) } fun onServiceStarted() { serviceRequested = true; _serviceRunning.value = true } fun onServiceStopped() { serviceRequested = false; _serviceRunning.value = false } + + fun requestProgramExit() { programExitRequested = true } + + /** Call only after STOP/disconnect and file finalization have completed. */ + fun close() { + if (isClosed) return + isClosed = true + scope.cancel() + appContext.stopService(Intent(appContext, MeasurementForegroundService::class.java)) + viewModelStore.clear() + operationalLogger.close() + } } diff --git a/Android/app/src/main/java/com/dct/app/background/MeasurementForegroundService.kt b/Android/app/src/main/java/com/dct/app/background/MeasurementForegroundService.kt index 2dd842a..02ff273 100644 --- a/Android/app/src/main/java/com/dct/app/background/MeasurementForegroundService.kt +++ b/Android/app/src/main/java/com/dct/app/background/MeasurementForegroundService.kt @@ -58,7 +58,7 @@ class MeasurementForegroundService : Service() { } catch (error: RuntimeException) { Log.e(TAG, "Foreground promotion failed", error) session.viewModel.setStatusMessage("백그라운드 계측 서비스를 시작하지 못했습니다: ${error.message}") - session.viewModel.disconnectCommunication() + session.viewModel.disconnectCommunication(releaseWifiAp = true) intentionalStop = true stopSelf() return @@ -113,7 +113,8 @@ class MeasurementForegroundService : Service() { wakeLock?.let { if (it.isHeld) it.release() } wakeLock = null if (::session.isInitialized) { - if (!intentionalStop && session.executionState.value.active) session.viewModel.disconnectCommunication() + if (!intentionalStop && !session.isClosed && session.executionState.value.active) + session.viewModel.disconnectCommunication(releaseWifiAp = true) session.onServiceStopped() } super.onDestroy() diff --git a/Android/app/src/main/java/com/dct/app/data/communication/WifiSocketManager.kt b/Android/app/src/main/java/com/dct/app/data/communication/WifiSocketManager.kt index 04b8396..679416e 100644 --- a/Android/app/src/main/java/com/dct/app/data/communication/WifiSocketManager.kt +++ b/Android/app/src/main/java/com/dct/app/data/communication/WifiSocketManager.kt @@ -60,7 +60,13 @@ class WifiSocketManager( return connectionGeneration.incrementAndGet() } - private val parser = SlidingRingBufferParser() + private val parser = SlidingRingBufferParser(onFrameRecovery = { recovery -> + val header = recovery.blockedHeader.joinToString(" ") { "%02X".format(it.toInt() and 0xFF) } + Log.w("WifiSocketManager", "TCP parser resync: session=$currentSessionId, " + + "buffered=${recovery.bufferedBytes}B, expected=${recovery.expectedBytes}B, " + + "discarded=${recovery.discardedBytes}B, idle=${recovery.idleMillis}ms, " + + "header=[$header], recovered=${DctCommand.toHexString(recovery.recoveredCommand)}") + }) private val _isConnected = MutableStateFlow(false) val isConnected: StateFlow = _isConnected.asStateFlow() @@ -444,6 +450,14 @@ class WifiSocketManager( disconnectInternal() } + /** A late liveness timeout must never close a newer connection. */ + @Synchronized + fun invalidateConnectionIfSession(sessionId: Long): Boolean { + if (sessionId == 0L || currentSessionId != sessionId || !_isConnected.value) return false + disconnectInternal() + return true + } + @Synchronized private fun disconnectActiveSocketOnly() { if (activeSocket != null) Log.i("WifiSocketManager", "TCP local cleanup: session=$currentSessionId", diff --git a/Android/app/src/main/java/com/dct/app/data/protocol/DctProtocolRequests.kt b/Android/app/src/main/java/com/dct/app/data/protocol/DctProtocolRequests.kt index 22d8ae3..3aabc0f 100644 --- a/Android/app/src/main/java/com/dct/app/data/protocol/DctProtocolRequests.kt +++ b/Android/app/src/main/java/com/dct/app/data/protocol/DctProtocolRequests.kt @@ -164,8 +164,11 @@ object DctProtocolRequests { stream.write(moduleMask) normalized.forEach { module -> var temperatureMask = 0 - module.channels.take(8).forEach { channel -> - if (!channel.isVoltage) temperatureMask = temperatureMask or (1 shl channel.channelIndex) + // PC RequestSMCount leaves unused modules' channel bytes at zero. + if (module.isUsed) { + module.channels.take(8).forEach { channel -> + if (!channel.isVoltage) temperatureMask = temperatureMask or (1 shl channel.channelIndex) + } } stream.write(temperatureMask) } diff --git a/Android/app/src/main/java/com/dct/app/data/protocol/SlidingRingBufferParser.kt b/Android/app/src/main/java/com/dct/app/data/protocol/SlidingRingBufferParser.kt index 74ce2fb..4d45405 100644 --- a/Android/app/src/main/java/com/dct/app/data/protocol/SlidingRingBufferParser.kt +++ b/Android/app/src/main/java/com/dct/app/data/protocol/SlidingRingBufferParser.kt @@ -14,25 +14,43 @@ import com.dct.app.domain.model.protocol.DctPacket * 5. 페이로드 내부 0x7E / 0xA1 데이터 처리: 길이 검증을 통해 페이로드 데이터 바이트와 실제 푸터를 정확히 구분. * 6. 체크섬 오류 패킷 안전 폐기 및 다음 패킷 복구. * 7. 링 버퍼 오버플로우 방지 및 자동 복구. - * 8. Thread-Safe: 모든 주요 연산에 synchronized 적용. + * 8. 2초 수신 공백 뒤 검증된 제어 프레임으로 미완성 선두 프레임 복구. + * 9. Thread-Safe: 모든 주요 연산에 synchronized 적용. */ class SlidingRingBufferParser( private val bufferCapacity: Int = DEFAULT_BUFFER_CAPACITY, private val maxPacketSize: Int = DEFAULT_MAX_PACKET_SIZE, - private val codec: DctPacketCodec = DctPacketCodec() + private val codec: DctPacketCodec = DctPacketCodec(), + private val clockNanos: () -> Long = System::nanoTime, + private val onFrameRecovery: (FrameRecovery) -> Unit = {} ) : PacketParser { companion object { const val DEFAULT_MAX_PACKET_SIZE: Int = DctPacketCodec.MAX_PAYLOAD_LENGTH + 6 const val DEFAULT_BUFFER_CAPACITY: Int = DEFAULT_MAX_PACKET_SIZE * 2 + const val RECOVERY_IDLE_NANOS: Long = 2_000_000_000L } + data class FrameRecovery( + val bufferedBytes: Int, + val expectedBytes: Int, + val discardedBytes: Int, + val idleMillis: Long, + val blockedHeader: ByteArray, + val recoveredCommand: Byte + ) + private val ringBuffer = ByteArray(bufferCapacity) private val scratchFrameBuffer = ByteArray(maxPacketSize) private var head = 0 private var tail = 0 private var availableBytes = 0 private var sequenceCounter = 0 + private var lastInputNanos = 0L + private var hasReceivedInput = false + private var recoveryBoundary = -1 + private var recoveryScanOffset = 0 + private var recoveryIdleNanos = 0L @Synchronized override fun parseStream(buffer: ByteArray, length: Int): List { @@ -40,6 +58,17 @@ class SlidingRingBufferParser( if (length <= 0) return result require(length <= buffer.size) { "Input length exceeds buffer size" } + val now = clockNanos() + if (availableBytes > 0 && hasReceivedInput && recoveryBoundary < 0 && + now - lastInputNanos >= RECOVERY_IDLE_NANOS) { + // TCP reads are not packet boundaries. Keep the original assembly and + // only consider checksum-valid control frames arriving AFTER an idle gap. + recoveryBoundary = availableBytes + recoveryScanOffset = availableBytes + recoveryIdleNanos = now - lastInputNanos + } + lastInputNanos = now + hasReceivedInput = true // Drain between chunks: a coalesced read larger than the ring must not erase older frames. var offset = 0 while (offset < length) { @@ -111,7 +140,8 @@ class SlidingRingBufferParser( // 2.4 전체 프레임 수신 여부 확인 if (availableBytes < expectedTotalLength) { - // 프레임 전체가 아직 도착하지 않았으므로 추가 데이터 수신 대기 + if (recoverStalledFrame(expectedTotalLength)) continue + // No timeout-only discard: ordinary fragments remain buffered. break } @@ -146,6 +176,42 @@ class SlidingRingBufferParser( } + private fun recoverStalledFrame(expectedLength: Int): Boolean { + if (recoveryBoundary < 0) return false + // The wire format has no escaping/sequence marker. A control-shaped payload + // after a long gap is inherently ambiguous; prefer bounded recovery of the + // control path over retaining a stalled sample forever. Complete outer frames + // and pre-gap payload candidates are never preempted by this policy. + // Control frames are at most eight bytes. Revisit only the short suffix + // needed for fragmented ACKs, not the entire accumulated telemetry buffer. + for (offset in maxOf(1, recoveryScanOffset)..(availableBytes - DctPacketCodec.MIN_PACKET_LENGTH)) { + if (peek(offset) != DctPacketCodec.START_BYTE) continue + val packLen = ((peek(offset + 1).toInt() and 0xFF) shl 8) or + (peek(offset + 2).toInt() and 0xFF) + val command = peek(offset + 3) + val isControl = when (command) { + DctCommand.RES_POSSIBLE_TO_CONNECT.code, + DctCommand.RES_STOP_TRANSMIT_DATA.code -> packLen in 1..2 + DctCommand.RES_START_TRANSMIT_DATA.code -> packLen == 3 + else -> false + } + val length = packLen + DctPacketCodec.OVERHEAD_LENGTH + if (!isControl || length > minOf(maxPacketSize, bufferCapacity) || offset + length > availableBytes) continue + peekBytes(scratchFrameBuffer, 0, length, offset) + if (!codec.isValidFrame(scratchFrameBuffer, length = length)) continue + + val header = ByteArray(minOf(8, recoveryBoundary)) + peekBytes(header, 0, header.size) + val recovery = FrameRecovery(availableBytes, expectedLength, offset, + recoveryIdleNanos / 1_000_000L, header, command) + skipBytes(offset) + onFrameRecovery(recovery) + return true + } + recoveryScanOffset = maxOf(recoveryBoundary, availableBytes - 7) + return false + } + @Synchronized override fun pollPacket(): DctPacket? = null @@ -155,6 +221,11 @@ class SlidingRingBufferParser( tail = 0 availableBytes = 0 sequenceCounter = 0 + lastInputNanos = 0L + hasReceivedInput = false + recoveryBoundary = -1 + recoveryScanOffset = 0 + recoveryIdleNanos = 0L } val availableByteCount: Int @@ -196,10 +267,11 @@ class SlidingRingBufferParser( return ringBuffer[index] } - private fun peekBytes(target: ByteArray, targetOffset: Int, count: Int) { - check(count <= availableBytes) { "Cannot peek $count bytes, only $availableBytes available" } - val firstChunk = (bufferCapacity - tail).coerceAtMost(count) - System.arraycopy(ringBuffer, tail, target, targetOffset, firstChunk) + private fun peekBytes(target: ByteArray, targetOffset: Int, count: Int, sourceOffset: Int = 0) { + check(sourceOffset + count <= availableBytes) { "Cannot peek $count bytes at $sourceOffset, only $availableBytes available" } + val start = (tail + sourceOffset) % bufferCapacity + val firstChunk = (bufferCapacity - start).coerceAtMost(count) + System.arraycopy(ringBuffer, start, target, targetOffset, firstChunk) if (count > firstChunk) { val secondChunk = count - firstChunk System.arraycopy(ringBuffer, 0, target, targetOffset + firstChunk, secondChunk) @@ -210,5 +282,14 @@ class SlidingRingBufferParser( val toSkip = count.coerceAtMost(availableBytes) tail = (tail + toSkip) % bufferCapacity availableBytes -= toSkip + if (recoveryBoundary >= 0) { + if (toSkip >= recoveryBoundary) { + recoveryBoundary = -1 + recoveryScanOffset = 0 + } else { + recoveryBoundary -= toSkip + recoveryScanOffset = (recoveryScanOffset - toSkip).coerceAtLeast(recoveryBoundary) + } + } } } diff --git a/Android/app/src/main/java/com/dct/app/data/repository/PacketRepositoryImpl.kt b/Android/app/src/main/java/com/dct/app/data/repository/PacketRepositoryImpl.kt index d00a0d2..d79e0ad 100644 --- a/Android/app/src/main/java/com/dct/app/data/repository/PacketRepositoryImpl.kt +++ b/Android/app/src/main/java/com/dct/app/data/repository/PacketRepositoryImpl.kt @@ -48,7 +48,8 @@ class PacketRepositoryImpl( private val heartbeatTimeoutMs: Long = DEFAULT_HEARTBEAT_TIMEOUT_MS, private val heartbeatFailureThreshold: Int = DEFAULT_HEARTBEAT_FAILURE_THRESHOLD, private val enableHeartbeat: Boolean = true, - private val heartbeatDispatcher: CoroutineDispatcher = Dispatchers.IO + private val heartbeatDispatcher: CoroutineDispatcher = Dispatchers.IO, + private val monotonicTimeMillis: () -> Long = { System.nanoTime() / 1_000_000L } ) : PacketRepository { private val scope = CoroutineScope(ioDispatcher + SupervisorJob()) private val requestMutex = Mutex() @@ -57,15 +58,21 @@ class PacketRepositoryImpl( private val responseQuarantineSession = ConcurrentHashMap() private var heartbeatJob: Job? = null private val inboundPacketGeneration = java.util.concurrent.atomic.AtomicLong(0L) + private val lastInboundMillis = java.util.concurrent.atomic.AtomicLong(0L) /** * Keeps TCP response matching ahead of slow graph/log subscribers without * allowing an unbounded heap backlog. A full queue applies backpressure to * the reader; already received packets are never silently discarded. */ private val packetDispatchQueue = Channel(dispatchQueueCapacity.coerceAtLeast(1)) + @Volatile private var xcpIsMsbFirst = false @Volatile private var pendingInitialConfiguration: DctMeasurementConfiguration? = null + /** A cancelled/timed-out START may already have reached the device. Clear only at a STOP/session boundary. */ + @Volatile private var startMayHaveReachedDevice = false + /** After START is requested, response silence must not close this live TCP session, even after STOP. */ + @Volatile private var playbackTransportSessionId: Long? = null private val _isConnected = MutableStateFlow(false) override val isConnected: StateFlow = _isConnected.asStateFlow() @@ -118,12 +125,31 @@ class PacketRepositoryImpl( } override suspend fun connect(target: String): DctResult = withContext(ioDispatcher) { + val sessionId = wifiSocketManager?.currentSessionId ?: 0L + var completed = false + try { + performHandshake().also { completed = it is DctResult.Success } + } finally { + // A failed/cancelled handshake cannot leave an apparently connected + // socket behind. Never invalidate a newer connection's session. + if (!completed && wifiSocketManager?.invalidateConnectionIfSession(sessionId) == true) { + _isStreaming.value = false + failPendingRequests("장비 초기화에 실패하여 TCP 연결을 종료했습니다.") + transition(DeviceConnectionState.ERROR) + android.util.Log.w("DctPacketRepository", "Handshake failed; TCP session closed: session=$sessionId") + } + } + } + + private suspend fun performHandshake(): DctResult = withContext(ioDispatcher) { // Consume the snapshot once. MainViewModel supplies it immediately before // each Wi-Fi callback, so a failed/stale session cannot leak its settings // into a later reconnect. val initialConfiguration = pendingInitialConfiguration.also { pendingInitialConfiguration = null } transition(DeviceConnectionState.CONNECTING) _isStreaming.value = false + startMayHaveReachedDevice = false + playbackTransportSessionId = null if (!hasLiveTransport()) { transition(DeviceConnectionState.ERROR) return@withContext failure("Wi-Fi TCP 전송 계층이 연결되지 않았습니다.") @@ -144,10 +170,11 @@ class PacketRepositoryImpl( return@withContext failure("연결 초기화 0x00: ${e.message}") } - // 2. SW -> HW: 0x01 (VERSION) 요청 발송 - 0x81 응답은 파란색 박스 교환 이후 도착하므로 deferred 선등록 + // 2. Register before sending: the version response may arrive immediately + // or while the following environment/settings exchanges are in flight. if (responseQuarantineSession[DctCommand.RES_VERSION.code] == wifiSocketManager?.currentSessionId) { transition(DeviceConnectionState.ERROR) - return@withContext failure("이전 버전 응답이 아직 대기 중입니다. 연결은 유지됩니다.") + return@withContext failure("이전 버전 응답이 아직 대기 중입니다. 다시 연결하세요.") } val versionDeferred = CompletableDeferred() pendingResponses[DctCommand.RES_VERSION.code] = versionDeferred @@ -160,6 +187,11 @@ class PacketRepositoryImpl( return@withContext failure("버전 요청(0x01) 송신에 실패했습니다.") } + // PC MainFrm.cpp ENABLE response handler (2026-04-13): + // VERSION -> Sleep(200) -> RequestDCTSavingIntervalTime. + // Delay after the actual write, not merely after queue insertion. + kotlinx.coroutines.delay(VERSION_ENV_GAP_MS) + // 3. 파란색 박스 인터리빙 시퀀스. 0x09/0x12는 payload 없이 // 보낼 수 있는 조회가 아니므로, 프로젝트 스냅샷이 있을 때만 // PC의 완전한 CAN 모드/SM 설정 payload를 추가합니다. @@ -184,9 +216,8 @@ class PacketRepositoryImpl( try { com.dct.app.data.protocol.DctResponseValidator.validateSetup(query, res.data) } catch (e: IllegalArgumentException) { - // A malformed/NACK setup response must fail this handshake, but - // it is not a transport error. Keep the TCP/Wi-Fi session alive so - // a caller can inspect logs or retry without a second P2P pairing. + // connect() closes the failed TCP session without releasing + // the Wi-Fi AP, so a clean retry does not require pairing. transition(DeviceConnectionState.ERROR) return@withContext failure("연결 초기화 ${DctCommand.toHexString(query.command)}: ${e.message}") } @@ -246,13 +277,15 @@ class PacketRepositoryImpl( override suspend fun disconnect(): DctResult = withContext(ioDispatcher) { stopHeartbeatLoop() try { - if (_isStreaming.value) stopStreaming() + if (_isStreaming.value || startMayHaveReachedDevice) stopStreaming() if (hasLiveTransport()) sendCommand(DctProtocolRequests.disable()) } finally { transition(DeviceConnectionState.DISCONNECTED) failPendingRequests("연결이 해제되었습니다.") wifiSocketManager?.disconnectSuspend() _isStreaming.value = false + startMayHaveReachedDevice = false + playbackTransportSessionId = null } DctResult.Success(Unit) } @@ -300,7 +333,7 @@ class PacketRepositoryImpl( } catch (e: kotlinx.coroutines.TimeoutCancellationException) { responseQuarantineSession[expectedResponse] = sessionCommand.transportSessionId android.util.Log.w("DctPacketRepository", - "Command response timeout; TCP session kept alive: session=${sessionCommand.transportSessionId}, command=${DctCommand.toHexString(command.command)}", e) + "Command response timeout: session=${sessionCommand.transportSessionId}, command=${DctCommand.toHexString(command.command)}", e) DctResult.Failure( DctError.ProtocolError.ResponseTimeout( DctCommand.toHexString(command.command), @@ -325,6 +358,7 @@ class PacketRepositoryImpl( if (_connectionState.value != DeviceConnectionState.READY) { return@withContext failure("장비 설정은 READY 상태에서만 적용할 수 있습니다.") } + val sessionId = wifiSocketManager?.currentSessionId ?: 0L val requests = try { fun packet(step: String, build: () -> DctPacket): DctPacket = try { @@ -368,29 +402,57 @@ class PacketRepositoryImpl( add(packet("XCP 미사용 모드") { DctProtocolRequests.xcpUse(0x02) }) } - // 4. CAN mode is a complete 0x09/0x06 request, not a - // subtype-only query. Reapply it here as well so a project - // change on an already paired device takes effect. + // PC registers CAN first, sends the DID preset after a 100ms + // gap, and refreshes the environment after RES_CANDBC_INFO. + // Keep one outstanding request per response code: older peers + // can omit the subtype needed to correlate overlapping 0x89s. + add(packet("CAN DBC") { DctProtocolRequests.canDbcInfo(configuration.canMessages) }) + add(packet("DID 사전 설정") { + com.dct.app.data.maintenance.DeviceMaintenanceProtocol.setupDid(configuration.didPreSetting) + }) + add(packet("저장 주기 조회") { DctProtocolRequests.dctEnvQuery(0) }) + add(packet("장비 환경 조회") { DctProtocolRequests.dctEnvQuery(3) }) add(packet("CAN 모드") { DctProtocolRequests.canMode(configuration.isHighSpeedCan, configuration.isOldModel) }) - // 5. SM setup is also sent with its module/channel bitmasks. + // SM setup is also sent with its module/channel bitmasks. // An empty list intentionally emits [01 00] to clear stale // module selection from the previous project. add(packet("센서 모듈") { DctProtocolRequests.sensorModuleInfo(configuration.sensorModules) }) - // 6. Send CAN DBC even when empty; [00 00 00] explicitly - // clears the previous project's DBC table on the device. - add(packet("CAN DBC") { DctProtocolRequests.canDbcInfo(configuration.canMessages) }) } } catch (e: IllegalArgumentException) { return@withContext failure("장비 설정 데이터가 프로토콜 범위를 벗어났습니다: ${e.message}") } for (request in requests) { + if (request.command == DctCommand.CMD_SETUP_DID_INFO.code) { + kotlinx.coroutines.delay(100) + // PC does not wait for 0x97 in its connection setup chain. + // Await the actual write, but do not invent a mandatory ACK. + if (!sendLive(request.copy(transportSessionId = wifiSocketManager?.currentSessionId ?: 0L))) { + return@withContext failure("DID 사전 설정 송신에 실패했습니다.") + } + // Controlled hardware comparison: separate the unacknowledged + // DID write from the following environment query, not from enqueue time. + kotlinx.coroutines.delay(DID_ENV_GAP_MS) + continue + } val result = sendCommand(request) if (result !is DctResult.Success || result.data == null) { + // Setup cannot resume after its response budget expires, even + // if heartbeat replies keep arriving. Close only this session + // so a late ACK cannot leave an unusable READY connection. + if (result is DctResult.Failure && result.error is DctError.ProtocolError.ResponseTimeout && + playbackTransportSessionId != sessionId && + wifiSocketManager?.invalidateConnectionIfSession(sessionId) == true) { + _isStreaming.value = false + failPendingRequests("장비 설정 응답 시간 초과로 TCP 연결을 종료했습니다.") + transition(DeviceConnectionState.ERROR) + android.util.Log.w("DctPacketRepository", + "Configuration response timeout; TCP session closed: session=$sessionId, command=${DctCommand.toHexString(request.command)}") + } return@withContext if (result is DctResult.Failure) result else failure( "장비 설정 응답 누락: ${DctCommand.toHexString(request.command)}" ) @@ -400,6 +462,11 @@ class PacketRepositoryImpl( } catch (e: IllegalArgumentException) { return@withContext failure("장비 설정 ${DctCommand.toHexString(request.command)}: ${e.message}") } + if (request.command == DctCommand.CMD_XCP_IFDATAS.code) { + // Calibration must use the byte order the device actually acknowledged. + // Commit here, not after all setup: a later failure does not undo IFData. + xcpIsMsbFirst = request.payload[0] == 0x01.toByte() + } } DctResult.Success(Unit) } @@ -416,6 +483,8 @@ class PacketRepositoryImpl( // Wi-Fi Direct 브리지의 control writer가 밀린 실제 로그에서 0xA0이 // 약 12초 후 도착했다. 일반 설정 명령의 빠른 실패 기준은 유지하되 // 계측 시작 ACK만 PC 통신 환경에 맞는 별도 유예 시간을 사용한다. + playbackTransportSessionId = sessionId + startMayHaveReachedDevice = true val result = sendCommandWithTimeout( DctProtocolRequests.startAll(), START_STREAM_RESPONSE_TIMEOUT_MS @@ -425,20 +494,29 @@ class PacketRepositoryImpl( com.dct.app.data.protocol.DctResponseValidator.validateSetup(DctProtocolRequests.startAll(), response) }.isSuccess) { _isStreaming.value = true + startMayHaveReachedDevice = false transition(DeviceConnectionState.MEASURING) DctResult.Success(Unit) - } else if (result is DctResult.Failure) result - else failure("장비가 계측 시작을 거절했습니다.") + } else if (response?.payload?.let { it.size == 2 && it[1] == 1.toByte() && it[0] != 1.toByte() } == true) { + startMayHaveReachedDevice = false // An explicit NACK is not an ambiguous timeout. + failure("장비가 계측 시작을 거절했습니다.") + } else { + // Missing/malformed replies must not implicitly STOP or disconnect + // after a playback request. Do not claim measurement success either. + // Keep the uncertain-START flag so explicit Disconnect still sends STOP. + failure("계측 시작 응답을 확인할 수 없습니다. 통신 연결은 유지합니다. 필요하면 Disconnect로 정리한 뒤 다시 연결하세요.") + } } override suspend fun stopStreaming(): DctResult = withContext(ioDispatcher) { - if (_connectionState.value != DeviceConnectionState.MEASURING) { + if (_connectionState.value != DeviceConnectionState.MEASURING && !startMayHaveReachedDevice) { return@withContext failure("현재 계측 중이 아닙니다.") } val result = sendCommand(DctProtocolRequests.stop()) val response = (result as? DctResult.Success)?.data if (response != null && (response.payload.isEmpty() || response.payload[0] == STATUS_SUCCESS)) { _isStreaming.value = false + startMayHaveReachedDevice = false transition(DeviceConnectionState.READY) DctResult.Success(Unit) } else if (result is DctResult.Failure && @@ -450,6 +528,7 @@ class PacketRepositoryImpl( // even when the peer's ACK is late or lost, while preserving the // live TCP/P2P connection for the next command or retry. _isStreaming.value = false + startMayHaveReachedDevice = false transition(DeviceConnectionState.READY) android.util.Log.w( "DctPacketRepository", @@ -566,6 +645,7 @@ class PacketRepositoryImpl( // PC는 패킷 종류와 무관하게 정상 수신이 계속되면 장비가 // 살아 있는 것으로 판단해 연결 감시 시간을 갱신한다. inboundPacketGeneration.incrementAndGet() + lastInboundMillis.set(monotonicTimeMillis()) // Validate and enqueue the stream ACK before publishing to // UI/log subscribers. SharedFlow emission is intentionally // lossless and may suspend on a slow consumer; ACKs must not @@ -678,36 +758,45 @@ class PacketRepositoryImpl( } /** - * [ISSUE-R1-02] 연결 수립 시 백그라운드 코루틴으로 주기적(3초 기본)으로 - * DctCommand.CMD_POSSIBLE_TO_CONNECT (0x05)를 발송하고 생존 여부를 확인합니다. + * PC처럼 정상 수신 자체를 생존 확인으로 사용한다. 마지막 수신 뒤 + * 3초(기본) 동안 조용할 때만 0x05를 보내 MDB/XCP ACK 흐름과의 경합을 줄인다. */ private fun startHeartbeatLoop() { if (!enableHeartbeat) return if (heartbeatJob?.isActive == true) return + // A new connection starts its own silence budget; never inherit an old session's time. + lastInboundMillis.set(monotonicTimeMillis()) heartbeatJob = scope.launch(heartbeatDispatcher) { var consecutiveFailures = 0 + var waitMillis = heartbeatIntervalMs.coerceAtLeast(1L) while (isActive && hasLiveTransport() && (_connectionState.value == DeviceConnectionState.READY || _connectionState.value == DeviceConnectionState.MEASURING) ) { - kotlinx.coroutines.delay(heartbeatIntervalMs) + kotlinx.coroutines.delay(waitMillis) if (!isActive || !hasLiveTransport() || (_connectionState.value != DeviceConnectionState.READY && _connectionState.value != DeviceConnectionState.MEASURING) ) break + waitMillis = remainingHeartbeatSilenceMillis() + if (waitMillis > 0L) { + consecutiveFailures = 0 + continue + } + val sessionId = wifiSocketManager?.currentSessionId ?: break val success = performHeartbeat() + waitMillis = heartbeatIntervalMs.coerceAtLeast(1L) consecutiveFailures = if (success) 0 else consecutiveFailures + 1 if (!success && consecutiveFailures >= heartbeatFailureThreshold.coerceAtLeast(1) && isActive && hasLiveTransport() && (_connectionState.value == DeviceConnectionState.READY || _connectionState.value == DeviceConnectionState.MEASURING) ) { - // 0x05/0x85는 보조 생존 진단이다. 장비/테스트 프로그램이 - // 제어 응답이나 대용량 스트림을 늦게 보내는 동안 Heartbeat만 - // 누락될 수 있으므로 이것만으로 정상 TCP를 닫지 않는다. - // 실제 단선은 TCP EOF/I/O 오류 또는 P2P 그룹 해제에서 확정한다. - reportHeartbeatTimeout(consecutiveFailures) + // Before playback retain the connection watchdog. After a + // START request, silence is diagnostic only until actual + // transport loss or explicit Disconnect, including after STOP. + if (closeUnresponsiveSession(sessionId, consecutiveFailures)) break consecutiveFailures = 0 } } @@ -719,6 +808,11 @@ class PacketRepositoryImpl( heartbeatJob = null } + private fun remainingHeartbeatSilenceMillis(): Long { + val elapsed = (monotonicTimeMillis() - lastInboundMillis.get()).coerceAtLeast(0L) + return (heartbeatIntervalMs.coerceAtLeast(1L) - elapsed).coerceAtLeast(0L) + } + /** * 계측 중(isMeasuring)일 때의 데이터 스트림 및 다른 동기 제어 명령과 충돌하지 않도록 * 안전하게 Mutex를 조율하며 0x05 발송 및 0x85 응답을 대기합니다. @@ -727,7 +821,6 @@ class PacketRepositoryImpl( if (!hasLiveTransport()) return false val sessionId = wifiSocketManager?.currentSessionId ?: return false val expected = DctCommand.RES_POSSIBLE_TO_CONNECT.code - val inboundGenerationBeforeProbe = inboundPacketGeneration.get() // 다른 제어 명령(UI 파라미터 변경 등)이 진행 중이면 이미 통신이 활성화된 상태이므로 경합을 피함 val locked = requestMutex.tryLock() @@ -735,10 +828,14 @@ class PacketRepositoryImpl( return true } + val inboundGenerationBeforeProbe = inboundPacketGeneration.get() val deferred = CompletableDeferred() - pendingResponses[expected] = deferred val packet = DctProtocolRequests.possibleToConnect().copy(transportSessionId = sessionId) try { + // Receive/ACK handling can run between the timer check and acquiring this mutex. + // Recheck immediately before registering/sending a probe; do not delay stream ACKs. + if (remainingHeartbeatSilenceMillis() > 0L) return true + pendingResponses[expected] = deferred if (!sendLive(packet)) { pendingResponses.remove(expected, deferred) return false @@ -769,13 +866,24 @@ class PacketRepositoryImpl( } } - /** Heartbeat 누락은 진단만 남기며 활성 TCP 세션의 상태를 변경하지 않는다. */ - private fun reportHeartbeatTimeout(consecutiveFailures: Int) { + private fun closeUnresponsiveSession(sessionId: Long, consecutiveFailures: Int): Boolean { + if (playbackTransportSessionId == sessionId) { + android.util.Log.w("DctPacketRepository", + "재생 요청 이후 수신 지연: session=$sessionId, 정상 패킷/Heartbeat 응답이 없어도 TCP 연결을 유지합니다.") + return false + } + // Close before transition(): ERROR cancels this heartbeat job. The + // session check and socket invalidation are atomic inside the manager. + if (wifiSocketManager?.invalidateConnectionIfSession(sessionId) != true) return false android.util.Log.w( "DctPacketRepository", - "Alive Check 경고: 하트비트와 정상 데이터 수신이 연속 ${consecutiveFailures}회 없었지만 " + - "TCP 연결은 유지합니다. 실제 EOF/I/O 오류 또는 Wi-Fi Direct 그룹 해제를 기다립니다." + "장비 무응답: session=$sessionId, 하트비트와 정상 데이터 수신이 " + + "${heartbeatTimeoutMs}ms 동안 없음 (${consecutiveFailures}회). PC와 동일하게 TCP 연결을 종료합니다." ) + _isStreaming.value = false + failPendingRequests("장비 응답이 없어 TCP 연결을 종료했습니다.") + transition(DeviceConnectionState.ERROR) + return true } private fun failPendingRequests(reason: String) { @@ -817,10 +925,12 @@ class PacketRepositoryImpl( // payload copies in memory. const val DEFAULT_DISPATCH_QUEUE_CAPACITY = 512 private const val DEFAULT_RESPONSE_TIMEOUT_MS = 3_000L + private const val VERSION_ENV_GAP_MS = 200L + private const val DID_ENV_GAP_MS = 20L private const val START_STREAM_RESPONSE_TIMEOUT_MS = 15_000L const val DEFAULT_HEARTBEAT_INTERVAL_MS = 3_000L const val DEFAULT_HEARTBEAT_TIMEOUT_MS = 5_000L - const val DEFAULT_HEARTBEAT_FAILURE_THRESHOLD = 2 + const val DEFAULT_HEARTBEAT_FAILURE_THRESHOLD = 1 private const val RESTART_STOP_BOUNDARY_TIMEOUT_MS = 12_000L private const val STOP_BOUNDARY_POLL_MS = 25L private const val STATUS_SUCCESS: Byte = 0x01 diff --git a/Android/app/src/main/java/com/dct/app/domain/model/protocol/DctMeasurementConfiguration.kt b/Android/app/src/main/java/com/dct/app/domain/model/protocol/DctMeasurementConfiguration.kt index 6853543..94c3104 100644 --- a/Android/app/src/main/java/com/dct/app/domain/model/protocol/DctMeasurementConfiguration.kt +++ b/Android/app/src/main/java/com/dct/app/domain/model/protocol/DctMeasurementConfiguration.kt @@ -16,7 +16,8 @@ data class DctMeasurementConfiguration( val canMessages: List = emptyList(), val sensorModules: List = emptyList(), val isHighSpeedCan: Boolean = true, - val isOldModel: Boolean = false + val isOldModel: Boolean = false, + val didPreSetting: Int = 0 ) data class DctCanMessageConfiguration( diff --git a/Android/app/src/main/java/com/dct/app/presentation/dialog/WifiApConnectionDialog.kt b/Android/app/src/main/java/com/dct/app/presentation/dialog/WifiApConnectionDialog.kt index 9b3781a..c77f5d8 100644 --- a/Android/app/src/main/java/com/dct/app/presentation/dialog/WifiApConnectionDialog.kt +++ b/Android/app/src/main/java/com/dct/app/presentation/dialog/WifiApConnectionDialog.kt @@ -10,6 +10,7 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.text.KeyboardOptions @@ -39,6 +40,7 @@ fun WifiApConnectionDialog(viewModel: MainViewModel, onDismiss: () -> Unit) { val state by viewModel.wifiApState.collectAsStateWithLifecycle() val ui by viewModel.uiState.collectAsStateWithLifecycle() val socket by viewModel.wifiSocketManager.isConnected.collectAsStateWithLifecycle() + val protocolState by viewModel.packetRepository.connectionState.collectAsStateWithLifecycle() val endpoint by viewModel.wifiApEndpoint.collectAsStateWithLifecycle() val connectedEndpoint by viewModel.wifiSocketManager.connectedEndpoint.collectAsStateWithLifecycle() var requested by remember { mutableStateOf(false) } @@ -72,11 +74,13 @@ fun WifiApConnectionDialog(viewModel: MainViewModel, onDismiss: () -> Unit) { Dialog(onDismissRequest = onDismiss) { WifiApConnectionContent(state.copy(error = settingsError ?: state.error), ui.isConnected, socket, onScan = viewModel::scanWifiAp, onConnect = viewModel::connectWifiAp, - onCancel = viewModel::cancelWifiApConnection, onDisconnect = { viewModel.disconnectCommunication() }, - onRetry = viewModel::retryWifiApTcp, onCurrent = viewModel::connectCurrentWifiAp, + onCancel = viewModel::cancelWifiApConnection, + onDisconnect = { viewModel.disconnectCommunication(releaseWifiAp = true) }, + onCurrent = viewModel::connectCurrentWifiAp, onPermissions = { launcher.launch(permissions) }, onSettings = ::settings, onDismiss = onDismiss, endpoint = endpoint, connectedEndpoint = connectedEndpoint, onEndpointChange = viewModel::setWifiApEndpoint, - canEditEndpoint = !ui.isMeasuring && ui.recordingState !in setOf( + communicationFailed = protocolState == com.dct.app.domain.repository.DeviceConnectionState.ERROR, + canEditEndpoint = !ui.isCommunicationConnecting && !ui.isMeasuring && ui.recordingState !in setOf( com.dct.app.presentation.main.MeasurementRecordingState.STARTING, com.dct.app.presentation.main.MeasurementRecordingState.RECORDING, com.dct.app.presentation.main.MeasurementRecordingState.STOPPING) && !state.busy) @@ -87,10 +91,11 @@ fun WifiApConnectionDialog(viewModel: MainViewModel, onDismiss: () -> Unit) { internal fun WifiApConnectionContent( state: WifiApUiState, communicationReady: Boolean, socketConnected: Boolean, onScan: () -> Unit, onConnect: (WifiApDevice, String) -> Unit, onCancel: () -> Unit, - onDisconnect: () -> Unit, onRetry: () -> Unit, onCurrent: () -> Unit, + onDisconnect: () -> Unit, onCurrent: () -> Unit, onPermissions: () -> Unit, onSettings: (String) -> Unit, onDismiss: () -> Unit, endpoint: WifiApEndpoint = WifiApEndpoint(), canEditEndpoint: Boolean = true, - onEndpointChange: (WifiApEndpoint) -> Boolean = { true }, connectedEndpoint: WifiApEndpoint? = null + onEndpointChange: (WifiApEndpoint) -> Boolean = { true }, connectedEndpoint: WifiApEndpoint? = null, + communicationFailed: Boolean = false ) { var selected by remember { mutableStateOf(null) } var addingNetwork by remember { mutableStateOf(false) } @@ -98,14 +103,19 @@ internal fun WifiApConnectionContent( var showDetails by remember { mutableStateOf(false) } var connectedDetailsKey by remember { mutableStateOf(null) } var editEndpoint by remember { mutableStateOf(false) } + val networkListState = rememberLazyListState() + val connectedNetworkKeys = state.connectedNetworks.map { it.key }.toSet() + LaunchedEffect(state.networkReady, state.activeNetworkKey, connectedNetworkKeys) { + // Stable item keys otherwise keep the old visible AP anchored when a + // newly connected row moves to the top. Scan/RSSI updates must not jump. + if (state.networkReady || connectedNetworkKeys.isNotEmpty()) networkListState.scrollToItem(0) + } val endpointEditable = canEditEndpoint && !state.busy - // This shortcut uses the existing single-IPv4 Wi-Fi path, never an arbitrary second network. - val canConnectCurrent = endpointEditable && endpoint.isValid && state.enabled && state.permissionGranted && - !communicationReady && !socketConnected && state.connectedNetworks.count { it.localAddress != null } == 1 val connectedStatus = when { communicationReady -> "연결됨" + communicationFailed -> "통신 연결 실패" socketConnected -> "장비 초기화 중…" - else -> "통신 연결 대기" + else -> "Wi-Fi 연결됨" } val configuredAddress = "접속 설정: ${endpoint.host}:${endpoint.port}" val currentAddress = if (!state.busy && (socketConnected || communicationReady)) connectedEndpoint?.let { @@ -171,7 +181,7 @@ internal fun WifiApConnectionContent( style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error) } LazyColumn(Modifier.fillMaxWidth().weight(1f, fill = false).heightIn(max = 320.dp) - .testTag("wifi:ap-list")) { + .testTag("wifi:ap-list"), state = networkListState) { if (state.networkReady || state.busy) item(key = "current") { WifiApNetworkRow(state.selectedSsid ?: "DCT Wi-Fi", status = if (state.busy) "연결 중…" else connectedStatus, @@ -211,8 +221,6 @@ internal fun WifiApConnectionContent( Row(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp, androidx.compose.ui.Alignment.End)) { if (state.busy) TextButton(onClick = onCancel) { Text("연결 취소") } - Button(enabled = canConnectCurrent, onClick = onCurrent, - modifier = Modifier.testTag("wifi:connected-connect")) { Text("통신 연결") } TextButton(onClick = onDismiss) { Text("닫기") } } } @@ -314,9 +322,6 @@ internal fun WifiApConnectionContent( TextButton(enabled = endpointEditable, onClick = { showDetails = false; editEndpoint = true }) { Text("IP / 포트 설정") } - if (state.networkReady && !communicationReady) TextButton(onClick = { - showDetails = false; onRetry() - }) { Text("통신 재연결") } TextButton(onClick = { showDetails = false }) { Text("확인") } } }) @@ -326,23 +331,20 @@ internal fun WifiApConnectionContent( var port by remember { mutableStateOf(endpoint.port.toString()) } var saveError by remember { mutableStateOf(false) } val target = WifiApEndpoint.fromInput(WifiConnectionInput(ip, port)) - fun applyTarget(connectNow: Boolean) { + fun applyTarget() { if (target == null || !endpointEditable || !onEndpointChange(target)) { saveError = true return } editEndpoint = false - if (connectNow) { - if (state.networkReady) onRetry() else onCurrent() - } } AlertDialog(onDismissRequest = { editEndpoint = false }, title = { Text("IP / 포트 설정") }, text = { Column(Modifier.verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(12.dp)) { - Text("PC와 같은 Wi-Fi에서 PC의 IPv4 주소를 입력하세요. 테스트 서버 기본 포트는 6000입니다.", - style = MaterialTheme.typography.bodySmall) + Text("장치와 같은 Wi-Fi에 연결한 뒤 장치의 IPv4 주소와 포트를 입력하세요. 기본값은 ${DctWifiAp.HOST}:${DctWifiAp.PORT}입니다.", + style = MaterialTheme.typography.bodySmall, modifier = Modifier.testTag("wifi:tcp-guide")) OutlinedTextField(ip, onValueChange = { ip = it; saveError = false }, label = { Text("IP 주소") }, singleLine = true, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), @@ -364,11 +366,8 @@ internal fun WifiApConnectionContent( dismissButton = { TextButton(onClick = { editEndpoint = false }) { Text("취소") } }, confirmButton = { Row { - TextButton(enabled = target != null && endpointEditable, onClick = { applyTarget(false) }, + TextButton(enabled = target != null && endpointEditable, onClick = { applyTarget() }, modifier = Modifier.testTag("wifi:tcp-save")) { Text("저장") } - TextButton(enabled = target != null && endpointEditable && state.enabled && - state.permissionGranted && !state.busy, onClick = { applyTarget(true) }, - modifier = Modifier.testTag("wifi:tcp-connect")) { Text("저장 후 연결") } } }) } diff --git a/Android/app/src/main/java/com/dct/app/presentation/main/MainActivity.kt b/Android/app/src/main/java/com/dct/app/presentation/main/MainActivity.kt index 4822bf1..d1f7af7 100644 --- a/Android/app/src/main/java/com/dct/app/presentation/main/MainActivity.kt +++ b/Android/app/src/main/java/com/dct/app/presentation/main/MainActivity.kt @@ -33,8 +33,12 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.lifecycle.lifecycleScope +import com.dct.app.DctApplication +import com.dct.app.background.DctSessionRuntime import com.dct.app.core.theme.DctTheme import com.dct.app.core.theme.DctThemeTokens +import kotlinx.coroutines.launch private const val PERMISSION_EXPLANATION_PREFS = "dct_permission_explanation" private const val STORAGE_EXPLANATION_SHOWN = "storage_explanation_shown" @@ -43,8 +47,13 @@ private const val STORAGE_EXPLANATION_SHOWN = "storage_explanation_shown" * DCT 안드로이드 태블릿 메인 액티비티 */ class MainActivity : ComponentActivity() { + private lateinit var activitySession: DctSessionRuntime + var programExitRequested by mutableStateOf(false) + private set + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + activitySession = (application as DctApplication).session // 처음 실행할 때 시스템 권한 화면으로 바로 이동하지 않고, 먼저 // 권한의 용도와 제한 사항을 설명한다. 실제 요청은 사용자가 @@ -90,6 +99,9 @@ class MainActivity : ComponentActivity() { BackgroundExecutionPermissionDialogs(backgroundRunning, showStoragePermissionExplanation) } } + // A configuration change must not strand the exit dialog or abandon cleanup. + // Keep this request only in the live session, never in process-death saved state. + if (activitySession.programExitRequested) exitProgram() } override fun onResume() { @@ -102,14 +114,33 @@ class MainActivity : ComponentActivity() { override fun onStart() { super.onStart() - (application as com.dct.app.DctApplication).session.onUiStarted() + activitySession.onUiStarted() } override fun onStop() { - (application as com.dct.app.DctApplication).session.onUiStopped() + activitySession.onUiStopped() super.onStop() } + fun exitProgram() { + if (programExitRequested) return + programExitRequested = true + activitySession.requestProgramExit() + lifecycleScope.launch { + activitySession.viewModel.autoSaveOnExit(applicationContext) + // Do not clear the shared ViewModel while STOP/recording finalization is pending. + activitySession.viewModel.disconnectCommunication(applicationContext, releaseWifiAp = true)?.join() + finishAndRemoveTask() + } + } + + override fun onDestroy() { + super.onDestroy() + if (isFinishing && programExitRequested) { + (application as DctApplication).releaseSession(activitySession) + } + } + /** 저장소 권한이 없어 최초 안내가 필요한지 확인한다. */ private fun storagePermissionRequired(): Boolean { return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { diff --git a/Android/app/src/main/java/com/dct/app/presentation/main/MainViewModel.kt b/Android/app/src/main/java/com/dct/app/presentation/main/MainViewModel.kt index e7c65e6..543ff5b 100644 --- a/Android/app/src/main/java/com/dct/app/presentation/main/MainViewModel.kt +++ b/Android/app/src/main/java/com/dct/app/presentation/main/MainViewModel.kt @@ -33,12 +33,15 @@ import com.dct.app.presentation.signaltree.AddToChartRequest import com.dct.app.presentation.signaltree.SignalTreeViewModel import com.dct.app.presentation.signaltree.TreeItemSelection import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.withContext import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch @@ -94,6 +97,7 @@ data class MainUiState( val dashboardTiles: List = emptyList(), val currentSelection: TreeItemSelection? = null, val isConnected: Boolean = false, + val isCommunicationConnecting: Boolean = false, val communicationType: CommunicationType = CommunicationType.NONE, val connectedDeviceName: String? = null, val statusMessage: String? = null, @@ -125,6 +129,20 @@ class MainViewModel( private val calibrationMutex = kotlinx.coroutines.sync.Mutex() private var calibrationJob: Job? = null private val sessionMutex = kotlinx.coroutines.sync.Mutex() + private val sessionControlJobs = java.util.concurrent.ConcurrentHashMap.newKeySet() + + /** Register before starting so Disconnect can cancel even a queued command. */ + private fun launchSessionControl(block: suspend () -> Unit): Job { + val job = scope.launch(dispatchers.io, start = CoroutineStart.LAZY) { block() } + sessionControlJobs.add(job) + job.invokeOnCompletion { sessionControlJobs.remove(job) } + job.start() + return job + } + + private fun cancelSessionControls() { + sessionControlJobs.toList().forEach { it.cancel() } + } private val recordingMutex = kotlinx.coroutines.sync.Mutex() // A whole CSV/MF4 sample and start/close are indivisible, including STOP ACK/disconnect paths. private val recordingSampleLock = Any() @@ -134,8 +152,10 @@ class MainViewModel( @Volatile private var configurationRequired = false private val chartTelemetryResolver = ChartTelemetryResolver() private val formulaTelemetryLock = Any() + private val telemetryResetLock = Any() private val formulaTelemetryEngine = com.dct.app.core.formula.FormulaTelemetryEngine() private var configuredMdbSelection: List>? = null + private var configuredCanMessages: List = emptyList() private val _isCalibrationBusy = MutableStateFlow(false) val isCalibrationBusy: StateFlow = _isCalibrationBusy.asStateFlow() @@ -197,7 +217,7 @@ class MainViewModel( try { val nowSec = packet.receivedAtSec.takeIf { it.isFinite() } ?: (packet.receivedAtMillis.takeIf { it > 0 } ?: System.currentTimeMillis()) / 1000.0 - processIncomingPacket(packet, nowSec) + synchronized(telemetryResetLock) { processIncomingPacket(packet, nowSec) } } catch (e: kotlinx.coroutines.CancellationException) { throw e } catch (e: Exception) { @@ -237,7 +257,10 @@ class MainViewModel( communicationType = if (isDisconnectedOrError) CommunicationType.NONE else it.communicationType, connectedDeviceName = if (isDisconnectedOrError) null else it.connectedDeviceName, statusMessage = when (state) { - DeviceConnectionState.ERROR -> "Wi-Fi 장비 연결이 종료되었습니다." + // ERROR can be collected after the configuration coroutine + // publishes its cause. Do not replace that recovery guidance. + DeviceConnectionState.ERROR -> if (it.statusMessage == CONFIGURATION_TIMEOUT_MESSAGE) + it.statusMessage else "Wi-Fi 장비 연결이 종료되었습니다." DeviceConnectionState.DISCONNECTED -> if (it.communicationType == CommunicationType.NONE) it.statusMessage else "장비 연결이 해제되었습니다." else -> it.statusMessage } @@ -733,6 +756,27 @@ class MainViewModel( renderers.forEach { it.clearSamplesOnly() } } + /** Debug-only display reset; never changes device settings, recording or project bindings. */ + fun resetDebugValues() { + if (!com.dct.app.BuildConfig.DEBUG) return + // A packet must finish as a whole before its values and graph samples are cleared. + synchronized(telemetryResetLock) { + clearFormulaTelemetry() + synchronized(recordingSampleLock) { latestMf4RawValues.clear() } + resetGraphData() + _uiState.update { current -> + current.copy( + packetCount = 0L, + dashboardTiles = current.dashboardTiles.map { tile -> + tile.historyBuffer.clear() + tile.copy(value = 0.0) + }, + statusMessage = "값을 초기화했습니다. 계측 중에는 다음 수신부터 다시 표시됩니다." + ) + } + } + } + /** * 기본 Signal list MDI 서브윈도우 인스턴스 생성 */ @@ -1034,6 +1078,8 @@ class MainViewModel( companion object { const val MAX_WORKSPACE_PAGES = 5 private const val LOG_TAG = "DctMainViewModel" + private const val CONFIGURATION_TIMEOUT_MESSAGE = + "장비 설정 응답 시간 초과로 통신 연결이 해제되었습니다. Connect로 다시 연결하세요." } /** @@ -1590,9 +1636,11 @@ class MainViewModel( if (!ensureBackgroundExecution()) return pendingWifiTarget.set(target) val generation = connectionGeneration.incrementAndGet() + cancelSessionControls() + _uiState.update { it.copy(isCommunicationConnecting = true) } configurationRequired = true val roleLabel = if (isGroupOwner) "서버(GO)" else "클라이언트" - scope.launch(dispatchers.io) { + launchSessionControl { sessionMutex.withLock { if (generation != connectionGeneration.get()) return@withLock if (_uiState.value.isMeasuring || packetRepository.isConnected.value) { @@ -1609,7 +1657,7 @@ class MainViewModel( wifiSocketManager.startDualMode( isGroupOwner = isGroupOwner, hostIp = hostIp, port = port, onConnected = { _, remoteAddress -> - scope.launch(dispatchers.io) { + launchSessionControl { sessionMutex.withLock { if (generation != connectionGeneration.get()) return@withLock connectedWifiTarget.set(target) @@ -1635,7 +1683,10 @@ class MainViewModel( DctResult.Loading -> Unit } } finally { - if (generation == connectionGeneration.get()) pendingWifiTarget.compareAndSet(target, null) + if (generation == connectionGeneration.get()) { + pendingWifiTarget.compareAndSet(target, null) + _uiState.update { it.copy(isCommunicationConnecting = false) } + } } } } @@ -1650,7 +1701,7 @@ class MainViewModel( onFailure = { error -> if (generation == connectionGeneration.get()) { pendingWifiTarget.compareAndSet(target, null) - _uiState.update { it.copy(isConnected = false, communicationType = CommunicationType.NONE, + _uiState.update { it.copy(isConnected = false, isCommunicationConnecting = false, communicationType = CommunicationType.NONE, connectedDeviceName = null, statusMessage = "Wi-Fi 연결 실패: $error") } } } @@ -1666,10 +1717,39 @@ class MainViewModel( val wifiApState = _wifiApState.asStateFlow() private val _wifiApEndpoint = MutableStateFlow(com.dct.app.data.communication.WifiApEndpoint()) val wifiApEndpoint = _wifiApEndpoint.asStateFlow() + val canConnectCommunication = combine( + _wifiApState, _wifiApEndpoint, _uiState, wifiSocketManager.isConnected + ) { ap, endpoint, ui, socket -> canStartWifiCommunication(ap, endpoint, ui, socket) } + .stateIn(scope, kotlinx.coroutines.flow.SharingStarted.Eagerly, false) private var wifiEndpointPreferences: com.dct.app.data.communication.WifiConnectionPreferences? = null + private fun canStartWifiCommunication( + ap: com.dct.app.data.communication.WifiApUiState, + endpoint: com.dct.app.data.communication.WifiApEndpoint, + ui: MainUiState, + socket: Boolean + ): Boolean = ap.enabled && ap.permissionGranted && !ap.busy && endpoint.isValid && + (ap.networkReady || ap.connectedNetworks.count { it.localAddress != null } == 1) && + !ui.isConnected && !ui.isCommunicationConnecting && !socket && !ui.isMeasuring && + ui.recordingState !in setOf(MeasurementRecordingState.STARTING, + MeasurementRecordingState.RECORDING, MeasurementRecordingState.STOPPING) + + /** Only the ribbon Connect action may start TCP and the DCT handshake. */ + fun connectCommunication() { + val manager = registeredWifiApManager ?: return + if (!canStartWifiCommunication(manager.uiState.value, _wifiApEndpoint.value, + _uiState.value, wifiSocketManager.isConnected.value)) return + // Adopting a Settings-connected network is synchronous when IPv4 is ready. + // onReady itself deliberately never starts protocol traffic. + if (!manager.uiState.value.networkReady) manager.connectCurrent() + val ap = manager.uiState.value + if (!ap.networkReady) return + val endpoint = _wifiApEndpoint.value + connectWifi(ap.selectedSsid ?: "Wi-Fi", endpoint.host, endpoint.port) + } + fun setWifiApEndpoint(endpoint: com.dct.app.data.communication.WifiApEndpoint): Boolean { - if (!endpoint.isValid || _uiState.value.isMeasuring || + if (!endpoint.isValid || _uiState.value.isCommunicationConnecting || _uiState.value.isMeasuring || _uiState.value.recordingState in setOf(MeasurementRecordingState.STARTING, MeasurementRecordingState.RECORDING, MeasurementRecordingState.STOPPING) || registeredWifiApManager?.uiState?.value?.busy == true) return false @@ -1692,7 +1772,7 @@ class MainViewModel( .onFailure { Log.w(LOG_TAG, "이전 측정 파일 SAF 내보내기 재시도 실패", it) } } val manager = com.dct.app.data.communication.WifiApManager(context.applicationContext, - onReady = { name -> val endpoint = _wifiApEndpoint.value; connectWifi(name, endpoint.host, endpoint.port) }, + onReady = { /* Wi-Fi/IP readiness alone must not start TCP or the DCT protocol. */ }, onLost = { closeWifiTransportAfterGroupLoss("장비 Wi-Fi AP 연결이 해제되었습니다.") }, onConnecting = { closeWifiTransportAfterGroupLoss(null) }, endpointProvider = { _wifiApEndpoint.value }) @@ -1706,21 +1786,16 @@ class MainViewModel( fun refreshWifiAp() { registeredWifiApManager?.refresh() } fun scanWifiAp() { registeredWifiApManager?.scan() } fun connectWifiAp(device: com.dct.app.data.communication.WifiApDevice, password: String) { - if (registeredWifiApManager?.uiState?.value?.canScan == true && ensureBackgroundExecution()) + if (registeredWifiApManager?.uiState?.value?.canScan == true) registeredWifiApManager?.connect(device, password) } fun connectCurrentWifiAp() { - if (ensureBackgroundExecution()) registeredWifiApManager?.connectCurrent() + registeredWifiApManager?.connectCurrent() } fun cancelWifiApConnection() { registeredWifiApManager?.disconnect() closeWifiTransportAfterGroupLoss("Wi-Fi AP 연결을 취소했습니다.") } - fun retryWifiApTcp() { - val endpoint = _wifiApEndpoint.value - if (registeredWifiApManager?.uiState?.value?.networkReady == true) - connectWifi(wifiApState.value.selectedSsid ?: "Wi-Fi", endpoint.host, endpoint.port) - } private val _wifiDirectState = MutableStateFlow(com.dct.app.data.communication.WifiDirectUiState()) val wifiDirectState: StateFlow = _wifiDirectState.asStateFlow() @@ -1781,9 +1856,11 @@ class MainViewModel( private fun closeWifiTransportAfterGroupLoss(message: String? = "Wi-Fi Direct 연결이 해제되었습니다.") { val generation = connectionGeneration.incrementAndGet() + cancelSessionControls() pendingWifiTarget.set(null) connectedWifiTarget.set(null) configurationRequired = true + _uiState.update { it.copy(isCommunicationConnecting = false) } // Invalidate immediately: queued cleanup must never close a newly formed group's socket. wifiSocketManager.invalidateConnection() scope.launch(dispatchers.io) { @@ -1802,23 +1879,24 @@ class MainViewModel( * Wi-Fi AP (or retained P2P) / TCP 통신 연결 해제. */ @Synchronized - fun disconnectCommunication(context: Context? = null) { - if (registeredWifiApManager?.uiState?.value?.busy == true) registeredWifiApManager?.disconnect() + fun disconnectCommunication(context: Context? = null, releaseWifiAp: Boolean = false): Job? { + if (releaseWifiAp && registeredWifiApManager?.uiState?.value?.busy == true) registeredWifiApManager?.disconnect() registeredWifiDirectManager?.takeIf { registeredWifiApManager == null }?.let { manager -> when { manager.uiState.value.link != null -> manager.disconnect() manager.uiState.value.phase == com.dct.app.data.communication.WifiDirectPhase.CONNECTING -> manager.cancelConnect() else -> closeWifiTransportAfterGroupLoss() } - return + return null } val disconnectGeneration = connectionGeneration.incrementAndGet() // Invalidate pending callbacks immediately. + cancelSessionControls() pendingWifiTarget.set(null) connectedWifiTarget.set(null) configurationRequired = true - _uiState.update { it.copy(isConnected = false, communicationType = CommunicationType.NONE, + _uiState.update { it.copy(isConnected = false, isCommunicationConnecting = false, communicationType = CommunicationType.NONE, connectedDeviceName = null, statusMessage = "통신 연결 해제 중...") } - scope.launch(dispatchers.io) { + return scope.launch(dispatchers.io) { sessionMutex.withLock { if (disconnectGeneration != connectionGeneration.get()) return@withLock try { @@ -1828,7 +1906,7 @@ class MainViewModel( finishMeasurementSession() wifiSocketManager.disconnectSuspend() // Keep the AP lease until STOP and log finalization have completed. - registeredWifiApManager?.disconnect() + if (releaseWifiAp) registeredWifiApManager?.disconnect() _uiState.update { it.copy(isConnected = false, isMeasuring = false, communicationType = CommunicationType.NONE, connectedDeviceName = null, statusMessage = "통신 연결이 해제되었습니다.") @@ -3189,11 +3267,15 @@ class MainViewModel( fun syncDeviceConfiguration(onComplete: ((Boolean) -> Unit)? = null) { configurationRequired = true val generation = connectionGeneration.get() - scope.launch(dispatchers.io) { - val success = sessionMutex.withLock { - generation == connectionGeneration.get() && syncDeviceConfigurationLocked(generation) + launchSessionControl { + var success = false + try { + success = sessionMutex.withLock { + generation == connectionGeneration.get() && syncDeviceConfigurationLocked(generation) + } + } finally { + onComplete?.invoke(success) } - onComplete?.invoke(success) } } @@ -3210,15 +3292,23 @@ class MainViewModel( } val configuration = createDeviceMeasurementConfiguration() val configResult = packetRepository.configureMeasurement(configuration) + if (generation != connectionGeneration.get()) return false if (configResult !is DctResult.Success) { - val message = if (configResult is DctResult.Failure) "장비 설정 실패: ${configResult.error.message}" - else "장비 설정이 아직 완료되지 않았습니다." + val message = when { + configResult is DctResult.Failure && + configResult.error is com.dct.app.core.error.DctError.ProtocolError.ResponseTimeout && + !packetRepository.isConnected.value -> + CONFIGURATION_TIMEOUT_MESSAGE + configResult is DctResult.Failure -> "장비 설정 실패: ${configResult.error.message}" + else -> "장비 설정이 아직 완료되지 않았습니다." + } _uiState.update { it.copy(statusMessage = message) } return false } if (generation != connectionGeneration.get()) return false configuredMdbSelection = configuration.selectedMdbSignals.map { it.reclid to it.idx } + configuredCanMessages = configuration.canMessages configurationRequired = false _uiState.update { it.copy(statusMessage = "Wi-Fi 장비 연결 및 설정 완료 (Ready)") } return true @@ -3231,7 +3321,7 @@ class MainViewModel( fun startMeasure(frequencyHz: Int = 30) { if (packetRepository.connectionState.value == DeviceConnectionState.READY && !ensureBackgroundExecution()) return val generation = connectionGeneration.get() - scope.launch(dispatchers.io) { + launchSessionControl { sessionMutex.withLock { if (generation != connectionGeneration.get() || _uiState.value.isMeasuring) return@withLock if (!_uiState.value.canDbcFileName.isNullOrBlank() && _canSignals.value.isEmpty()) { @@ -3247,14 +3337,15 @@ class MainViewModel( setStatusMessage("Wi-Fi 장비 연결을 먼저 완료하세요.") return@withLock } - // Formulas/graph bindings can change after the initial connection handshake. - // Register their raw MDB inputs before START, while the device is still READY. + // Bindings can change after Connect. Register MDB inputs and selected + // CAN messages before START; a loaded DBC is not a monitoring selection. val mdbSignals = _uiState.value.mdbDatabase?.allSignals.orEmpty() - if (mdbSignals.isNotEmpty()) { - val selected = selectVisibleMdbSignals(mdbSignals).map { it.reclid to it.idx } - if (selected != configuredMdbSelection && !syncDeviceConfigurationLocked(generation, notifyProgress = false)) { - return@withLock - } + val configuration = createDeviceMeasurementConfiguration() + val mdbChanged = mdbSignals.isNotEmpty() && + configuration.selectedMdbSignals.map { it.reclid to it.idx } != configuredMdbSelection + if ((mdbChanged || configuration.canMessages != configuredCanMessages) && + !syncDeviceConfigurationLocked(generation, notifyProgress = false)) { + return@withLock } setStatusMessage("계측 시작 요청 중...") measurementRequestMillis = System.currentTimeMillis() @@ -3265,7 +3356,9 @@ class MainViewModel( beginMeasurementSession(frequencyHz) } } - is DctResult.Failure -> _uiState.update { it.copy(isMeasuring = false, statusMessage = result.error.message) } + is DctResult.Failure -> if (generation == connectionGeneration.get()) { + _uiState.update { it.copy(isMeasuring = false, statusMessage = result.error.message) } + } DctResult.Loading -> Unit } } @@ -3280,14 +3373,22 @@ class MainViewModel( private fun createDeviceMeasurementConfiguration(): com.dct.app.domain.model.protocol.DctMeasurementConfiguration { val project = _currentProject.value ?: com.dct.app.data.repository.DpcProjectManager.createDefaultProject() - val maxDlc = if (project.canMode == "CAN(FD)") 64 else 8 - val messages = _canSignals.value - .filter { it.messageId in 0L..0xFFFFL } + val canSignals = _canSignals.value + val selectedKeys = selectedMeasurementKeys() + // PC CheckCANDBCIDList registers messages referenced by selected signals, + // not every schema entry in the loaded DBC. Keep the complete message DLC. + val selectedCanIds = canSignals.asSequence() + .filter { signal -> selectedKeys.any { com.dct.app.domain.model.mdb.CanSignalKey.matches(it, signal) } } + .map { it.messageId }.toHashSet() + val messages = canSignals + .filter { it.messageId in 0L..0xFFFFL && it.messageId in selectedCanIds } .groupBy { it.messageId } .map { (canId, signals) -> val declaredDlc = signals.maxOfOrNull { it.messageDlc } ?: 0 - com.dct.app.domain.model.protocol.DctCanMessageConfiguration(canId, declaredDlc.coerceAtMost(maxDlc)) - } + // PC SendCANDBCIDData preserves the DBC byte length regardless of CAN mode. + // Mode selection is sent separately; silently truncating FD lengths changes the schema. + com.dct.app.domain.model.protocol.DctCanMessageConfiguration(canId, declaredDlc) + }.sortedBy { it.canId } Log.i( LOG_TAG, "CAN 0x11 설정 준비: signals=${_canSignals.value.size}, messages=${messages.size}, " + @@ -3308,14 +3409,13 @@ class MainViewModel( canMessages = messages, sensorModules = _sensorModules.value, isHighSpeedCan = project.canMode == "CAN(HiSpeed)", - isOldModel = project.forOldModel + isOldModel = project.forOldModel, + didPreSetting = project.didInfo.preSetting ) } - /** Register visible MDB signals and Formula inputs in 0x13, even without raw graph bindings. */ - private fun selectVisibleMdbSignals( - mdbSignals: List - ): List { + /** Selection snapshot for configuration only; never scan definitions for each packet. */ + private fun selectedMeasurementKeys(): Set { val selectedKeys = mutableSetOf() fun addSeries(series: com.dct.app.domain.model.chart.ChartSignalSeries) { selectedKeys += series.signalId @@ -3343,8 +3443,14 @@ class MainViewModel( if (dependency.startsWith("raw_sig_", ignoreCase = true)) selectedKeys += dependency.substring(4) } } - val foldedKeys = selectedKeys.mapTo(hashSetOf()) { it.lowercase(java.util.Locale.ROOT) } + return selectedKeys.mapTo(hashSetOf()) { it.lowercase(java.util.Locale.ROOT) } + } + /** Register visible MDB signals and Formula inputs in 0x13, even without raw graph bindings. */ + private fun selectVisibleMdbSignals( + mdbSignals: List + ): List { + val foldedKeys = selectedMeasurementKeys() val matched = mdbSignals.filter { signal -> "sig_${signal.reclid}_${signal.idx}" in foldedKeys || signal.name.lowercase(java.util.Locale.ROOT) in foldedKeys } @@ -4102,7 +4208,7 @@ class MainViewModel( */ fun stopMeasure() { val generation = connectionGeneration.get() - scope.launch(dispatchers.io) { + launchSessionControl { sessionMutex.withLock { if (generation != connectionGeneration.get() || !_uiState.value.isMeasuring) return@withLock setStatusMessage("계측 정지 요청 중...") diff --git a/Android/app/src/main/java/com/dct/app/presentation/maintenance/MaintenanceDialog.kt b/Android/app/src/main/java/com/dct/app/presentation/maintenance/MaintenanceDialog.kt index dcf7335..5f84617 100644 --- a/Android/app/src/main/java/com/dct/app/presentation/maintenance/MaintenanceDialog.kt +++ b/Android/app/src/main/java/com/dct/app/presentation/maintenance/MaintenanceDialog.kt @@ -273,7 +273,7 @@ private fun SeedKeyTab(state: SeedKeyState) { Column(Modifier.fillMaxSize().padding(20.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { Text("HKMC ASK 인증 상태", fontWeight = FontWeight.Bold) Text(text) - Text("실제 인증에는 대상 ABI용 libHKMC_ASK.so와 nativeGenerateKey JNI 심볼이 필요합니다. 라이브러리가 없으면 앱은 키를 추정하지 않고 PC 프로토콜 실패 응답(0x32)을 보냅니다.", fontSize = 12.sp) + Text("실제 인증에는 대상 ABI용 libHKMC_ASK.so와 nativeGenerateKey JNI 심볼이 필요합니다. 라이브러리가 없으면 앱은 키를 추정하지 않고 장치 프로토콜 실패 응답(0x32)을 보냅니다.", fontSize = 12.sp) } } diff --git a/Android/app/src/main/java/com/dct/app/presentation/monitor/DiagnosticDataMonitorScreen.kt b/Android/app/src/main/java/com/dct/app/presentation/monitor/DiagnosticDataMonitorScreen.kt index 4be15f8..128d315 100644 --- a/Android/app/src/main/java/com/dct/app/presentation/monitor/DiagnosticDataMonitorScreen.kt +++ b/Android/app/src/main/java/com/dct/app/presentation/monitor/DiagnosticDataMonitorScreen.kt @@ -137,6 +137,7 @@ fun DiagnosticDataMonitorScreen( var showWifiDialog by remember { mutableStateOf(false) } var showMaintenanceDialog by remember { mutableStateOf(false) } var showExitConfirmDialog by remember { mutableStateOf(false) } + val programExitInProgress = (context as? com.dct.app.presentation.main.MainActivity)?.programExitRequested == true var newProjectState by remember { mutableStateOf(com.dct.app.data.repository.DpcProjectManager.createDefaultProject(context)) } var pendingPropertyDocument by remember { mutableStateOf(null) } var pendingNewProjectDocument by remember { mutableStateOf(null) } @@ -503,6 +504,7 @@ fun DiagnosticDataMonitorScreen( androidx.activity.compose.BackHandler { when { + programExitInProgress -> Unit showExitConfirmDialog -> showExitConfirmDialog = false showNewProjectDialog -> { if (!propertyApplying) { @@ -1430,9 +1432,7 @@ fun DiagnosticDataMonitorScreen( Button( onClick = { showExitConfirmDialog = false - viewModel.autoSaveOnExit(context) - viewModel.disconnectCommunication(context.applicationContext) - (context as? android.app.Activity)?.finish() + (context as? com.dct.app.presentation.main.MainActivity)?.exitProgram() }, colors = ButtonDefaults.buttonColors(containerColor = Color(0xFFDC2626)) ) { @@ -1446,6 +1446,15 @@ fun DiagnosticDataMonitorScreen( } ) } + + if (programExitInProgress) { + AlertDialog( + onDismissRequest = {}, + title = { Text("프로그램 종료 중") }, + text = { Text("프로젝트 저장 및 통신·기록 정리를 마무리하고 있습니다.") }, + confirmButton = {} + ) + } } /** diff --git a/Android/app/src/main/java/com/dct/app/presentation/monitor/components/RecordingRibbonLabel.kt b/Android/app/src/main/java/com/dct/app/presentation/monitor/components/RecordingRibbonLabel.kt new file mode 100644 index 0000000..afc8033 --- /dev/null +++ b/Android/app/src/main/java/com/dct/app/presentation/monitor/components/RecordingRibbonLabel.kt @@ -0,0 +1,71 @@ +package com.dct.app.presentation.monitor.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.material3.Text +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.repeatOnLifecycle +import com.dct.app.presentation.main.MeasurementRecordingState +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import java.util.Locale + +/** The timer is UI-only: never publish one-second ticks to the measurement/recording pipeline. */ +@Composable +internal fun RecordingRibbonLabel( + recordingState: MeasurementRecordingState, + startedAtMillis: Long?, + nowMillis: () -> Long = System::currentTimeMillis +) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) { + Text("신호 기록", fontSize = 12.sp, color = ColorTextSecondary) + if (recordingState == MeasurementRecordingState.RECORDING && startedAtMillis != null) { + RecordingElapsedTime(startedAtMillis, nowMillis) + } + } +} + +@Composable +private fun RecordingElapsedTime(startedAtMillis: Long, nowMillis: () -> Long) { + val lifecycleOwner = LocalLifecycleOwner.current + val readNow by rememberUpdatedState(nowMillis) + var currentMillis by remember(startedAtMillis) { mutableLongStateOf(nowMillis()) } + LaunchedEffect(lifecycleOwner, startedAtMillis) { + lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { + while (isActive) { + // Recompute from the real recording start after resume; do not count UI ticks. + currentMillis = readNow() + delay(1_000L) + } + } + } + val elapsed = formatRecordingElapsedTime(startedAtMillis, currentMillis) + Text( + text = elapsed, + fontSize = 12.sp, + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.Medium, + color = Color(0xFFDC2626), + maxLines = 1, + softWrap = false, + modifier = Modifier.testTag("RecordingElapsedTime") + .semantics { contentDescription = "기록 경과 시간 $elapsed" } + ) +} + +internal fun formatRecordingElapsedTime(startedAtMillis: Long, nowMillis: Long): String { + val seconds = ((nowMillis - startedAtMillis).coerceAtLeast(0L)) / 1_000L + return String.format(Locale.US, "%02d:%02d:%02d", seconds / 3_600L, (seconds / 60L) % 60L, seconds % 60L) +} diff --git a/Android/app/src/main/java/com/dct/app/presentation/monitor/components/WorkspaceRibbonBar.kt b/Android/app/src/main/java/com/dct/app/presentation/monitor/components/WorkspaceRibbonBar.kt index 525872e..321f511 100644 --- a/Android/app/src/main/java/com/dct/app/presentation/monitor/components/WorkspaceRibbonBar.kt +++ b/Android/app/src/main/java/com/dct/app/presentation/monitor/components/WorkspaceRibbonBar.kt @@ -81,6 +81,49 @@ fun RibbonButton( } } +@Composable +internal fun WifiConnectionRibbonButtons( + ui: MainUiState, + ap: com.dct.app.data.communication.WifiApUiState, + canConnect: Boolean, + onWifiClick: () -> Unit, + onConnectClick: () -> Unit, + onDisconnectClick: () -> Unit, + onWifiDisconnectClick: () -> Unit +) { + val connected = ui.isConnected && ui.communicationType != CommunicationType.NONE + val wifiConnected = connected && ui.communicationType == CommunicationType.WIFI + RibbonButton( + icon = Icons.Default.WifiOff, + tooltip = "Wi-Fi 연결 해제", + enabled = ap.networkReady || ap.busy, + onClick = onWifiDisconnectClick + ) + RibbonButton( + icon = Icons.Default.Wifi, + tooltip = if (ap.networkReady) "Wi-Fi AP 연결됨 (${ap.selectedSsid})" else "Wi-Fi AP 연결", + enabled = !connected || wifiConnected, + tint = if (ap.networkReady || ap.connectedNetworks.isNotEmpty()) Color(0xFF2563EB) else ColorSecondary, + onClick = onWifiClick + ) + RibbonButton( + icon = Icons.Default.Link, + tooltip = if (ui.isCommunicationConnecting) "장치 통신 연결 중…" else "장치 통신 연결 (Connect)", + label = "Connect", + enabled = canConnect, + tint = if (wifiConnected) Color(0xFF2563EB) else ColorSecondary, + onClick = onConnectClick + ) + RibbonButton( + icon = Icons.Default.LinkOff, + tooltip = "통신 끊기 (Disconnect)", + label = "Disconnect", + enabled = connected || ui.isCommunicationConnecting, + tint = if (connected) Color(0xFFEF4444) else ColorSecondary, + onClick = onDisconnectClick + ) +} + /** * 상단 도구 리본바 컴포넌트 (Figma 1-553 규격 호환) */ @@ -103,7 +146,8 @@ fun WorkspaceRibbonBar( onWifiClick: () -> Unit, onMaintenanceClick: () -> Unit, onResetGraphDataClick: () -> Unit, - onDisconnectClick: () -> Unit = { viewModel.disconnectCommunication() } + onDisconnectClick: () -> Unit = { viewModel.disconnectCommunication() }, + onWifiDisconnectClick: () -> Unit = { viewModel.disconnectCommunication(releaseWifiAp = true) } ) { var showFileMenu by remember { mutableStateOf(false) } val currentProject by viewModel.currentProject.collectAsState() @@ -114,10 +158,12 @@ fun WorkspaceRibbonBar( .fillMaxWidth() .height(44.dp) ) { + Row(modifier = Modifier.fillMaxSize(), verticalAlignment = Alignment.CenterVertically) { Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier - .fillMaxSize() + .weight(1f) + .fillMaxHeight() .horizontalScroll(rememberScrollState()) .padding(horizontal = 6.dp), horizontalArrangement = Arrangement.spacedBy(2.dp) @@ -299,21 +345,14 @@ fun WorkspaceRibbonBar( // Group 2: 통신 연결 val isCommConnected = mainUiState.isConnected && mainUiState.communicationType != CommunicationType.NONE val apState by viewModel.wifiApState.collectAsState() + val canConnectCommunication by viewModel.canConnectCommunication.collectAsState() val isWifiConnected = isCommConnected && mainUiState.communicationType == CommunicationType.WIFI - RibbonButton( - icon = Icons.Default.LinkOff, - tooltip = "통신 끊기 (Disconnect)", - enabled = isCommConnected || apState.networkReady || apState.busy, - tint = if (isCommConnected) Color(0xFFEF4444) else ColorSecondary, - onClick = onDisconnectClick - ) - RibbonButton( - icon = Icons.Default.Wifi, - tooltip = if (apState.networkReady) "Wi-Fi AP 연결됨 (${apState.selectedSsid})" else "Wi-Fi AP 연결", - enabled = !isCommConnected || isWifiConnected, - tint = if (isWifiConnected) Color(0xFF2563EB) else ColorSecondary, - onClick = onWifiClick + WifiConnectionRibbonButtons( + ui = mainUiState, ap = apState, canConnect = canConnectCommunication, + onWifiClick = onWifiClick, onConnectClick = viewModel::connectCommunication, + onDisconnectClick = onDisconnectClick, + onWifiDisconnectClick = onWifiDisconnectClick ) VerticalDivider(modifier = Modifier.height(20.dp).padding(horizontal = 4.dp), color = ColorOutlineVariant) @@ -338,8 +377,8 @@ fun WorkspaceRibbonBar( onClick = { viewModel.stopMeasure() } ) VerticalDivider(modifier = Modifier.height(20.dp).padding(horizontal = 4.dp), color = ColorOutlineVariant) - Text("신호 기록", fontSize = 12.sp, color = ColorTextSecondary) val recordingState = mainUiState.recordingState + RecordingRibbonLabel(recordingState, mainUiState.recordingStartedAtMillis) val canStartRecording = isCommConnected && isMeasuring && recordingState !in setOf( MeasurementRecordingState.STARTING, @@ -433,5 +472,26 @@ fun WorkspaceRibbonBar( } ) } + DebugValueResetButton(onClick = viewModel::resetDebugValues) + } + } +} + +/** Kept outside the scrolling ribbon so the debug action is always at the right edge. */ +@Composable +internal fun DebugValueResetButton( + isDebugBuild: Boolean = com.dct.app.BuildConfig.DEBUG, + onClick: () -> Unit +) { + if (isDebugBuild) { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(horizontal = 6.dp)) { + VerticalDivider(modifier = Modifier.height(20.dp).padding(end = 6.dp), color = ColorOutlineVariant) + RibbonButton( + icon = Icons.Default.RestartAlt, + tooltip = "값 초기화 (그래프 포함)", + label = "값 초기화", + onClick = onClick + ) + } } } diff --git a/Android/app/src/test/java/com/dct/app/data/maintenance/HeartbeatAndOtaParityTest.kt b/Android/app/src/test/java/com/dct/app/data/maintenance/HeartbeatAndOtaParityTest.kt index 631a43e..bc2655e 100644 --- a/Android/app/src/test/java/com/dct/app/data/maintenance/HeartbeatAndOtaParityTest.kt +++ b/Android/app/src/test/java/com/dct/app/data/maintenance/HeartbeatAndOtaParityTest.kt @@ -18,6 +18,8 @@ import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.Assertions.* import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.ValueSource @DisplayName("Heartbeat 주기 루프 및 OTA 유지보수 단위 테스트") class HeartbeatAndOtaParityTest { @@ -80,8 +82,9 @@ class HeartbeatAndOtaParityTest { assertTrue(fwInfoCommands.any { it.payload[0] == 0x08.toByte() && it.payload[1] == 0x02.toByte() }) } - @Test - fun `Heartbeat와 다른 수신이 모두 누락되어도 활성 TCP 소켓은 유지한다`() = runTest { + @ParameterizedTest + @ValueSource(booleans = [false, true]) + fun `재생 전 무응답은 종료하고 재생 요청 이후에는 실제 링크 단절까지 연결을 유지한다`(measuring: Boolean) = runTest { val wifi = mockk(relaxed = true) val packets = MutableSharedFlow(extraBufferCapacity = 16) val isConnected = MutableStateFlow(true) @@ -89,6 +92,10 @@ class HeartbeatAndOtaParityTest { every { wifi.packetStream } returns packets every { wifi.currentSessionId } returns 1L coEvery { wifi.disconnectSuspend() } coAnswers { isConnected.value = false } + every { wifi.invalidateConnectionIfSession(1L) } answers { + isConnected.value = false + true + } val sentList = mutableListOf() coEvery { wifi.sendCommand(capture(sentList)) } returns true @@ -100,7 +107,8 @@ class HeartbeatAndOtaParityTest { heartbeatDispatcher = testDispatcher, heartbeatIntervalMs = 1000L, heartbeatTimeoutMs = 5000L, - enableHeartbeat = true + enableHeartbeat = true, + monotonicTimeMillis = { testScheduler.currentTime } ) // 핸드셰이크 통과 설정 @@ -127,43 +135,81 @@ class HeartbeatAndOtaParityTest { assertTrue(repository.connect("") is DctResult.Success) assertEquals(DeviceConnectionState.READY, repository.connectionState.value) + if (measuring) { + coEvery { wifi.sendCommand(match { it.command == 0x20.toByte() }) } coAnswers { + packets.emit(DctPacket(command = 0xA0.toByte(), payload = byteArrayOf(1, 1))) + true + } + assertTrue(repository.startStreaming(30) is DctResult.Success) + } // 1초 후 첫 번째 Heartbeat(0x05) 발송 시점 advanceTimeBy(1001L) val heartbeat1 = sentList.find { it.command == DctCommand.CMD_POSSIBLE_TO_CONNECT.code } assertNotNull(heartbeat1) - // 첫 누락만으로는 일시 지연일 수 있으므로 연결을 유지한다. - advanceTimeBy(5001L) - assertEquals(DeviceConnectionState.READY, repository.connectionState.value) - assertTrue(isConnected.value) - - // 다음 probe도 0x85와 기타 정상 패킷이 모두 없지만 Heartbeat만으로 - // 활성 TCP 연결을 종료해서는 안 된다. - advanceTimeBy(6001L) - - assertEquals(DeviceConnectionState.READY, repository.connectionState.value) + // 5초 응답 대기 예산 안에서는 연결을 유지한다. + advanceTimeBy(4998L) assertTrue(isConnected.value) + advanceTimeBy(3L) + if (measuring) { + advanceTimeBy(18001L) // Several fully silent heartbeat periods must not close playback. + assertEquals(DeviceConnectionState.MEASURING, repository.connectionState.value) + assertTrue(repository.isConnected.value) + assertTrue(repository.isStreaming.value) + assertTrue(isConnected.value) + verify(exactly = 0) { wifi.invalidateConnectionIfSession(any()) } + assertFalse(sentList.any { it.command == 0x21.toByte() || it.command == 0x0F.toByte() }) + + coEvery { wifi.sendCommand(match { it.command == 0x21.toByte() }) } coAnswers { + packets.emit(DctPacket(0xA1.toByte(), byteArrayOf(1))) + true + } + assertTrue(repository.stopStreaming() is DctResult.Success) + advanceTimeBy(12001L) + assertEquals(DeviceConnectionState.READY, repository.connectionState.value) + assertTrue(isConnected.value) // STOP does not re-enable automatic liveness disconnect. + verify(exactly = 0) { wifi.invalidateConnectionIfSession(any()) } + + isConnected.value = false // An actual transport/link loss still ends the session. + testScheduler.runCurrent() + assertEquals(DeviceConnectionState.ERROR, repository.connectionState.value) + assertFalse(repository.isConnected.value) + assertFalse(repository.isStreaming.value) + + every { wifi.currentSessionId } returns 2L + every { wifi.invalidateConnectionIfSession(2L) } answers { isConnected.value = false; true } + isConnected.value = true + assertTrue(repository.connect("") is DctResult.Success) + advanceTimeBy(6002L) + assertEquals(DeviceConnectionState.ERROR, repository.connectionState.value) + verify(exactly = 1) { wifi.invalidateConnectionIfSession(2L) } + return@runTest + } + assertEquals(DeviceConnectionState.ERROR, repository.connectionState.value) + assertFalse(repository.isConnected.value) + assertFalse(repository.isStreaming.value) + assertFalse(isConnected.value) + verify(exactly = 1) { wifi.invalidateConnectionIfSession(1L) } + // 무응답 장비에 STOP/DISABLE ACK를 추가로 기다리지 않는다. + assertFalse(sentList.any { it.command == 0x21.toByte() || it.command == 0x0F.toByte() }) coVerify(exactly = 0) { wifi.disconnectSuspend() } - // 로그에서 재현된 것처럼 늦은 정상 응답이 도착해도 그대로 수신하고 - // 이후 Heartbeat 감시를 계속할 수 있어야 한다. + // 종료 뒤 늦게 도착한 START ACK로 연결/계측을 되살리지 않는다. packets.emit(DctPacket( command = DctCommand.RES_START_TRANSMIT_DATA.code, payload = byteArrayOf(1, 1), transportSessionId = 1L )) - advanceTimeBy(1L) - assertEquals(DeviceConnectionState.READY, repository.connectionState.value) - assertTrue(isConnected.value) - - // 제품 동작 검증 후 모의 전송 계층을 내려 반복 Heartbeat 작업을 정리한다. - isConnected.value = false advanceTimeBy(6001L) + assertEquals(DeviceConnectionState.ERROR, repository.connectionState.value) + assertFalse(isConnected.value) + assertEquals(1, sentList.count { it.command == DctCommand.CMD_POSSIBLE_TO_CONNECT.code }) } - @Test - fun `계측 중에도 Heartbeat 루프가 데이터 스트림을 차단하지 않고 공존한다`() = runTest { + @ParameterizedTest + @ValueSource(booleans = [false, true]) + fun `계측 데이터 또는 Heartbeat 응답이 있으면 연결을 유지한다`(heartbeatReply: Boolean) = runTest { val wifi = mockk(relaxed = true) val packets = MutableSharedFlow(extraBufferCapacity = 64) val isConnected = MutableStateFlow(true) @@ -182,7 +228,8 @@ class HeartbeatAndOtaParityTest { heartbeatDispatcher = testDispatcher, heartbeatIntervalMs = 2000L, heartbeatTimeoutMs = 5000L, - enableHeartbeat = true + enableHeartbeat = true, + monotonicTimeMillis = { testScheduler.currentTime } ) // 핸드셰이크 통과 @@ -227,16 +274,17 @@ class HeartbeatAndOtaParityTest { // Heartbeat 주기(2000ms) 전진 advanceTimeBy(2001L) - // 0x85 대신 계측 데이터가 들어와도 PC와 같이 장비 생존으로 판단한다. + // 정상 데이터만 수신하는 경우와 0x85 응답만 받는 경우를 각각 검증한다. packets.emit(DctPacket( - command = DctCommand.SEND_CAN_FRAME.code, - payload = byteArrayOf(0, 8, 1, 2, 3, 4, 5, 6, 7, 8), + command = if (heartbeatReply) DctCommand.RES_POSSIBLE_TO_CONNECT.code else DctCommand.SEND_CAN_FRAME.code, + payload = if (heartbeatReply) byteArrayOf() else byteArrayOf(1, 0xA4.toByte(), 1), transportSessionId = 2L )) advanceTimeBy(5001L) assertEquals(DeviceConnectionState.MEASURING, repository.connectionState.value) assertTrue(sentList.any { it.command == DctCommand.CMD_POSSIBLE_TO_CONNECT.code }) + verify(exactly = 0) { wifi.invalidateConnectionIfSession(any()) } repository.disconnect() } diff --git a/Android/app/src/test/java/com/dct/app/tests/DctProtocolRequestsTest.kt b/Android/app/src/test/java/com/dct/app/tests/DctProtocolRequestsTest.kt index 1d7ad9b..494bb29 100644 --- a/Android/app/src/test/java/com/dct/app/tests/DctProtocolRequestsTest.kt +++ b/Android/app/src/test/java/com/dct/app/tests/DctProtocolRequestsTest.kt @@ -163,13 +163,29 @@ class DctProtocolRequestsTest { assertThat(DctProtocolRequests.canDbcInfo(emptyList()).payload) .isEqualTo(hex("00 00 00")) assertThat(DctProtocolRequests.sensorModuleInfo(createDefaultSensorModules()).payload) - .isEqualTo(hex("01 01 FF FF FF FF")) + .isEqualTo(hex("01 01 FF 00 00 00")) assertThat(DctProtocolRequests.sensorModuleInfo(emptyList()).payload) .isEqualTo(hex("01 00")) assertThat(DctProtocolRequests.canMode(isHighSpeedCan = true, isOldModel = true).payload) .isEqualTo(hex("06 11")) } + @Test + fun `미사용 센서 모듈은 저장된 채널 타입과 무관하게 PC처럼 0으로 전송한다`() { + val modules = createDefaultSensorModules().map { module -> + module.copy(isUsed = module.moduleIndex == 2) + } + assertThat(DctProtocolRequests.sensorModuleInfo(modules.reversed()).payload) + .isEqualTo(hex("01 04 00 00 FF 00")) + assertThat(DctProtocolRequests.sensorModuleInfo(modules.map { it.copy(isUsed = false) }).payload) + .isEqualTo(hex("01 00 00 00 00 00")) + val mixed = modules.map { module -> + module.copy(channels = module.channels.map { it.copy(isVoltage = it.channelIndex % 2 == 0) }) + } + assertThat(DctProtocolRequests.sensorModuleInfo(mixed).payload) + .isEqualTo(hex("01 04 00 00 AA 00")) + } + @Test fun `MDB wire 폭 축소 후 중복되는 ID와 Index는 설정 전에 거절한다`() { val base = SignalDefinition(1, 1, "A", MdbAttrType.ATTR_0_UINT8, 1.0, 0.0, "", 0, 0.0, 1.0) diff --git a/Android/app/src/test/java/com/dct/app/tests/FormulaTelemetryRegressionTest.kt b/Android/app/src/test/java/com/dct/app/tests/FormulaTelemetryRegressionTest.kt index 17f6354..46070b1 100644 --- a/Android/app/src/test/java/com/dct/app/tests/FormulaTelemetryRegressionTest.kt +++ b/Android/app/src/test/java/com/dct/app/tests/FormulaTelemetryRegressionTest.kt @@ -84,6 +84,86 @@ class FormulaTelemetryRegressionTest { assertEquals(3.0, vm.liveTelemetryValues.value["B"]) } + @Test fun debugResetClearsAllPagesAndValuesWithoutStoppingRecordingOrRemovingConfiguration() { + vm.startMeasure() + vm.addFormula(FormulaDefinition(name = "F", expression = "Incar_Snr*2", unit = "V")) + val binding = com.dct.app.domain.model.project.DpcSignalBindingState( + "formula_F", "F", AxisPosition.LEFT_0, 0x123456L, "V") + vm.restoreProjectWorkspace(com.dct.app.domain.model.project.DpcWorkspaceState("page_1", listOf( + com.dct.app.domain.model.project.DpcPageState("page_1", "One", listOf( + com.dct.app.domain.model.project.DpcWindowState("graph_one", "GRAPH", "Graph", signals = listOf(binding)))), + com.dct.app.domain.model.project.DpcPageState("page_2", "Two", listOf( + com.dct.app.domain.model.project.DpcWindowState("multi_two", "MULTI_AXIS_GRAPH", "Multi", signals = listOf(binding))))))) + vm.chartRenderer.bindSignal("formula_F", "F", AxisPosition.LEFT_0, 0x123456L) + val renderers = listOf(vm.chartRenderer, vm.getChartRenderer("graph_one"), vm.getChartRenderer("multi_two")) + val ui = field(vm, "_uiState").get(vm) as MutableStateFlow + ui.value = ui.value.copy(dashboardTiles = listOf( + com.dct.app.domain.model.dashboard.TileWidgetItem("Incar_Snr", "Incar_Snr"))) + vm.startMeasurementRecording() + telemetry(2f) + renderers.forEach { assertTrue(it.seriesListFlow.value.single().buffer.size > 0) } + assertTrue(vm.uiState.value.dashboardTiles.any { it.historyBuffer.size > 0 }) + val uiBefore = vm.uiState.value + val project = vm.currentProject.value + val formulas = vm.formulas.value + val pages = vm.pages.value.map { it.id to it.title } + val bindings = renderers.map { it.exportWorkspaceState() } + val raw = field(vm, "latestMf4RawValues").get(vm) as MutableMap + raw.putAll(mapOf("sig_1_1" to 42, "can_1" to 55, "a2l_1" to 66)) + + vm.resetDebugValues() + + assertTrue(vm.liveTelemetryValues.value.isEmpty()) + assertTrue(raw.isEmpty()) + assertEquals(0L, vm.uiState.value.packetCount) + assertTrue(vm.uiState.value.dashboardTiles.all { it.value == 0.0 && it.historyBuffer.size == 0 }) + assertEquals(uiBefore.dashboardTiles.map { it.signalId }, vm.uiState.value.dashboardTiles.map { it.signalId }) + assertEquals(project, vm.currentProject.value) + assertEquals(formulas, vm.formulas.value) + assertEquals(pages, vm.pages.value.map { it.id to it.title }) + assertTrue(vm.uiState.value.isMeasuring) + assertTrue(vm.uiState.value.isConnected) + assertEquals(uiBefore.recordingState, vm.uiState.value.recordingState) + assertEquals(uiBefore.recordingSampleCount, vm.uiState.value.recordingSampleCount) + assertEquals(uiBefore.recordingStartedAtMillis, vm.uiState.value.recordingStartedAtMillis) + renderers.forEachIndexed { index, renderer -> + val series = renderer.seriesListFlow.value.single() + assertEquals(0, series.buffer.size) + assertEquals("formula_F", series.signalId) + assertEquals(0x123456L, series.color) + assertEquals(bindings[index].axes, renderer.exportWorkspaceState().axes) + } + coVerify(exactly = 0) { repository.stopStreaming() } + coVerify(exactly = 0) { repository.disconnect() } + telemetry(3f) + assertEquals(6.0, vm.liveTelemetryValues.value["F"]) + assertEquals(1L, vm.uiState.value.packetCount) + assertEquals(uiBefore.recordingSampleCount + 1, vm.uiState.value.recordingSampleCount) + renderers.forEach { assertEquals(1, it.seriesListFlow.value.single().buffer.size) } + vm.stopMeasurementRecording() + val rows = directory.listFiles()!!.single { it.name.endsWith("_formula.csv") }.readLines() + .filter { it.matches(Regex("^\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2},.*")) } + assertEquals(listOf("0", "1"), rows.map { it.split(',')[1].trim() }) + assertEquals(listOf(4.0, 6.0), rows.map { it.substringAfterLast(',').toDouble() }) + } + + @Test fun debugResetWhileStoppedDoesNotReuseOldFormulaInputs() { + vm.addFormula(FormulaDefinition(name = "F", expression = "Incar_Snr*2")) + telemetry(2f) + assertEquals(4.0, vm.liveTelemetryValues.value["F"]) + vm.resetDebugValues() + assertTrue(vm.liveTelemetryValues.value.isEmpty()) + vm.resetDebugValues() // Repeated reset without a project/measurement is safe. + assertTrue(packets.tryEmit(DctPacket(0xA4.toByte(), byteArrayOf(1, 0, 1, 0, 0, 10, 0, 0)))) + assertTrue(vm.liveTelemetryValues.value.isEmpty()) // A stopped session ignores stream data. + assertFalse(vm.uiState.value.isMeasuring) + vm.startMeasure() + assertTrue(packets.tryEmit(DctPacket(0xA4.toByte(), byteArrayOf(1, 0, 1, 0, 0, 10, 0, 0)))) + assertTrue(vm.liveTelemetryValues.value.getValue("F").isNaN()) + telemetry(5f) + assertEquals(10.0, vm.liveTelemetryValues.value["F"]) + } + @Test fun unrelatedSmPacketAddsNeitherFormulaGraphNorCsvSample() { vm.startMeasure() vm.addFormula(FormulaDefinition(name = "F", expression = "Incar_Snr*2")) @@ -101,8 +181,11 @@ class FormulaTelemetryRegressionTest { assertEquals(2, graph.buffer.size) vm.stopMeasurementRecording() val csv = directory.listFiles()!!.single { it.name.endsWith("_formula.csv") } - val rows = csv.readLines().filter { it.matches(Regex("^\\d{2}:\\d{2}:\\d{2}\\.\\d{3},.*")) } + // The PC-compatible default includes the date and omits milliseconds. + val rows = csv.readLines().filter { it.matches(Regex("^\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2},.*")) } assertEquals(2, rows.size) + assertEquals(listOf("0", "1"), rows.map { it.split(',')[1].trim() }) + assertEquals(listOf(4.0, 6.0), rows.map { it.substringAfterLast(',').toDouble() }) } @Test fun editsRenameAndDeletionRefreshWithoutWaitingForAnotherPacket() { diff --git a/Android/app/src/test/java/com/dct/app/tests/HeartbeatIdleRegressionTest.kt b/Android/app/src/test/java/com/dct/app/tests/HeartbeatIdleRegressionTest.kt new file mode 100644 index 0000000..55bb43e --- /dev/null +++ b/Android/app/src/test/java/com/dct/app/tests/HeartbeatIdleRegressionTest.kt @@ -0,0 +1,137 @@ +package com.dct.app.tests + +import com.dct.app.core.common.DctResult +import com.dct.app.data.communication.WifiSocketManager +import com.dct.app.data.repository.PacketRepositoryImpl +import com.dct.app.domain.model.protocol.DctPacket +import com.dct.app.domain.repository.DeviceConnectionState +import io.mockk.* +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.* +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.ValueSource + +@OptIn(ExperimentalCoroutinesApi::class) +class HeartbeatIdleRegressionTest { + private class Fixture(scope: TestScope) { + val wifi = mockk(relaxed = true) + val packets = MutableSharedFlow(extraBufferCapacity = 64) + val connected = MutableStateFlow(true) + val sent = mutableListOf() + val acks = mutableListOf() + var sessionId = 7L + val repository: PacketRepositoryImpl + + init { + every { wifi.isConnected } returns connected + every { wifi.packetStream } returns packets + every { wifi.currentSessionId } answers { sessionId } + every { wifi.offerCommand(capture(acks)) } returns true + coEvery { wifi.disconnectSuspend() } coAnswers { connected.value = false } + every { wifi.invalidateConnectionIfSession(any()) } answers { + connected.value = false + true + } + coEvery { wifi.sendCommand(any()) } coAnswers { + val request = firstArg() + sent += request + val payload = when (request.command.toInt() and 255) { + 0x00, 0x04, 0x09, 0x21 -> byteArrayOf(1) + 0x01 -> byteArrayOf(4, 68, 67, 84, 0) + 0x03 -> ByteArray(6) + 0x20 -> byteArrayOf(1, 1) + else -> null // Probes intentionally remain silent. + } + if (payload != null) packets.emit(DctPacket( + ((request.command.toInt() and 255) + 0x80).toByte(), payload, + transportSessionId = sessionId)) + true + } + val dispatcher = StandardTestDispatcher(scope.testScheduler) + repository = PacketRepositoryImpl(wifiSocketManager = wifi, ioDispatcher = dispatcher, + heartbeatDispatcher = dispatcher, heartbeatIntervalMs = 1000, + heartbeatTimeoutMs = 5000, monotonicTimeMillis = { scope.testScheduler.currentTime }) + } + + suspend fun connect() { assertTrue(repository.connect("") is DctResult.Success) } + fun probes() = sent.count { it.command == 0x05.toByte() } + } + + @ParameterizedTest + @ValueSource(ints = [0, 1, 2, 3]) + fun continuousMdbCanSensorAndXcpKeepTheirAcksWithoutHeartbeatProbes(source: Int) = runTest { + val f = Fixture(this) + f.connect() + assertTrue(f.repository.startStreaming() is DctResult.Success) + val (command, payload, ack) = when (source) { + 0 -> Triple(0xA3, byteArrayOf(1, 0xA0.toByte(), 42), 0x23) + 1 -> Triple(0xA3, byteArrayOf(2, 1, 1, 1, 1, 0x23, 1, 42), 0x23) + 2 -> Triple(0xA4, byteArrayOf(0), 0x24) + else -> Triple(0xD0, byteArrayOf(0, 1, 0, 42), 0x50) + } + repeat(20) { + advanceTimeBy(500) + f.packets.emit(DctPacket(command.toByte(), payload, transportSessionId = f.sessionId)) + runCurrent() + } + assertEquals(0, f.probes()) + assertEquals(20, f.acks.size) + assertTrue(f.acks.all { it.command == ack.toByte() && it.payload.contentEquals(byteArrayOf(1)) }) + assertEquals(DeviceConnectionState.MEASURING, f.repository.connectionState.value) + f.repository.disconnect() + } + + @Test fun probeWaitsForFullSilenceFromLastPacketNotTheOriginalTimerTick() = runTest { + val f = Fixture(this) + f.connect() + advanceTimeBy(900) + f.packets.emit(DctPacket(0xA4.toByte(), byteArrayOf(0), transportSessionId = f.sessionId)) + runCurrent() + advanceTimeBy(999) + runCurrent() + assertEquals(0, f.probes()) + advanceTimeBy(1) + runCurrent() + assertEquals(1, f.probes()) + assertTrue(f.connected.value) + f.repository.disconnect() + } + + @Test fun normalControlResponsesAlsoSuppressIdleProbes() = runTest { + val f = Fixture(this) + f.connect() + repeat(8) { + advanceTimeBy(750) + f.packets.emit(DctPacket(0x85.toByte(), byteArrayOf(), transportSessionId = f.sessionId)) + runCurrent() + } + assertEquals(0, f.probes()) + assertTrue(f.acks.isEmpty()) + f.repository.disconnect() + } + + @Test fun staleSessionPacketsCannotPostponeProbeAndReconnectStartsFreshBudget() = runTest { + val f = Fixture(this) + f.connect() + advanceTimeBy(900) + f.packets.emit(DctPacket(0xA4.toByte(), byteArrayOf(0), transportSessionId = f.sessionId)) + runCurrent() + f.repository.disconnect() + f.sessionId++ + f.connected.value = true + f.connect() + val before = f.probes() + advanceTimeBy(900) + f.packets.emit(DctPacket(0xA4.toByte(), byteArrayOf(0), transportSessionId = f.sessionId - 1)) + runCurrent() + advanceTimeBy(100) + runCurrent() + assertEquals(before + 1, f.probes()) + assertEquals(1, f.acks.size) // Only the first session's real packet was acknowledged. + f.repository.disconnect() + } +} diff --git a/Android/app/src/test/java/com/dct/app/tests/MainViewModelTest.kt b/Android/app/src/test/java/com/dct/app/tests/MainViewModelTest.kt index 96bff97..aa61c04 100644 --- a/Android/app/src/test/java/com/dct/app/tests/MainViewModelTest.kt +++ b/Android/app/src/test/java/com/dct/app/tests/MainViewModelTest.kt @@ -40,6 +40,120 @@ import java.io.File @DisplayName("M5 - MainViewModel 메인 뷰모델 및 오케스트레이션 단위 테스트") class MainViewModelTest { + @Test fun canConfigurationPreservesDbcLengthsInHighSpeedMode() { + assertCanConfigurationPreservesDbcLengths("CAN(HiSpeed)", true) + } + + @Test fun canConfigurationPreservesDbcLengthsInFdMode() { + assertCanConfigurationPreservesDbcLengths("CAN(FD)", false) + } + + private fun assertCanConfigurationPreservesDbcLengths(mode: String, highSpeed: Boolean) { + viewModel.setCurrentProject(com.dct.app.domain.model.project.DpcProject(canMode = mode)) + val lengths = listOf(0, 8, 12, 16, 20, 24, 32, 48, 64) + val signals = lengths.mapIndexed { index, length -> + sampleDb.allSignals.first().copy( + reclid = 0, idx = index, name = "CAN_$index", + messageId = 0x100L + index, messageDlc = length + ) + } + @Suppress("UNCHECKED_CAST") + val definitions = MainViewModel::class.java.getDeclaredField("_canSignals") + .apply { isAccessible = true }.get(viewModel) as MutableStateFlow> + // Multiple signals in one message must not create duplicate registrations. + definitions.value = signals + signals.last().copy(idx = 99, name = "SameMessage") + signals.forEach { signal -> + chartRenderer.bindSignal(CanSignalKey.stable(signal), signal.name, AxisPosition.LEFT_0, 0L) + } + val configuration = MainViewModel::class.java.getDeclaredMethod("createDeviceMeasurementConfiguration") + .apply { isAccessible = true }.invoke(viewModel) as + com.dct.app.domain.model.protocol.DctMeasurementConfiguration + + assertEquals(lengths, configuration.canMessages.map { it.dlc }) + assertEquals(highSpeed, configuration.isHighSpeedCan) + assertEquals(mode, viewModel.currentProject.value!!.canMode) + val payload = com.dct.app.data.protocol.DctProtocolRequests.canDbcInfo(configuration.canMessages).payload + val expected = byteArrayOf(1, 0, lengths.size.toByte()) + lengths.flatMapIndexed { index, length -> + listOf(1.toByte(), index.toByte(), length.toByte()) + }.toByteArray() + assertArrayEquals(expected, payload) + + definitions.value = emptyList() + val emptyConfiguration = MainViewModel::class.java.getDeclaredMethod("createDeviceMeasurementConfiguration") + .apply { isAccessible = true }.invoke(viewModel) as + com.dct.app.domain.model.protocol.DctMeasurementConfiguration + assertArrayEquals(byteArrayOf(0, 0, 0), + com.dct.app.data.protocol.DctProtocolRequests.canDbcInfo(emptyConfiguration.canMessages).payload) + } + + private fun setCanDefinitions(signals: List) { + @Suppress("UNCHECKED_CAST") + val state = MainViewModel::class.java.getDeclaredField("_canSignals") + .apply { isAccessible = true }.get(viewModel) as MutableStateFlow> + state.value = signals + } + + private fun deviceConfiguration() = MainViewModel::class.java.getDeclaredMethod("createDeviceMeasurementConfiguration") + .apply { isAccessible = true }.invoke(viewModel) as com.dct.app.domain.model.protocol.DctMeasurementConfiguration + + @Test fun canConfigurationDoesNotRegisterAnUnselectedLoadedDbc() { + val signals = (0..170).map { index -> sampleDb.allSignals.first().copy( + reclid = 0, idx = index, name = "CAN_$index", messageId = index.toLong(), messageDlc = 32) } + setCanDefinitions(signals) + mdiViewModel.openOrFocusCategoryWindow(MdiWindowType.CAN_SIGNAL_LIST, "CAN Signal") + val project = com.dct.app.domain.model.project.DpcProject( + canDbcFileName = "Loaded.dbc", didInfo = com.dct.app.domain.model.project.DpcDidInfo(preSetting = 49)) + viewModel.setCurrentProject(project) + val configuration = deviceConfiguration() + assertTrue(configuration.canMessages.isEmpty()) + assertEquals(49, configuration.didPreSetting) + assertEquals("Loaded.dbc", viewModel.currentProject.value!!.canDbcFileName) + assertEquals(signals, viewModel.canSignals.value) + assertArrayEquals(byteArrayOf(0, 0, 0), + com.dct.app.data.protocol.DctProtocolRequests.canDbcInfo(configuration.canMessages).payload) + } + + @Test fun canConfigurationUsesBindingsAliasesAndDeduplicatesMessagesWithoutShrinkingDlc() { + val signals = (0..4).map { index -> sampleDb.allSignals.first().copy( + reclid = 0, idx = index, name = "CAN_$index", messageId = 0x100L + index, messageDlc = 32) } + val duplicate = signals[0].copy(idx = 99, name = "SecondSignal", messageDlc = 64) + setCanDefinitions(signals + duplicate) + chartRenderer.bindSignal(CanSignalKey.stable(signals[0]), "Old name", AxisPosition.LEFT_0, 0L) + chartRenderer.bindSignal(CanSignalKey.legacy(duplicate), "Old name", AxisPosition.LEFT_0, 0L) + val alpha = mdiViewModel.addWindow(MdiWindowType.ALPHANUMERICAL)!! + viewModel.addAlphanumericalSignal(alpha, "CAN_0x101_CAN_1") + viewModel.addFormula(com.dct.app.domain.model.formula.FormulaDefinition(name = "F", expression = "CAN_2*2")) + val messages = deviceConfiguration().canMessages + assertEquals(listOf(0x100L, 0x101L, 0x102L), messages.map { it.canId }) + assertEquals(listOf(64, 32, 32), messages.map { it.dlc }) + } + + @Test fun canConfigurationRefreshesBeforeStartWhenCanOnlyBindingsChange() { + viewModel.setCurrentProject(com.dct.app.domain.model.project.DpcProject(canDbcFileName = "Loaded.dbc")) + val signal = sampleDb.allSignals.first().copy(reclid = 0, idx = 7, name = "CAN_SPEED", messageId = 0x456, messageDlc = 32) + setCanDefinitions(listOf(signal)) + connectionState.value = DeviceConnectionState.READY + repositoryConnected.value = true + val configurations = mutableListOf() + coEvery { packetRepository.configureMeasurement(any()) } answers { + configurations += firstArg() + DctResult.Success(Unit) + } + viewModel.syncDeviceConfiguration() + assertTrue(configurations.single().canMessages.isEmpty()) + chartRenderer.bindSignal(CanSignalKey.stable(signal), signal.name, AxisPosition.LEFT_0, 0L) + viewModel.startMeasure() + assertEquals(listOf(0x456L), configurations.last().canMessages.map { it.canId }) + assertEquals(2, configurations.size) + viewModel.stopMeasure() + chartRenderer.unbindSignal(CanSignalKey.stable(signal)) + viewModel.startMeasure() + assertEquals(3, configurations.size) + assertTrue(configurations.last().canMessages.isEmpty()) + coVerify(exactly = 2) { packetRepository.startStreaming(any()) } + viewModel.stopMeasure() + } + @Test fun propertyChangeKeepsUnchangedSourceSamplesAndMatchesMdbItems() { viewModel.openMdbFromFile(File("Sample.mdb")) viewModel.setCurrentProject(com.dct.app.domain.model.project.DpcProject(mdbFileName = "Sample.mdb")) @@ -360,6 +474,175 @@ class MainViewModelTest { private lateinit var sampleDb: MdbDatabase private var backgroundExecutionAllowed = true + private fun installWifiAp( + initial: com.dct.app.data.communication.WifiApUiState + ): Pair> { + val state = MutableStateFlow(initial) + val manager = mockk(relaxed = true) + every { manager.uiState } returns state + MainViewModel::class.java.getDeclaredField("registeredWifiApManager") + .apply { isAccessible = true }.set(viewModel, manager) + @Suppress("UNCHECKED_CAST") + val exposed = MainViewModel::class.java.getDeclaredField("_wifiApState") + .apply { isAccessible = true }.get(viewModel) as MutableStateFlow + exposed.value = initial + return manager to state + } + + private fun readyWifiAp() = com.dct.app.data.communication.WifiApUiState( + phase = com.dct.app.data.communication.WifiApPhase.CONNECTED, + enabled = true, permissionGranted = true, locationEnabled = true, + selectedSsid = "DCT", localAddress = "192.168.4.2") + + @Test fun wifiNetworkActionsDoNotStartTcpProtocolOrRequireForegroundPermission() { + val (manager, _) = installWifiAp(readyWifiAp()) + backgroundExecutionAllowed = false + viewModel.connectWifiAp(com.dct.app.data.communication.WifiApDevice("DCT", "", -40, + com.dct.app.data.communication.WifiApSecurity.WPA2), "12345678") + viewModel.connectCurrentWifiAp() + verify(exactly = 1) { manager.connect(any(), "12345678") } + verify(exactly = 1) { manager.connectCurrent() } + verify(exactly = 0) { viewModel.wifiSocketManager.startDualMode(any(), any(), any(), any(), any(), any()) } + coVerify(exactly = 0) { packetRepository.connect(any()) } + assertFalse(viewModel.uiState.value.isCommunicationConnecting) + } + + @Test fun apReadyAndDhcpReadyCallbacksNeverStartProtocolAutomatically() { + val context = mockk(relaxed = true) + every { context.applicationContext } returns context + every { context.getSystemService(android.net.wifi.WifiManager::class.java) } returns null + every { context.getSystemService(android.net.ConnectivityManager::class.java) } returns null + mockkConstructor(android.os.Handler::class) + mockkObject(com.dct.app.data.logger.MeasurementSafExports) + every { com.dct.app.data.logger.MeasurementSafExports.exportReady(context) } returns 0 + viewModel.initializeWifiAp(context) + val manager = MainViewModel::class.java.getDeclaredField("registeredWifiApManager") + .apply { isAccessible = true }.get(viewModel) as com.dct.app.data.communication.WifiApManager + @Suppress("UNCHECKED_CAST") + val onReady = com.dct.app.data.communication.WifiApManager::class.java.getDeclaredField("onReady") + .apply { isAccessible = true }.get(manager) as (String) -> Unit + onReady("DCT") + onReady("DCT") + verify(exactly = 0) { viewModel.wifiSocketManager.startDualMode(any(), any(), any(), any(), any(), any()) } + coVerify(exactly = 0) { packetRepository.connect(any()) } + assertFalse(viewModel.uiState.value.isCommunicationConnecting) + } + + @Test fun connectStartsTcpOnlyOnceAndHandshakeOnlyAfterSocketReady() { + installWifiAp(readyWifiAp()) + assertTrue(viewModel.canConnectCommunication.value) + var ready: ((Boolean, String) -> Unit)? = null + every { viewModel.wifiSocketManager.startDualMode(any(), any(), any(), any(), any(), any()) } answers { + ready = arg(3) + } + coEvery { packetRepository.connect(any()) } answers { + repositoryConnected.value = true + connectionState.value = DeviceConnectionState.READY + DctResult.Success(Unit) + } + viewModel.connectCommunication() + viewModel.connectCommunication() + assertTrue(viewModel.uiState.value.isCommunicationConnecting) + assertFalse(viewModel.canConnectCommunication.value) + assertFalse(viewModel.setWifiApEndpoint(com.dct.app.data.communication.WifiApEndpoint("192.168.1.10", 6000))) + verify(exactly = 1) { viewModel.wifiSocketManager.startDualMode(false, "192.168.4.1", 5000, any(), any(), any()) } + coVerify(exactly = 0) { packetRepository.connect(any()) } + ready!!.invoke(false, "192.168.4.1") + coVerify(exactly = 1) { packetRepository.connect("192.168.4.1") } + coVerify(exactly = 1) { packetRepository.configureMeasurement(any()) } + assertFalse(viewModel.uiState.value.isCommunicationConnecting) + } + + @Test fun connectAdoptsExistingWifiButMissingIpv4NeverStartsTcp() { + val network = com.dct.app.data.communication.ConnectedWifiNetwork("1", "Office", "192.168.1.20") + val (manager, state) = installWifiAp(readyWifiAp().copy( + phase = com.dct.app.data.communication.WifiApPhase.IDLE, localAddress = null, + connectedNetworks = listOf(network))) + every { manager.connectCurrent() } answers { state.value = readyWifiAp() } + viewModel.connectCommunication() + verify(exactly = 1) { manager.connectCurrent() } + verify(exactly = 1) { viewModel.wifiSocketManager.startDualMode(any(), any(), any(), any(), any(), any()) } + viewModel.disconnectCommunication() + state.value = state.value.copy(phase = com.dct.app.data.communication.WifiApPhase.IDLE, + localAddress = null, connectedNetworks = listOf(network.copy(localAddress = null))) + viewModel.connectCommunication() + verify(exactly = 1) { viewModel.wifiSocketManager.startDualMode(any(), any(), any(), any(), any(), any()) } + verify(exactly = 0) { manager.disconnect() } + } + + @Test fun connectRejectsUnavailableAmbiguousBusyAndAlreadyConnectedStates() { + val base = readyWifiAp() + val network = com.dct.app.data.communication.ConnectedWifiNetwork("1", "Office", "192.168.1.20") + for (unavailable in listOf( + base.copy(enabled = false), base.copy(permissionGranted = false), + base.copy(phase = com.dct.app.data.communication.WifiApPhase.CONNECTING), + base.copy(localAddress = null), + base.copy(phase = com.dct.app.data.communication.WifiApPhase.IDLE, localAddress = null, + connectedNetworks = listOf(network, network.copy(key = "2"))) + )) { + installWifiAp(unavailable) + assertFalse(viewModel.canConnectCommunication.value) + viewModel.connectCommunication() + } + installWifiAp(base) + @Suppress("UNCHECKED_CAST") + val socket = viewModel.wifiSocketManager.isConnected as MutableStateFlow + socket.value = true + assertFalse(viewModel.canConnectCommunication.value) + viewModel.connectCommunication() + socket.value = false + connectionState.value = DeviceConnectionState.READY + assertFalse(viewModel.canConnectCommunication.value) + viewModel.connectCommunication() + connectionState.value = DeviceConnectionState.MEASURING + assertFalse(viewModel.canConnectCommunication.value) + viewModel.connectCommunication() + verify(exactly = 0) { viewModel.wifiSocketManager.startDualMode(any(), any(), any(), any(), any(), any()) } + } + + @Test fun tcpFailureAllowsExplicitRetryAndDisconnectCancelsLateHandshakeWithoutLeavingWifi() { + val (manager, _) = installWifiAp(readyWifiAp()) + var ready: ((Boolean, String) -> Unit)? = null + var failed: ((String) -> Unit)? = null + every { viewModel.wifiSocketManager.startDualMode(any(), any(), any(), any(), any(), any()) } answers { + ready = arg(3); failed = arg(5) + } + viewModel.connectCommunication() + failed!!.invoke("timeout") + assertFalse(viewModel.uiState.value.isCommunicationConnecting) + assertTrue(viewModel.canConnectCommunication.value) + viewModel.connectCommunication() + viewModel.disconnectCommunication() + ready!!.invoke(false, "192.168.4.1") + coVerify(exactly = 0) { packetRepository.connect(any()) } + verify(exactly = 0) { manager.disconnect() } + assertTrue(manager.uiState.value.networkReady) + viewModel.disconnectCommunication(releaseWifiAp = true) + verify(exactly = 1) { manager.disconnect() } + } + + @Test fun disconnectCompletionWaitsForTransportAndApCleanupBeforeExit() = runTest { + val (manager, _) = installWifiAp(readyWifiAp()) + val disconnected = kotlinx.coroutines.CompletableDeferred() + coEvery { packetRepository.disconnect() } coAnswers { + disconnected.await() + DctResult.Success(Unit) + } + + val cleanup = viewModel.disconnectCommunication(releaseWifiAp = true)!! + assertFalse(cleanup.isCompleted) + verify(exactly = 0) { manager.disconnect() } + coVerify(exactly = 0) { viewModel.wifiSocketManager.disconnectSuspend() } + + disconnected.complete(Unit) + cleanup.join() + assertTrue(cleanup.isCompleted) + coVerify(exactly = 1) { viewModel.wifiSocketManager.disconnectSuspend() } + verify(exactly = 1) { manager.disconnect() } + assertFalse(viewModel.uiState.value.isConnected) + assertFalse(viewModel.uiState.value.isMeasuring) + } + @Test fun editableWifiTargetRejectsInvalidInputAndChangesDuringMeasurement() { val original = viewModel.wifiApEndpoint.value assertFalse(viewModel.setWifiApEndpoint(com.dct.app.data.communication.WifiApEndpoint("0.0.0.0", 6000))) @@ -372,6 +655,102 @@ class MainViewModelTest { assertEquals(pc, viewModel.wifiApEndpoint.value) } + @Test fun disconnectCancelsInFlightStartWithoutWaitingForItsResponse() { + val (manager, _) = installWifiAp(readyWifiAp()) + connectionState.value = DeviceConnectionState.READY + var cancelled = false + coEvery { packetRepository.startStreaming(any()) } coAnswers { + try { kotlinx.coroutines.awaitCancellation() } finally { cancelled = true } + } + viewModel.startMeasure() + viewModel.disconnectCommunication() + assertTrue(cancelled) + coVerify(exactly = 1) { packetRepository.disconnect() } + verify(exactly = 0) { manager.disconnect() } + assertFalse(viewModel.uiState.value.isConnected) + } + + @Test fun configurationTimeoutShowsDisconnectedKeepsWifiAndAllowsExplicitReconnect() { + val (manager, _) = installWifiAp(readyWifiAp()) + every { viewModel.wifiSocketManager.startDualMode(any(), any(), any(), any(), any(), any()) } answers { + arg<(Boolean, String) -> Unit>(3).invoke(false, "192.168.4.1") + } + coEvery { packetRepository.connect(any()) } coAnswers { + repositoryConnected.value = true + connectionState.value = DeviceConnectionState.READY + DctResult.Success(Unit) + } + coEvery { packetRepository.configureMeasurement(any()) } coAnswers { + repositoryConnected.value = false + connectionState.value = DeviceConnectionState.ERROR + DctResult.Failure(com.dct.app.core.error.DctError.ProtocolError.ResponseTimeout("0x09", "0x89")) + } + val project = com.dct.app.domain.model.project.DpcProject(projectFileName = "Preserved.dpc") + viewModel.setCurrentProject(project) + viewModel.connectCommunication() + + assertFalse(viewModel.uiState.value.isConnected) + assertFalse(viewModel.uiState.value.isCommunicationConnecting) + assertFalse(viewModel.uiState.value.isMeasuring) + assertEquals(com.dct.app.presentation.main.CommunicationType.NONE, viewModel.uiState.value.communicationType) + assertEquals("장비 설정 응답 시간 초과로 통신 연결이 해제되었습니다. Connect로 다시 연결하세요.", + viewModel.uiState.value.statusMessage) + assertTrue(viewModel.wifiApState.value.networkReady) + assertEquals(project, viewModel.currentProject.value) + verify(exactly = 0) { manager.disconnect() } + viewModel.startMeasure() + coVerify(exactly = 0) { packetRepository.startStreaming(any()) } + coVerify(exactly = 1) { packetRepository.connect(any()) } + + coEvery { packetRepository.configureMeasurement(any()) } returns DctResult.Success(Unit) + viewModel.connectCommunication() + assertTrue(viewModel.uiState.value.isConnected) + assertFalse(viewModel.uiState.value.isCommunicationConnecting) + assertTrue(viewModel.uiState.value.statusMessage!!.contains("설정 완료")) + coVerify(exactly = 2) { packetRepository.connect(any()) } + coVerify(exactly = 2) { packetRepository.configureMeasurement(any()) } + coVerify(exactly = 0) { packetRepository.startStreaming(any()) } + verify(exactly = 0) { manager.disconnect() } + } + + @Test fun disconnectCancelsInFlightHandshakeAndDoesNotApplyConfiguration() { + val (manager, _) = installWifiAp(readyWifiAp()) + every { viewModel.wifiSocketManager.startDualMode(any(), any(), any(), any(), any(), any()) } answers { + arg<(Boolean, String) -> Unit>(3).invoke(false, "192.168.4.1") + } + var cancelled = false + coEvery { packetRepository.connect(any()) } coAnswers { + try { kotlinx.coroutines.awaitCancellation() } finally { cancelled = true } + } + viewModel.connectCommunication() + assertTrue(viewModel.uiState.value.isCommunicationConnecting) + viewModel.disconnectCommunication() + assertTrue(cancelled) + coVerify(exactly = 1) { packetRepository.disconnect() } + coVerify(exactly = 0) { packetRepository.configureMeasurement(any()) } + verify(exactly = 0) { manager.disconnect() } + } + + @Test fun disconnectCancelsConfigurationAndProjectDidMaskReachesSnapshot() { + installWifiAp(readyWifiAp()) + viewModel.setCurrentProject(com.dct.app.domain.model.project.DpcProject( + didInfo = com.dct.app.domain.model.project.DpcDidInfo(preSetting = 0xA5))) + connectionState.value = DeviceConnectionState.READY + var captured: com.dct.app.domain.model.protocol.DctMeasurementConfiguration? = null + var cancelled = false + coEvery { packetRepository.configureMeasurement(any()) } coAnswers { + captured = firstArg() + try { kotlinx.coroutines.awaitCancellation() } finally { cancelled = true } + } + var completed: Boolean? = null + viewModel.syncDeviceConfiguration { completed = it } + assertEquals(0xA5, captured!!.didPreSetting) + viewModel.disconnectCommunication(releaseWifiAp = true) + assertTrue(cancelled) + assertEquals(false, completed) + coVerify(exactly = 1) { packetRepository.disconnect() } + } + @Test fun changingOnlyTheWifiPortStartsNewTransportButExactTargetIsIdempotent() = runTest { val host = "192.168.1.100" every { viewModel.wifiSocketManager.connectedHost } returns MutableStateFlow(host) @@ -540,7 +919,9 @@ class MainViewModelTest { calibrationViewModel = calibrationViewModel, eventBus = eventBus, dispatchers = dispatchers, - wifiSocketManager = mockk(relaxed = true), // Lifecycle tests must not launch real asynchronous TCP attempts. + wifiSocketManager = mockk(relaxed = true) { + every { isConnected } returns MutableStateFlow(false) + }, // Lifecycle tests must not launch real asynchronous TCP attempts. packetRepository = packetRepository, ensureBackgroundExecution = { backgroundExecutionAllowed } ) diff --git a/Android/app/src/test/java/com/dct/app/tests/PacketRepositoryResponseTest.kt b/Android/app/src/test/java/com/dct/app/tests/PacketRepositoryResponseTest.kt index a077267..a44da19 100644 --- a/Android/app/src/test/java/com/dct/app/tests/PacketRepositoryResponseTest.kt +++ b/Android/app/src/test/java/com/dct/app/tests/PacketRepositoryResponseTest.kt @@ -3,7 +3,11 @@ package com.dct.app.tests import com.dct.app.core.common.DctResult import com.dct.app.data.communication.WifiSocketManager import com.dct.app.data.protocol.DctProtocolRequests +import com.dct.app.data.parser.A2lParser import com.dct.app.data.repository.PacketRepositoryImpl +import com.dct.app.domain.model.a2l.A2lDataType +import com.dct.app.domain.model.mdb.MdbAttrType +import com.dct.app.domain.model.mdb.SignalDefinition import com.dct.app.domain.model.protocol.* import com.dct.app.domain.model.project.createDefaultSensorModules import com.dct.app.domain.repository.DeviceConnectionState @@ -14,8 +18,90 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.* import org.junit.jupiter.api.Assertions.* import org.junit.jupiter.api.Test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.CsvSource +import org.junit.jupiter.params.provider.ValueSource class PacketRepositoryResponseTest { + @ParameterizedTest + @CsvSource("true,false", "false,true") + fun xcpCalibrationFollowsConfiguredEndianAcrossProjectChanges(firstMsb: Boolean, secondMsb: Boolean) = runTest { + val f = Fixture(UnconfinedTestDispatcher(testScheduler)) + assertTrue(f.repository.connect("") is DctResult.Success) + for (msb in listOf(firstMsb, secondMsb)) { + assertTrue(f.repository.configureMeasurement(xcpConfiguration(msb)) is DctResult.Success) + assertXcpCalibrationPackets(f, msb) + } + } + + @ParameterizedTest + @ValueSource(strings = ["rejected", "timeout", "malformed"]) + fun xcpFailedIfDataDoesNotReplaceAppliedEndian(failure: String) = runTest { + val f = Fixture(UnconfinedTestDispatcher(testScheduler)) + if (failure == "timeout") f.sessionId = 7L + assertTrue(f.repository.connect("") is DctResult.Success) + assertTrue(f.repository.sendXcpIfDatas(A2lParser.XcpIfData(isMsbFirst = true)) is DctResult.Success) + when (failure) { + "rejected" -> f.reject = 0x41 + "timeout" -> f.silent = 0x41 + "malformed" -> coEvery { f.wifi.sendCommand(match { it.command == 0x41.toByte() }) } coAnswers { + f.packets.emit(DctPacket(0xC1.toByte(), byteArrayOf(1, 99))) + true + } + } + assertTrue(f.repository.configureMeasurement(xcpConfiguration(false)) is DctResult.Failure) + if (failure == "timeout") { + assertFalse(f.connected.value) + f.sessionId = 8L + f.connected.value = true + f.silent = null + assertTrue(f.repository.connect("") is DctResult.Success) + } + assertXcpCalibrationPackets(f, true) + } + + @Test fun xcpIfDataAckUpdatesEndianEvenWhenLaterSetupFails() = runTest { + val f = Fixture(UnconfinedTestDispatcher(testScheduler)) + assertTrue(f.repository.connect("") is DctResult.Success) + f.reject = 0x42 + assertTrue(f.repository.configureMeasurement(xcpConfiguration(true)) is DctResult.Failure) + assertXcpCalibrationPackets(f, true) + } + + @Test fun xcpMissingIfDataUsesDefaultLittleEndianAfterMsbProject() = runTest { + val f = Fixture(UnconfinedTestDispatcher(testScheduler)) + assertTrue(f.repository.connect("") is DctResult.Success) + assertTrue(f.repository.sendXcpIfDatas(A2lParser.XcpIfData(isMsbFirst = true)) is DctResult.Success) + assertTrue(f.repository.configureMeasurement(xcpConfiguration(true).copy(xcpIfData = null)) is DctResult.Success) + assertXcpCalibrationPackets(f, false) + } + + private fun xcpConfiguration(msb: Boolean) = DctMeasurementConfiguration( + a2lSignals = listOf(SignalDefinition(1, 1, "Calibration", MdbAttrType.ATTR_2_UINT16, + 1.0, 0.0, "", 0, 0.0, 65535.0, adl = 0x12345678, a2lDataType = A2lDataType.UWORD)), + xcpIfData = A2lParser.XcpIfData(isMsbFirst = msb) + ) + + private suspend fun assertXcpCalibrationPackets(f: Fixture, msb: Boolean) { + val valueBytes = if (msb) byteArrayOf(0x10, 0x20) else byteArrayOf(0x20, 0x10) + coEvery { f.wifi.sendCommand(match { it.command == 0x52.toByte() }) } coAnswers { + f.sentPackets += firstArg() + f.packets.emit(DctPacket(0xD2.toByte(), byteArrayOf(1) + valueBytes)) + true + } + assertTrue(f.repository.writeXcpCharacteristic(0x12345678, A2lDataType.UWORD, 0x1020.toDouble()) is DctResult.Success) + val read = f.repository.readXcpCharacteristic(0x12345678, A2lDataType.UWORD) + assertTrue(read is DctResult.Success) + assertArrayEquals(valueBytes, (read as DctResult.Success).data) + // PC Characteristic payloads: [type][value][address] / [type][address]. + val expectedWrite = if (msb) byteArrayOf(2, 0x10, 0x20, 0x12, 0x34, 0x56, 0x78) + else byteArrayOf(2, 0x20, 0x10, 0x78, 0x56, 0x34, 0x12) + val expectedRead = if (msb) byteArrayOf(2, 0x12, 0x34, 0x56, 0x78) + else byteArrayOf(2, 0x78, 0x56, 0x34, 0x12) + assertArrayEquals(expectedWrite, f.sentPackets.last { it.command == 0x51.toByte() }.payload) + assertArrayEquals(expectedRead, f.sentPackets.last { it.command == 0x52.toByte() }.payload) + } + @Test fun responseBudgetIncludesSlowSend() = runTest { val f = Fixture(UnconfinedTestDispatcher(testScheduler)) coEvery { f.wifi.sendCommand(any()) } coAnswers { kotlinx.coroutines.delay(1000); true } @@ -43,16 +129,27 @@ class PacketRepositoryResponseTest { val wifi = mockk(relaxed = true) val packets = MutableSharedFlow(extraBufferCapacity = 8) val connected = MutableStateFlow(true) + var sessionId = 0L var reject: Byte? = null + var silent: Byte? = null var versionPayload = byteArrayOf(1, 65) val sentPackets = mutableListOf() init { every { wifi.isConnected } returns connected every { wifi.packetStream } returns packets + every { wifi.currentSessionId } answers { sessionId } + every { wifi.invalidateConnectionIfSession(any()) } answers { + if (firstArg() != 0L && firstArg() == wifi.currentSessionId && connected.value) { + sessionId = 0L + connected.value = false + true + } else false + } coEvery { wifi.disconnectSuspend() } coAnswers { connected.value = false } coEvery { wifi.sendCommand(any()) } coAnswers { val request = firstArg() sentPackets += request + if (request.command == silent) return@coAnswers true val subCmd = request.payload.firstOrNull() ?: 1 val payload = when (request.command.toInt()) { 1 -> versionPayload @@ -69,6 +166,73 @@ class PacketRepositoryResponseTest { val repository = PacketRepositoryImpl(wifiSocketManager = wifi, ioDispatcher = dispatcher, responseTimeoutMs = 50) } + @Test fun firstEnvironmentQueryWaits200msAfterVersionWriteCompletes() = runTest { + val f = Fixture(UnconfinedTestDispatcher(testScheduler)) + var versionWrittenAt = -1L + var firstEnvAt = -1L + coEvery { f.wifi.sendCommand(match { it.command == 1.toByte() }) } coAnswers { + kotlinx.coroutines.delay(75) + f.packets.emit(DctPacket(0x81.toByte(), byteArrayOf(1, 65))) + versionWrittenAt = testScheduler.currentTime + true + } + coEvery { f.wifi.sendCommand(match { it.command == 9.toByte() }) } coAnswers { + if (firstEnvAt < 0) firstEnvAt = testScheduler.currentTime + f.packets.emit(DctPacket(0x89.toByte(), byteArrayOf(firstArg().payload[0], 1))) + true + } + assertTrue(f.repository.connect("") is DctResult.Success) + assertTrue(versionWrittenAt >= 0) + assertEquals(200L, firstEnvAt - versionWrittenAt) + } + + @ParameterizedTest + @CsvSource("0,false", "0,true", "9,false", "9,true", "18,false", "18,true", "3,false", "3,true", "4,false", "4,true") + fun failedHandshakeClosesOnlyFailedSessionAndCanReconnect(command: Int, nack: Boolean) = runTest { + val f = Fixture(UnconfinedTestDispatcher(testScheduler)) + every { f.wifi.currentSessionId } returns 7L + every { f.wifi.invalidateConnectionIfSession(7L) } answers { f.connected.value = false; true } + if (nack) f.reject = command.toByte() else f.silent = command.toByte() + if (nack && command == 3) { + // HWID has no status byte: its failure path is a truncated address. + coEvery { f.wifi.sendCommand(match { it.command == 3.toByte() }) } coAnswers { + f.packets.emit(DctPacket(0x83.toByte(), ByteArray(if (f.reject == 3.toByte()) 5 else 6))) + true + } + } + f.repository.setInitialMeasurementConfiguration(DctMeasurementConfiguration(sensorModules = createDefaultSensorModules())) + assertTrue(f.repository.connect("") is DctResult.Failure) + assertEquals(DeviceConnectionState.ERROR, f.repository.connectionState.value) + assertFalse(f.repository.isConnected.value) + assertFalse(f.connected.value) + verify(exactly = 1) { f.wifi.invalidateConnectionIfSession(7L) } + assertFalse(f.sentPackets.any { it.command == 0x0F.toByte() || it.command == 0x20.toByte() }) + + // A new session must not inherit the failed session's response quarantine. + every { f.wifi.currentSessionId } returns 8L + f.connected.value = true + f.reject = null + f.silent = null + assertTrue(f.repository.connect("") is DctResult.Success) + assertEquals(DeviceConnectionState.READY, f.repository.connectionState.value) + verify(exactly = 0) { f.wifi.invalidateConnectionIfSession(8L) } + } + + @Test fun cancelledHandshakeDuringPcGapClosesItsSocket() = runTest { + val f = Fixture(UnconfinedTestDispatcher(testScheduler)) + every { f.wifi.currentSessionId } returns 7L + every { f.wifi.invalidateConnectionIfSession(7L) } answers { f.connected.value = false; true } + val connect = async { f.repository.connect("") } + advanceTimeBy(100) + connect.cancel() + runCurrent() + assertTrue(connect.isCancelled) + assertFalse(f.connected.value) + assertEquals(DeviceConnectionState.ERROR, f.repository.connectionState.value) + verify(exactly = 1) { f.wifi.invalidateConnectionIfSession(7L) } + coVerify(exactly = 0) { f.wifi.sendCommand(match { it.command == 9.toByte() }) } + } + @Test fun negativeEnableNeverReachesReady() = runTest { val f = Fixture(UnconfinedTestDispatcher(testScheduler)) f.reject = 0 @@ -106,7 +270,7 @@ class PacketRepositoryResponseTest { val env = f.sentPackets.filter { it.command == 0x09.toByte() } assertTrue(env.any { it.payload.contentEquals(byteArrayOf(0x06, 0x11)) }) val sm = f.sentPackets.first { it.command == 0x12.toByte() } - assertTrue(sm.payload.contentEquals(byteArrayOf(0x01, 0x01, 0xFF.toByte(), 0xFF.toByte(), 0xFF.toByte(), 0xFF.toByte()))) + assertTrue(sm.payload.contentEquals(byteArrayOf(0x01, 0x01, 0xFF.toByte(), 0x00, 0x00, 0x00))) } @Test fun truncatedVersionStillStopsHandshakeBeforeHardwareId() = runTest { val f = Fixture(UnconfinedTestDispatcher(testScheduler)) @@ -126,6 +290,75 @@ class PacketRepositoryResponseTest { ) assertTrue(f.repository.configureMeasurement(config) is DctResult.Failure) coVerify(exactly = 0) { f.wifi.sendCommand(match { it.command == 0x10.toByte() }) } + assertTrue(f.connected.value) + assertEquals(DeviceConnectionState.READY, f.repository.connectionState.value) + } + + @ParameterizedTest + @ValueSource(ints = [8, 9, 18, 65]) + fun configurationTimeoutClosesTcpEvenWithHeartbeatAndIgnoresLateAck(command: Int) = runTest { + val f = Fixture(UnconfinedTestDispatcher(testScheduler)) + f.sessionId = 7L + assertTrue(f.repository.connect("") is DctResult.Success) + f.sentPackets.clear() + f.silent = command.toByte() + val configuration = if (command == 65) xcpConfiguration(false) + else DctMeasurementConfiguration(projectName = "TEST") + val setup = async { f.repository.configureMeasurement(configuration) } + runCurrent() + if (command == 9 || command == 18) { + advanceTimeBy(120) // CAN-to-DID 100ms plus DID-to-environment 20ms. + runCurrent() + } + assertEquals(command, f.sentPackets.last().command.toInt() and 255) + f.packets.emit(DctPacket(0x85.toByte(), byteArrayOf(1), transportSessionId = 7L)) + advanceTimeBy(49) + assertFalse(setup.isCompleted) + assertTrue(f.connected.value) + advanceTimeBy(1) + runCurrent() + + val result = setup.await() + assertTrue(result is DctResult.Failure && result.error is com.dct.app.core.error.DctError.ProtocolError.ResponseTimeout) + assertFalse(f.connected.value) + assertFalse(f.repository.isConnected.value) + assertFalse(f.repository.isStreaming.value) + assertEquals(DeviceConnectionState.ERROR, f.repository.connectionState.value) + verify(exactly = 1) { f.wifi.invalidateConnectionIfSession(7L) } + val sentAtTimeout = f.sentPackets.size + f.packets.emit(DctPacket((command + 0x80).toByte(), byteArrayOf(0, 1), transportSessionId = 7L)) + assertEquals(sentAtTimeout, f.sentPackets.size) + assertEquals(DeviceConnectionState.ERROR, f.repository.connectionState.value) + assertTrue(f.repository.startStreaming() is DctResult.Failure) + assertFalse(f.sentPackets.any { it.command == 0x20.toByte() }) + + f.sessionId = 8L + f.connected.value = true + f.silent = null + assertTrue(f.repository.connect("") is DctResult.Success) + assertTrue(f.repository.configureMeasurement(configuration) is DctResult.Success) + f.packets.emit(DctPacket((command + 0x80).toByte(), byteArrayOf(0, 1), transportSessionId = 7L)) + assertTrue(f.connected.value) + assertEquals(DeviceConnectionState.READY, f.repository.connectionState.value) + verify(exactly = 0) { f.wifi.invalidateConnectionIfSession(8L) } + } + + @Test fun oldConfigurationTimeoutDoesNotCloseNewerTcpSession() = runTest { + val f = Fixture(UnconfinedTestDispatcher(testScheduler)) + f.sessionId = 7L + assertTrue(f.repository.connect("") is DctResult.Success) + f.silent = 8 + val setup = async { f.repository.configureMeasurement(DctMeasurementConfiguration(projectName = "TEST")) } + runCurrent() + f.sessionId = 8L + advanceTimeBy(50) + runCurrent() + assertTrue(setup.await() is DctResult.Failure) + verify(exactly = 1) { f.wifi.invalidateConnectionIfSession(7L) } + assertTrue(f.connected.value) + assertTrue(f.repository.isConnected.value) + assertEquals(DeviceConnectionState.READY, f.repository.connectionState.value) + verify(exactly = 0) { f.wifi.invalidateConnectionIfSession(8L) } } @Test fun timeoutKeepsTransportAndBlocksAmbiguousRetryUntilLateAck() = runTest { val f = Fixture(UnconfinedTestDispatcher(testScheduler)) @@ -143,15 +376,195 @@ class PacketRepositoryResponseTest { } assertTrue(f.repository.sendCommand(command) is DctResult.Success) } - @Test fun startTimeoutKeepsTransportAliveForRetry() = runTest { + @ParameterizedTest + @ValueSource(booleans = [false, true]) + fun ambiguousStartTimeoutKeepsTcpUntilExplicitDisconnect(stopAckLost: Boolean) = runTest { val f = Fixture(UnconfinedTestDispatcher(testScheduler)) assertTrue(f.repository.connect("") is DctResult.Success) - coEvery { f.wifi.sendCommand(match { it.command == DctCommand.CMD_START_TRANSMIT_DATA.code }) } returns true + coEvery { f.wifi.sendCommand(match { it.command == DctCommand.CMD_START_TRANSMIT_DATA.code }) } coAnswers { + f.sentPackets += firstArg() + true + } + coEvery { f.wifi.sendCommand(match { it.command == 0x21.toByte() }) } coAnswers { + f.sentPackets += firstArg() + // A late successful START must not revive the run during rollback. + f.packets.emit(DctPacket(0xA0.toByte(), byteArrayOf(1, 1))) + if (!stopAckLost) f.packets.emit(DctPacket(0xA1.toByte(), byteArrayOf(1))) + true + } assertTrue(f.repository.startStreaming(30) is DctResult.Failure) + assertTrue(f.connected.value) + assertFalse(f.repository.isStreaming.value) + assertEquals(DeviceConnectionState.READY, f.repository.connectionState.value) + assertFalse(f.sentPackets.any { it.command == 0x21.toByte() || it.command == 0x0F.toByte() }) coVerify(exactly = 0) { f.wifi.disconnectSuspend() } + f.packets.emit(DctPacket(0xA0.toByte(), byteArrayOf(1, 1))) + assertFalse(f.repository.isStreaming.value) + assertEquals(DeviceConnectionState.READY, f.repository.connectionState.value) + + // Only explicit Disconnect rolls back the uncertain START and closes TCP. + assertTrue(f.repository.disconnect() is DctResult.Success) + assertEquals(listOf(0x20, 0x21, 0x0F), f.sentPackets.takeLast(3).map { it.command.toInt() and 255 }) + coVerify(exactly = 1) { f.wifi.disconnectSuspend() } + assertFalse(f.connected.value) + assertFalse(f.repository.isStreaming.value) + assertEquals(DeviceConnectionState.DISCONNECTED, f.repository.connectionState.value) + f.packets.emit(DctPacket(0xA0.toByte(), byteArrayOf(1, 1))) + assertFalse(f.repository.isStreaming.value) + assertEquals(DeviceConnectionState.DISCONNECTED, f.repository.connectionState.value) + } + + @Test fun cancelledStartStillSendsStopBeforeDisableOnDisconnect() = runTest { + val f = Fixture(UnconfinedTestDispatcher(testScheduler)) + assertTrue(f.repository.connect("") is DctResult.Success) + coEvery { f.wifi.sendCommand(match { it.command == 0x20.toByte() }) } coAnswers { + f.sentPackets += firstArg() + true + } + val start = async { f.repository.startStreaming(30) } + runCurrent() + start.cancel() + start.join() + assertTrue(f.repository.disconnect() is DctResult.Success) + assertEquals(listOf(0x20, 0x21, 0x0F), f.sentPackets.takeLast(3).map { it.command.toInt() and 255 }) + assertFalse(f.connected.value) + } + + @ParameterizedTest + @ValueSource(longs = [0L, 75L]) + fun pcPostCanSetupSendsDidWithoutWaitingForAckThenRefreshesEnvironment(writeDelayMs: Long) = runTest { + val f = Fixture(UnconfinedTestDispatcher(testScheduler)) + assertTrue(f.repository.connect("") is DctResult.Success) + f.sentPackets.clear() + var didWrittenAt = -1L + var didStartedAt = -1L + var firstEnvAt = -1L + coEvery { f.wifi.sendCommand(match { it.command == 0x17.toByte() }) } coAnswers { + f.sentPackets += firstArg() + didStartedAt = testScheduler.currentTime + kotlinx.coroutines.delay(writeDelayMs) + didWrittenAt = testScheduler.currentTime + true // PC does not gate setup completion on a 0x97 response. + } + coEvery { f.wifi.sendCommand(match { it.command == 9.toByte() && it.payload.firstOrNull() == 0.toByte() }) } coAnswers { + f.sentPackets += firstArg() + firstEnvAt = testScheduler.currentTime + f.packets.emit(DctPacket(0x89.toByte(), byteArrayOf(0, 1))) + true + } + val before = testScheduler.currentTime + assertTrue(f.repository.configureMeasurement(DctMeasurementConfiguration( + didPreSetting = 0x31, isHighSpeedCan = false, sensorModules = createDefaultSensorModules() + )) is DctResult.Success) + assertEquals(listOf(0x40, 0x11, 0x17, 0x09, 0x09, 0x09, 0x12), + f.sentPackets.map { it.command.toInt() and 255 }) + assertEquals(100L, didStartedAt - before) + assertEquals(writeDelayMs, didWrittenAt - didStartedAt) + assertEquals(20L, firstEnvAt - didWrittenAt) + assertArrayEquals(byteArrayOf(0x31), f.sentPackets[2].payload) + assertEquals(listOf(0, 3, 6), f.sentPackets.filter { it.command == 9.toByte() } + .map { it.payload.first().toInt() }) + assertArrayEquals(byteArrayOf(6, 0), f.sentPackets.last { it.command == 9.toByte() }.payload) + } + + @Test fun cancellingDidEnvironmentGapDoesNotSendTheNextRequest() = runTest { + val f = Fixture(UnconfinedTestDispatcher(testScheduler)) + assertTrue(f.repository.connect("") is DctResult.Success) + f.sentPackets.clear() + val setup = async { f.repository.configureMeasurement(DctMeasurementConfiguration()) } + advanceTimeBy(119) + runCurrent() + assertEquals(listOf(0x40, 0x11, 0x17), f.sentPackets.map { it.command.toInt() and 255 }) + setup.cancel() + runCurrent() + advanceTimeBy(50) + runCurrent() + assertTrue(setup.isCancelled) + assertFalse(f.sentPackets.any { it.command == 9.toByte() || it.command == 0x20.toByte() }) + } + + @Test fun rejectedCanRegistrationDoesNotSendDidOrPostSetup() = runTest { + val f = Fixture(UnconfinedTestDispatcher(testScheduler)) + assertTrue(f.repository.connect("") is DctResult.Success) + f.sentPackets.clear() + f.reject = 0x11 + assertTrue(f.repository.configureMeasurement(DctMeasurementConfiguration()) is DctResult.Failure) + assertEquals(listOf(0x40, 0x11), f.sentPackets.map { it.command.toInt() and 255 }) + } + + @Test fun didWriteFailureStopsTheSetupBeforeEnvironmentRefresh() = runTest { + val f = Fixture(UnconfinedTestDispatcher(testScheduler)) + assertTrue(f.repository.connect("") is DctResult.Success) + f.sentPackets.clear() + coEvery { f.wifi.sendCommand(match { it.command == 0x17.toByte() }) } returns false + assertTrue(f.repository.configureMeasurement(DctMeasurementConfiguration()) is DctResult.Failure) + assertEquals(listOf(0x40, 0x11), f.sentPackets.map { it.command.toInt() and 255 }) + } + + @ParameterizedTest + @ValueSource(ints = [0, 49, 255]) + fun didPresetMaskIsSerializedWithoutOverwritingProjectIntent(mask: Int) = runTest { + val f = Fixture(UnconfinedTestDispatcher(testScheduler)) + assertTrue(f.repository.connect("") is DctResult.Success) + assertTrue(f.repository.configureMeasurement(DctMeasurementConfiguration(didPreSetting = mask)) is DctResult.Success) + assertArrayEquals(byteArrayOf(mask.toByte()), f.sentPackets.single { it.command == 0x17.toByte() }.payload) + } + + @Test fun explicitStartRejectionAndMalformedResponseKeepTcpWithoutPretendingToMeasure() = runTest { + val f = Fixture(UnconfinedTestDispatcher(testScheduler)) + assertTrue(f.repository.connect("") is DctResult.Success) + coEvery { f.wifi.sendCommand(match { it.command == 0x20.toByte() }) } coAnswers { + f.packets.emit(DctPacket(0xA0.toByte(), byteArrayOf(2, 1))) + true + } + assertTrue(f.repository.startStreaming(30) is DctResult.Failure) + assertEquals(DeviceConnectionState.READY, f.repository.connectionState.value) assertTrue(f.connected.value) + coEvery { f.wifi.sendCommand(match { it.command == 0x20.toByte() }) } coAnswers { + f.packets.emit(DctPacket(0xA0.toByte(), byteArrayOf(1))) + true + } + assertTrue(f.repository.startStreaming(30) is DctResult.Failure) + assertTrue(f.connected.value) + assertTrue(f.repository.isConnected.value) + assertFalse(f.repository.isStreaming.value) assertEquals(DeviceConnectionState.READY, f.repository.connectionState.value) + assertFalse(f.sentPackets.any { it.command == 0x21.toByte() || it.command == 0x0F.toByte() }) + coVerify(exactly = 0) { f.wifi.disconnectSuspend() } + } + + @Test fun configurationTimeoutAfterPlaybackKeepsTcpUntilExplicitDisconnectAndNewSessionResetsPolicy() = runTest { + val f = Fixture(UnconfinedTestDispatcher(testScheduler)) + f.sessionId = 7L + assertTrue(f.repository.connect("") is DctResult.Success) + coEvery { f.wifi.sendCommand(match { it.command == 0x20.toByte() }) } coAnswers { + f.sentPackets += firstArg() + f.packets.emit(DctPacket(0xA0.toByte(), byteArrayOf(1, 1))) + true + } + assertTrue(f.repository.startStreaming() is DctResult.Success) + assertTrue(f.repository.stopStreaming() is DctResult.Success) + f.silent = 9 + assertTrue(f.repository.configureMeasurement(DctMeasurementConfiguration()) is DctResult.Failure) + assertEquals(DeviceConnectionState.READY, f.repository.connectionState.value) + assertTrue(f.connected.value) + verify(exactly = 0) { f.wifi.invalidateConnectionIfSession(any()) } + + assertTrue(f.repository.disconnect() is DctResult.Success) + assertFalse(f.connected.value) + coVerify(exactly = 1) { f.wifi.disconnectSuspend() } + assertEquals(0x0F.toByte(), f.sentPackets.last().command) + + f.sessionId = 8L + f.connected.value = true + f.silent = null + assertTrue(f.repository.connect("") is DctResult.Success) + f.silent = 9 + assertTrue(f.repository.configureMeasurement(DctMeasurementConfiguration()) is DctResult.Failure) + assertFalse(f.connected.value) + assertEquals(DeviceConnectionState.ERROR, f.repository.connectionState.value) + verify(exactly = 1) { f.wifi.invalidateConnectionIfSession(8L) } } @Test fun stopTimeoutEndsLocalMeasurementButKeepsTransportAlive() = runTest { diff --git a/Android/app/src/test/java/com/dct/app/tests/RecordingElapsedTimeTest.kt b/Android/app/src/test/java/com/dct/app/tests/RecordingElapsedTimeTest.kt new file mode 100644 index 0000000..33296d3 --- /dev/null +++ b/Android/app/src/test/java/com/dct/app/tests/RecordingElapsedTimeTest.kt @@ -0,0 +1,22 @@ +package com.dct.app.tests + +import com.dct.app.presentation.monitor.components.formatRecordingElapsedTime +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.CsvSource + +class RecordingElapsedTimeTest { + @ParameterizedTest + @CsvSource( + "1000,1000,00:00:00", + "1000,1999,00:00:00", + "1000,2000,00:00:01", + "1000,61000,00:01:00", + "1000,3601000,01:00:00", + "1000,90062000,25:01:01", + "2000,1000,00:00:00" + ) + fun durationUsesRecordingStartAndDoesNotWrapAtOneDay(start: Long, now: Long, expected: String) { + assertEquals(expected, formatRecordingElapsedTime(start, now)) + } +} diff --git a/Android/app/src/test/java/com/dct/app/tests/SlidingRingBufferParserTest.kt b/Android/app/src/test/java/com/dct/app/tests/SlidingRingBufferParserTest.kt index 882262a..edab69c 100644 --- a/Android/app/src/test/java/com/dct/app/tests/SlidingRingBufferParserTest.kt +++ b/Android/app/src/test/java/com/dct/app/tests/SlidingRingBufferParserTest.kt @@ -216,4 +216,197 @@ class SlidingRingBufferParserTest { assertThat(result).hasSize(1) assertThat(result[0].command).isEqualTo(DctCommand.CMD_ENABLE.code) } + + private var nowNanos = 0L + private val recoveries = mutableListOf() + + private fun timedParser(capacity: Int = SlidingRingBufferParser.DEFAULT_BUFFER_CAPACITY): SlidingRingBufferParser = + SlidingRingBufferParser(bufferCapacity = capacity, clockNanos = { nowNanos }, onFrameRecovery = { recoveries.add(it) }) + + private fun control(command: DctCommand, payload: ByteArray = byteArrayOf()): ByteArray = + codec.encode(DctPacket.create(command, payload)) + + private fun stalledHeader(): ByteArray = byteArrayOf(0xA1.toByte(), 0x7F, 0x00, 0xA3.toByte(), 0x12, 0x34) + + @Test + fun staleLargeLengthDoesNotBlockHeartbeatOrStopAfterIdle() { + for (command in listOf(DctCommand.RES_POSSIBLE_TO_CONNECT, DctCommand.RES_STOP_TRANSMIT_DATA, + DctCommand.RES_START_TRANSMIT_DATA)) { + for (payload in if (command == DctCommand.RES_START_TRANSMIT_DATA) listOf(byteArrayOf(1, 1)) + else listOf(byteArrayOf(), byteArrayOf(1))) { + parser = timedParser() + assertThat(parser.parseStream(stalledHeader())).isEmpty() + nowNanos += SlidingRingBufferParser.RECOVERY_IDLE_NANOS + val result = parser.parseStream(control(command, payload)) + assertThat(result.map { it.command }).containsExactly(command.code) + assertThat(result.single().payload).isEqualTo(payload) + assertThat(parser.availableByteCount).isEqualTo(0) + assertThat(recoveries.last().expectedBytes).isEqualTo(0x7F00 + 5) + assertThat(recoveries.last().discardedBytes).isEqualTo(stalledHeader().size) + assertThat(recoveries.last().idleMillis).isEqualTo(2000) + assertThat(recoveries.last().blockedHeader).isEqualTo(stalledHeader()) + } + } + } + + @Test + fun recoveryAcceptsEveryControlSplitAndCoalescedFollowingDataInOrder() { + val stop = control(DctCommand.RES_STOP_TRANSMIT_DATA, byteArrayOf(1)) + val heartbeat = control(DctCommand.RES_POSSIBLE_TO_CONNECT) + val data = control(DctCommand.SEND_CAN_FRAME, byteArrayOf(0x7E, 0xA1.toByte(), 3)) + for (split in 1 until stop.size) { + parser = timedParser() + parser.parseStream(stalledHeader()) + nowNanos += SlidingRingBufferParser.RECOVERY_IDLE_NANOS + assertThat(parser.parseStream(stop.copyOfRange(0, split))).isEmpty() + val result = parser.parseStream(stop.copyOfRange(split, stop.size) + heartbeat + data) + assertThat(result.map { it.command }).containsExactly( + DctCommand.RES_STOP_TRANSMIT_DATA.code, DctCommand.RES_POSSIBLE_TO_CONNECT.code, + DctCommand.SEND_CAN_FRAME.code).inOrder() + assertThat(result.map { it.sequence }).containsExactly(0, 1, 2).inOrder() + } + } + + @Test + fun corruptAndWrongLengthControlsCannotTriggerRecovery() { + parser = timedParser() + parser.parseStream(stalledHeader()) + nowNanos += SlidingRingBufferParser.RECOVERY_IDLE_NANOS + val corrupt = control(DctCommand.RES_STOP_TRANSMIT_DATA, byteArrayOf(1)).also { + it[it.lastIndex - 1] = (it[it.lastIndex - 1].toInt() xor 1).toByte() + } + val invalidCandidates = corrupt + control(DctCommand.RES_POSSIBLE_TO_CONNECT, byteArrayOf(1, 2)) + + control(DctCommand.RES_START_TRANSMIT_DATA, byteArrayOf(1)) + + control(DctCommand.SEND_CAN_FRAME, byteArrayOf(1)) + assertThat(parser.parseStream(invalidCandidates)).isEmpty() + assertThat(recoveries).isEmpty() + val result = parser.parseStream(control(DctCommand.RES_STOP_TRANSMIT_DATA, byteArrayOf(1))) + assertThat(result.single().command).isEqualTo(DctCommand.RES_STOP_TRANSMIT_DATA.code) + assertThat(recoveries.single().discardedBytes).isEqualTo(stalledHeader().size + invalidCandidates.size) + } + + @Test + fun embeddedValidControlsInActiveFragmentedPayloadAreNeverScanned() { + parser = timedParser() + val payload = byteArrayOf(5, 6) + control(DctCommand.RES_STOP_TRANSMIT_DATA, byteArrayOf(1)) + + control(DctCommand.RES_POSSIBLE_TO_CONNECT) + ByteArray(6000) { 0x7E } + val frame = control(DctCommand.SEND_CAN_FRAME, payload) + val result = mutableListOf() + frame.forEach { byte -> + nowNanos += 1_000_000L // Total assembly >2s, but ingress never stalls. + result.addAll(parser.parseStream(byteArrayOf(byte))) + } + assertThat(result.single().payload).isEqualTo(payload) + assertThat(recoveries).isEmpty() + } + + @Test + fun preIdleEmbeddedControlsAreNotReinterpretedAfterGap() { + parser = timedParser() + val embedded = control(DctCommand.RES_STOP_TRANSMIT_DATA, byteArrayOf(1)) + val payload = embedded + ByteArray(100) { 0x33 } + val frame = control(DctCommand.SEND_CAN_FRAME, payload) + val split = 4 + embedded.size + parser.parseStream(frame.copyOfRange(0, split)) + nowNanos += SlidingRingBufferParser.RECOVERY_IDLE_NANOS + assertThat(parser.parseStream(frame.copyOfRange(split, split + 10))).isEmpty() + assertThat(parser.parseStream(frame.copyOfRange(split + 10, frame.size)).single().payload).isEqualTo(payload) + assertThat(recoveries).isEmpty() + } + + @Test + fun completedOriginalFrameWinsEvenWithPostIdleEmbeddedControl() { + parser = timedParser() + val payload = ByteArray(20) { 3 } + control(DctCommand.RES_STOP_TRANSMIT_DATA, byteArrayOf(1)) + val frame = control(DctCommand.SEND_CAN_FRAME, payload) + parser.parseStream(frame.copyOfRange(0, 24)) + nowNanos += SlidingRingBufferParser.RECOVERY_IDLE_NANOS + assertThat(parser.parseStream(frame.copyOfRange(24, frame.size)).single().payload).isEqualTo(payload) + assertThat(recoveries).isEmpty() + } + + @Test + fun idleGapAloneDoesNotDiscardMaximumFrame() { + parser = timedParser() + val payload = ByteArray(DctPacketCodec.MAX_PAYLOAD_LENGTH) { (it % 251).toByte() } + val frame = control(DctCommand.SEND_CAN_FRAME, payload) + parser.parseStream(frame.copyOfRange(0, 100)) + nowNanos += SlidingRingBufferParser.RECOVERY_IDLE_NANOS * 10 + assertThat(parser.parseStream(frame.copyOfRange(100, 200))).isEmpty() + assertThat(parser.availableByteCount).isEqualTo(200) + assertThat(parser.parseStream(frame.copyOfRange(200, frame.size)).single().payload).isEqualTo(payload) + assertThat(recoveries).isEmpty() + } + + @Test + fun idleThresholdIsNotRoundedUpAndEmptyCallsDoNotCountAsIngress() { + parser = timedParser() + parser.parseStream(stalledHeader()) + nowNanos += SlidingRingBufferParser.RECOVERY_IDLE_NANOS - 1 + assertThat(parser.parseStream(control(DctCommand.RES_POSSIBLE_TO_CONNECT))).isEmpty() + assertThat(recoveries).isEmpty() + nowNanos += SlidingRingBufferParser.RECOVERY_IDLE_NANOS + parser.parseStream(byteArrayOf()) + assertThat(parser.parseStream(control(DctCommand.RES_STOP_TRANSMIT_DATA)).single().command) + .isEqualTo(DctCommand.RES_STOP_TRANSMIT_DATA.code) + } + + @Test + fun recoveryAcrossRingWrapKeepsFollowingFramesAndResetClearsOldBoundary() { + parser = timedParser(capacity = 64) + parser.parseStream(ByteArray(59)) // Put the stale header across the ring end. + val shortStall = byteArrayOf(0xA1.toByte(), 0, 55, 0xA3.toByte(), 2, 3) + parser.parseStream(shortStall) + nowNanos += SlidingRingBufferParser.RECOVERY_IDLE_NANOS + val heartbeat = control(DctCommand.RES_POSSIBLE_TO_CONNECT) + assertThat(parser.parseStream(heartbeat + heartbeat)).hasSize(2) + assertThat(recoveries).hasSize(1) + parser.parseStream(shortStall) + nowNanos += SlidingRingBufferParser.RECOVERY_IDLE_NANOS + parser.parseStream(heartbeat.copyOfRange(0, 3)) + parser.reset() + val payload = heartbeat + ByteArray(30) { 4 } + val frame = control(DctCommand.SEND_CAN_FRAME, payload) + assertThat(parser.parseStream(frame.copyOfRange(0, 4 + heartbeat.size))).isEmpty() + assertThat(parser.parseStream(frame.copyOfRange(4 + heartbeat.size, frame.size)).single().payload).isEqualTo(payload) + assertThat(recoveries).hasSize(1) + } + + @Test + fun fragmentedRecoveryControlCanStraddleTheRingEnd() { + parser = timedParser(capacity = 64) + parser.parseStream(ByteArray(53)) + parser.parseStream(byteArrayOf(0xA1.toByte(), 0, 55, 0xA3.toByte(), 2, 3)) + nowNanos += SlidingRingBufferParser.RECOVERY_IDLE_NANOS + val stop = control(DctCommand.RES_STOP_TRANSMIT_DATA, byteArrayOf(1)) + assertThat(parser.parseStream(stop.copyOfRange(0, 5))).isEmpty() + assertThat(parser.parseStream(stop.copyOfRange(5, stop.size)).single().command) + .isEqualTo(DctCommand.RES_STOP_TRANSMIT_DATA.code) + assertThat(recoveries).hasSize(1) + } + + @Test + fun recoveryDoesNotRevalidateTheEntireAccumulatedBufferOnEachRead() { + val delegate = com.dct.app.data.protocol.DefaultChecksumCalculator() + var verifications = 0 + val countingChecksum = object : com.dct.app.data.protocol.ChecksumCalculator { + override fun calculate(data: ByteArray, offset: Int, length: Int): Byte = delegate.calculate(data, offset, length) + override fun verify(packet: ByteArray, offset: Int, length: Int): Boolean { + verifications++ + return delegate.verify(packet, offset, length) + } + } + parser = SlidingRingBufferParser(codec = DctPacketCodec(countingChecksum), clockNanos = { nowNanos }) + parser.parseStream(stalledHeader()) + nowNanos += SlidingRingBufferParser.RECOVERY_IDLE_NANOS + val corrupt = control(DctCommand.RES_STOP_TRANSMIT_DATA, byteArrayOf(1)).also { + it[it.lastIndex - 1] = (it[it.lastIndex - 1].toInt() xor 1).toByte() + } + val candidates = ByteArrayOutputStream().apply { repeat(100) { write(corrupt) } }.toByteArray() + assertThat(parser.parseStream(candidates)).isEmpty() + assertThat(verifications).isEqualTo(100) + repeat(1000) { assertThat(parser.parseStream(byteArrayOf(0))).isEmpty() } + assertThat(verifications).isAtMost(102) + assertThat(parser.parseStream(control(DctCommand.RES_STOP_TRANSMIT_DATA))).hasSize(1) + } } diff --git a/Android/app/src/test/java/com/dct/app/tests/WifiConnectionGenerationTest.kt b/Android/app/src/test/java/com/dct/app/tests/WifiConnectionGenerationTest.kt index 87944d5..f955f1a 100644 --- a/Android/app/src/test/java/com/dct/app/tests/WifiConnectionGenerationTest.kt +++ b/Android/app/src/test/java/com/dct/app/tests/WifiConnectionGenerationTest.kt @@ -7,6 +7,33 @@ import org.junit.jupiter.api.Test import java.net.Socket class WifiConnectionGenerationTest { + @Test fun livenessTimeoutClosesOnlyItsOwnSession() { + val manager = WifiSocketManager() + val socket = Socket() // No network activity. + WifiSocketManager::class.java.getDeclaredField("activeSocket").also { it.isAccessible = true } + .set(manager, socket) + WifiSocketManager::class.java.getDeclaredField("currentSessionId").also { it.isAccessible = true } + .setLong(manager, 7L) + @Suppress("UNCHECKED_CAST") + val connected = WifiSocketManager::class.java.getDeclaredField("_isConnected") + .also { it.isAccessible = true }.get(manager) as kotlinx.coroutines.flow.MutableStateFlow + connected.value = true + try { + assertFalse(manager.invalidateConnectionIfSession(6L)) + assertFalse(manager.invalidateConnectionIfSession(0L)) + assertTrue(manager.isConnected.value) + assertFalse(socket.isClosed) + assertEquals(7L, manager.currentSessionId) + assertTrue(manager.invalidateConnectionIfSession(7L)) + assertTrue(socket.isClosed) + assertFalse(manager.isConnected.value) + assertEquals(0L, manager.currentSessionId) + assertFalse(manager.invalidateConnectionIfSession(7L)) + } finally { + manager.invalidateConnection() + } + } + private fun next(manager: WifiSocketManager): Long = WifiSocketManager::class.java .getDeclaredMethod("nextConnectionGeneration").also { it.isAccessible = true }.invoke(manager) as Long private fun attach(manager: WifiSocketManager, socket: Socket, generation: Long): Boolean = WifiSocketManager::class.java